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

@@ -4,3 +4,4 @@ export 'src/domain/arguments/usecase_argument.dart';
export 'src/domain/usecases/usecase.dart';
export 'src/utils/date_time_utils.dart';
export 'src/presentation/widgets/web_mobile_frame.dart';
export 'src/config/app_config.dart';

View File

@@ -0,0 +1,5 @@
class AppConfig {
AppConfig._();
static const String googlePlacesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY');
}

View File

@@ -1,4 +1,6 @@
import 'package:krow_core/krow_core.dart';
class HubsConstants {
static const String googlePlacesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY');
static const String googlePlacesApiKey = AppConfig.googlePlacesApiKey;
static const List<String> supportedCountries = <String>['us'];
}

View File

@@ -255,20 +255,12 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState> {
}
int _calculateCategoryCount(String category) {
if (state.selectedDate == null) return 0;
final String selectedDateStr = DateFormat(
'yyyy-MM-dd',
).format(state.selectedDate!);
final List<OrderItem> ordersOnDate = state.orders
.where((OrderItem s) => s.date == selectedDateStr)
.toList();
if (category == 'active') {
return ordersOnDate
return state.orders
.where((OrderItem s) => s.status == 'IN_PROGRESS')
.length;
} else if (category == 'completed') {
return ordersOnDate
return state.orders
.where((OrderItem s) => s.status == 'COMPLETED')
.length;
}
@@ -276,14 +268,7 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState> {
}
int _calculateUpNextCount() {
if (state.selectedDate == null) return 0;
final String selectedDateStr = DateFormat(
'yyyy-MM-dd',
).format(state.selectedDate!);
final List<OrderItem> ordersOnDate = state.orders
.where((OrderItem s) => s.date == selectedDateStr)
.toList();
return ordersOnDate
return state.orders
.where(
(OrderItem s) =>
// TODO(orders): move PENDING to its own tab once available.

View File

@@ -0,0 +1,82 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:view_orders/src/presentation/blocs/view_orders_cubit.dart';
import 'package:view_orders/src/presentation/blocs/view_orders_state.dart';
import 'package:view_orders/src/domain/usecases/get_orders_use_case.dart';
import 'package:view_orders/src/domain/usecases/get_accepted_applications_for_day_use_case.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:view_orders/src/domain/arguments/orders_range_arguments.dart';
import 'package:view_orders/src/domain/arguments/orders_day_arguments.dart';
class MockGetOrdersUseCase extends Mock implements GetOrdersUseCase {}
class MockGetAcceptedAppsUseCase extends Mock implements GetAcceptedApplicationsForDayUseCase {}
void main() {
group('ViewOrdersCubit', () {
late GetOrdersUseCase getOrdersUseCase;
late GetAcceptedApplicationsForDayUseCase getAcceptedAppsUseCase;
setUp(() {
getOrdersUseCase = MockGetOrdersUseCase();
getAcceptedAppsUseCase = MockGetAcceptedAppsUseCase();
registerFallbackValue(OrdersRangeArguments(start: DateTime.now(), end: DateTime.now()));
registerFallbackValue(OrdersDayArguments(day: DateTime.now()));
});
test('initial state is correct', () {
final cubit = ViewOrdersCubit(
getOrdersUseCase: getOrdersUseCase,
getAcceptedAppsUseCase: getAcceptedAppsUseCase,
);
expect(cubit.state.status, ViewOrdersStatus.initial);
cubit.close();
});
blocTest<ViewOrdersCubit, ViewOrdersState>(
'calculates upNextCount based on ALL loaded orders, not just the selected day',
build: () {
final mockOrders = [
// Order 1: Today (Matches selected date)
OrderItem(
id: '1', orderId: '1', title: 'Order 1', clientName: 'Client',
status: 'OPEN', date: '2026-02-04', startTime: '09:00', endTime: '17:00',
location: 'Loc', locationAddress: 'Addr', filled: 0, workersNeeded: 1,
hourlyRate: 20, hours: 8, totalValue: 160
),
// Order 2: Tomorrow (Different date)
OrderItem(
id: '2', orderId: '2', title: 'Order 2', clientName: 'Client',
status: 'OPEN', date: '2026-02-05', startTime: '09:00', endTime: '17:00',
location: 'Loc', locationAddress: 'Addr', filled: 0, workersNeeded: 1,
hourlyRate: 20, hours: 8, totalValue: 160
),
];
when(() => getOrdersUseCase(any())).thenAnswer((_) async => mockOrders);
when(() => getAcceptedAppsUseCase(any())).thenAnswer((_) async => {});
return ViewOrdersCubit(
getOrdersUseCase: getOrdersUseCase,
getAcceptedAppsUseCase: getAcceptedAppsUseCase,
);
},
act: (cubit) async {
// Wait for init to trigger load
await Future.delayed(const Duration(milliseconds: 100));
// Select 'Today' (2026-02-04 matches Order 1)
cubit.selectDate(DateTime(2026, 02, 04));
},
verify: (cubit) {
// Assert:
// 1. filteredOrders should only have 1 order (the one for the selected date)
expect(cubit.state.filteredOrders.length, 1, reason: 'Should only show orders for selected filtered date');
expect(cubit.state.filteredOrders.first.id, '1');
// 2. upNextCount should have 2 orders (Total for the loaded week)
expect(cubit.state.upNextCount, 2, reason: 'Up Next count should include ALL orders in the week range');
},
);
});
}

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

View File

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

View File

@@ -18,6 +18,7 @@ dependencies:
firebase_core: ^4.2.1
firebase_auth: ^6.1.2 # Updated for compatibility
firebase_data_connect: ^0.2.2+1
http: ^1.2.0
# Architecture Packages
krow_domain: