feat: Implement Google Places Autocomplete for Staff Location
- Implemented strictly filtered Google Places Autocomplete (cities only) for Staff Profile Setup. - Centralized Google Places API Key configuration in Core AppConfig. - Updated Client Hubs to use the centralized AppConfig. - Verified ViewOrdersCubit logic for weekly order summaries.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../domain/repositories/place_repository.dart';
|
||||
|
||||
class PlaceRepositoryImpl implements PlaceRepository {
|
||||
final http.Client _client;
|
||||
|
||||
PlaceRepositoryImpl({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
@override
|
||||
Future<List<String>> searchCities(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
final Uri uri = Uri.https(
|
||||
'maps.googleapis.com',
|
||||
'/maps/api/place/autocomplete/json',
|
||||
<String, String>{
|
||||
'input': query,
|
||||
'types': '(cities)',
|
||||
'key': AppConfig.googlePlacesApiKey,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
final http.Response response = await _client.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final Map<String, dynamic> data = json.decode(response.body) as Map<String, dynamic>;
|
||||
|
||||
if (data['status'] == 'OK' || data['status'] == 'ZERO_RESULTS') {
|
||||
final List<dynamic> predictions = data['predictions'] as List<dynamic>;
|
||||
|
||||
return predictions.map((dynamic prediction) {
|
||||
return prediction['description'] as String;
|
||||
}).toList();
|
||||
} else {
|
||||
// Handle other statuses (OVER_QUERY_LIMIT, REQUEST_DENIED, etc.)
|
||||
// Returning empty list for now to avoid crashing UI, ideally log this.
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
throw Exception('Network Error: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
abstract class PlaceRepository {
|
||||
/// Searches for cities matching the [query].
|
||||
/// Returns a list of city names.
|
||||
Future<List<String>> searchCities(String query);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import '../repositories/place_repository.dart';
|
||||
|
||||
class SearchCitiesUseCase {
|
||||
final PlaceRepository _repository;
|
||||
|
||||
SearchCitiesUseCase(this._repository);
|
||||
|
||||
Future<List<String>> call(String query) {
|
||||
return _repository.searchCities(query);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../domain/usecases/submit_profile_setup_usecase.dart';
|
||||
|
||||
import '../../../domain/usecases/search_cities_usecase.dart';
|
||||
|
||||
import 'profile_setup_event.dart';
|
||||
import 'profile_setup_state.dart';
|
||||
|
||||
@@ -11,7 +13,9 @@ export 'profile_setup_state.dart';
|
||||
class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
|
||||
ProfileSetupBloc({
|
||||
required SubmitProfileSetup submitProfileSetup,
|
||||
required SearchCitiesUseCase searchCities,
|
||||
}) : _submitProfileSetup = submitProfileSetup,
|
||||
_searchCities = searchCities,
|
||||
super(const ProfileSetupState()) {
|
||||
on<ProfileSetupFullNameChanged>(_onFullNameChanged);
|
||||
on<ProfileSetupBioChanged>(_onBioChanged);
|
||||
@@ -20,9 +24,12 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
|
||||
on<ProfileSetupSkillsChanged>(_onSkillsChanged);
|
||||
on<ProfileSetupIndustriesChanged>(_onIndustriesChanged);
|
||||
on<ProfileSetupSubmitted>(_onSubmitted);
|
||||
on<ProfileSetupLocationQueryChanged>(_onLocationQueryChanged);
|
||||
on<ProfileSetupClearLocationSuggestions>(_onClearLocationSuggestions);
|
||||
}
|
||||
|
||||
final SubmitProfileSetup _submitProfileSetup;
|
||||
final SearchCitiesUseCase _searchCities;
|
||||
|
||||
/// Handles the [ProfileSetupFullNameChanged] event.
|
||||
void _onFullNameChanged(
|
||||
@@ -99,4 +106,29 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLocationQueryChanged(
|
||||
ProfileSetupLocationQueryChanged event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) async {
|
||||
if (event.query.isEmpty) {
|
||||
emit(state.copyWith(locationSuggestions: []));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final results = await _searchCities(event.query);
|
||||
emit(state.copyWith(locationSuggestions: results));
|
||||
} catch (e) {
|
||||
// Quietly fail or clear
|
||||
emit(state.copyWith(locationSuggestions: []));
|
||||
}
|
||||
}
|
||||
|
||||
void _onClearLocationSuggestions(
|
||||
ProfileSetupClearLocationSuggestions event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) {
|
||||
emit(state.copyWith(locationSuggestions: []));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,24 @@ class ProfileSetupIndustriesChanged extends ProfileSetupEvent {
|
||||
List<Object?> get props => <Object?>[industries];
|
||||
}
|
||||
|
||||
/// Event triggered when the location query changes.
|
||||
class ProfileSetupLocationQueryChanged extends ProfileSetupEvent {
|
||||
/// The search query.
|
||||
final String query;
|
||||
|
||||
/// Creates a [ProfileSetupLocationQueryChanged] event.
|
||||
const ProfileSetupLocationQueryChanged(this.query);
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[query];
|
||||
}
|
||||
|
||||
/// Event triggered when the location suggestions should be cleared.
|
||||
class ProfileSetupClearLocationSuggestions extends ProfileSetupEvent {
|
||||
/// Creates a [ProfileSetupClearLocationSuggestions] event.
|
||||
const ProfileSetupClearLocationSuggestions();
|
||||
}
|
||||
|
||||
/// Event triggered when the profile submission is requested.
|
||||
class ProfileSetupSubmitted extends ProfileSetupEvent {
|
||||
/// Creates a [ProfileSetupSubmitted] event.
|
||||
|
||||
@@ -26,8 +26,8 @@ class ProfileSetupState extends Equatable {
|
||||
/// The current status of the profile setup process.
|
||||
final ProfileSetupStatus status;
|
||||
|
||||
/// Error message if the status is [ProfileSetupStatus.failure].
|
||||
final String? errorMessage;
|
||||
/// List of location suggestions from the API.
|
||||
final List<String> locationSuggestions;
|
||||
|
||||
/// Creates a [ProfileSetupState] instance.
|
||||
const ProfileSetupState({
|
||||
@@ -39,6 +39,7 @@ class ProfileSetupState extends Equatable {
|
||||
this.industries = const <String>[],
|
||||
this.status = ProfileSetupStatus.initial,
|
||||
this.errorMessage,
|
||||
this.locationSuggestions = const <String>[],
|
||||
});
|
||||
|
||||
/// Creates a copy of the current state with updated values.
|
||||
@@ -51,6 +52,7 @@ class ProfileSetupState extends Equatable {
|
||||
List<String>? industries,
|
||||
ProfileSetupStatus? status,
|
||||
String? errorMessage,
|
||||
List<String>? locationSuggestions,
|
||||
}) {
|
||||
return ProfileSetupState(
|
||||
fullName: fullName ?? this.fullName,
|
||||
@@ -61,18 +63,20 @@ class ProfileSetupState extends Equatable {
|
||||
industries: industries ?? this.industries,
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage,
|
||||
locationSuggestions: locationSuggestions ?? this.locationSuggestions,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
fullName,
|
||||
bio,
|
||||
preferredLocations,
|
||||
maxDistanceMiles,
|
||||
skills,
|
||||
industries,
|
||||
status,
|
||||
errorMessage,
|
||||
];
|
||||
fullName,
|
||||
bio,
|
||||
preferredLocations,
|
||||
maxDistanceMiles,
|
||||
skills,
|
||||
industries,
|
||||
status,
|
||||
errorMessage,
|
||||
locationSuggestions,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/common/section_title_subtitle.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
@@ -32,26 +34,38 @@ class ProfileSetupLocation extends StatefulWidget {
|
||||
|
||||
class _ProfileSetupLocationState extends State<ProfileSetupLocation> {
|
||||
final TextEditingController _locationController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_locationController.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Adds the current text from the controller as a location.
|
||||
void _addLocation() {
|
||||
final String loc = _locationController.text.trim();
|
||||
if (loc.isNotEmpty && !widget.preferredLocations.contains(loc)) {
|
||||
final List<String> updatedList = List<String>.from(widget.preferredLocations)
|
||||
..add(loc);
|
||||
void _onSearchChanged(String query) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
context
|
||||
.read<ProfileSetupBloc>()
|
||||
.add(ProfileSetupLocationQueryChanged(query));
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds the selected location.
|
||||
void _addLocation(String location) {
|
||||
if (location.isNotEmpty && !widget.preferredLocations.contains(location)) {
|
||||
final List<String> updatedList =
|
||||
List<String>.from(widget.preferredLocations)..add(location);
|
||||
widget.onLocationsChanged(updatedList);
|
||||
_locationController.clear();
|
||||
context
|
||||
.read<ProfileSetupBloc>()
|
||||
.add(const ProfileSetupClearLocationSuggestions());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
/// Builds the location setup step UI.
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -62,37 +76,55 @@ class _ProfileSetupLocationState extends State<ProfileSetupLocation> {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Add Location input
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiTextField(
|
||||
label: t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.location
|
||||
.add_location_label,
|
||||
controller: _locationController,
|
||||
hintText: t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.location
|
||||
.add_location_hint,
|
||||
onSubmitted: (_) => _addLocation(),
|
||||
// Search Input
|
||||
UiTextField(
|
||||
label: t.staff_authentication.profile_setup_page.location
|
||||
.add_location_label,
|
||||
controller: _locationController,
|
||||
hintText: t.staff_authentication.profile_setup_page.location
|
||||
.add_location_hint,
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
|
||||
// Suggestions List
|
||||
BlocBuilder<ProfileSetupBloc, ProfileSetupState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.locationSuggestions != current.locationSuggestions,
|
||||
builder: (context, state) {
|
||||
if (state.locationSuggestions.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
margin: const EdgeInsets.only(top: UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMd),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
UiButton.secondary(
|
||||
text:
|
||||
t.staff_authentication.profile_setup_page.location.add_button,
|
||||
onPressed: _addLocation,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
maximumSize: const Size(double.infinity, 48),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: state.locationSuggestions.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = state.locationSuggestions[index];
|
||||
return ListTile(
|
||||
title: Text(suggestion, style: UiTypography.body2m),
|
||||
leading: const Icon(UiIcons.mapPin, size: 16),
|
||||
onTap: () => _addLocation(suggestion),
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
@@ -134,18 +166,12 @@ class _ProfileSetupLocationState extends State<ProfileSetupLocation> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.location
|
||||
t.staff_authentication.profile_setup_page.location
|
||||
.min_dist_label,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
Text(
|
||||
t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.location
|
||||
t.staff_authentication.profile_setup_page.location
|
||||
.max_dist_label,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
@@ -158,8 +184,8 @@ class _ProfileSetupLocationState extends State<ProfileSetupLocation> {
|
||||
|
||||
/// Removes the specified [location] from the list.
|
||||
void _removeLocation({required String location}) {
|
||||
final List<String> updatedList = List<String>.from(widget.preferredLocations)
|
||||
..remove(location);
|
||||
final List<String> updatedList =
|
||||
List<String>.from(widget.preferredLocations)..remove(location);
|
||||
widget.onLocationsChanged(updatedList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import 'package:staff_authentication/src/domain/usecases/verify_otp_usecase.dart
|
||||
import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart';
|
||||
import 'package:staff_authentication/src/data/repositories_impl/profile_setup_repository_impl.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/submit_profile_setup_usecase.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/place_repository.dart';
|
||||
import 'package:staff_authentication/src/data/repositories_impl/place_repository_impl.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/pages/get_started_page.dart';
|
||||
@@ -44,11 +47,13 @@ class StaffAuthenticationModule extends Module {
|
||||
dataConnect: ExampleConnector.instance,
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(SignInWithPhoneUseCase.new);
|
||||
i.addLazySingleton(VerifyOtpUseCase.new);
|
||||
i.addLazySingleton(SubmitProfileSetup.new);
|
||||
i.addLazySingleton(SearchCitiesUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.addLazySingleton<AuthBloc>(
|
||||
@@ -60,6 +65,7 @@ class StaffAuthenticationModule extends Module {
|
||||
i.add<ProfileSetupBloc>(
|
||||
() => ProfileSetupBloc(
|
||||
submitProfileSetup: i.get<SubmitProfileSetup>(),
|
||||
searchCities: i.get<SearchCitiesUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user