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:
2026-02-04 12:30:20 +05:30
parent 41b808d196
commit 1ba83e3ea6
15 changed files with 303 additions and 76 deletions

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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: []));
}
}

View File

@@ -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.

View File

@@ -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,
];
}

View File

@@ -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);
}
}