feat: Implement preferred locations feature with search and display components

This commit is contained in:
Achintha Isuru
2026-03-01 12:02:43 -05:00
parent 2c61baaaa9
commit 1e1dc39e20
7 changed files with 364 additions and 321 deletions

View File

@@ -56,9 +56,9 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
'email': staff.email,
'phone': staff.phone,
'preferredLocations':
staff.address != null
? <String>[staff.address!]
: <String>[], // TODO: Map correctly when Staff entity supports list
staff.preferredLocations != null
? List<String>.from(staff.preferredLocations!)
: <String>[],
'avatar': staff.avatar,
};
@@ -111,8 +111,8 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
'email': updatedStaff.email,
'phone': updatedStaff.phone,
'preferredLocations':
updatedStaff.address != null
? <String>[updatedStaff.address!]
updatedStaff.preferredLocations != null
? List<String>.from(updatedStaff.preferredLocations!)
: <String>[],
'avatar': updatedStaff.avatar,
};

View File

@@ -4,13 +4,15 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:google_places_flutter/google_places_flutter.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_core/core.dart';
import '../blocs/personal_info_bloc.dart';
import '../blocs/personal_info_event.dart';
import '../blocs/personal_info_state.dart';
import '../widgets/preferred_locations_page/places_search_field.dart';
import '../widgets/preferred_locations_page/locations_list.dart';
import '../widgets/preferred_locations_page/empty_locations_state.dart';
/// The maximum number of preferred locations a staff member can add.
const int _kMaxLocations = 5;
@@ -80,7 +82,6 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
message: i18n.preferred_locations.save_success,
type: UiSnackbarType.success,
);
Navigator.of(context).pop();
} else if (state.status == PersonalInfoStatus.error) {
UiSnackbar.show(
context,
@@ -98,25 +99,14 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
return Scaffold(
backgroundColor: UiColors.background,
appBar: AppBar(
backgroundColor: UiColors.bgPopup,
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
onPressed: () => Navigator.of(context).pop(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
),
title: Text(
i18n.preferred_locations.title,
style: UiTypography.title1m.textPrimary,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: UiColors.border, height: 1.0),
),
appBar: UiAppBar(
title: i18n.preferred_locations.title,
showBackButton: true,
),
body: SafeArea(
child: Column(
body: Stack(
children: [
SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// ── Description
@@ -138,7 +128,7 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: _PlacesSearchField(
child: PlacesSearchField(
controller: _searchController,
focusNode: _searchFocusNode,
hint: i18n.preferred_locations.search_hint,
@@ -187,11 +177,11 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
const SizedBox(height: UiConstants.space3),
// ── Locations list / empty state
// Locations list / empty state
Expanded(
child: locations.isEmpty
? _EmptyLocationsState(message: i18n.preferred_locations.empty_state)
: _LocationsList(
? EmptyLocationsState(message: i18n.preferred_locations.empty_state)
: LocationsList(
locations: locations,
isSaving: isSaving,
removeTooltip: i18n.preferred_locations.remove_tooltip,
@@ -199,19 +189,42 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
),
),
// ── Save button
// Save button
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: UiButton.primary(
text: i18n.preferred_locations.save_button,
text: isSaving ? null : i18n.preferred_locations.save_button,
fullWidth: true,
onPressed: isSaving ? null : () => _save(context, bloc, state),
child: isSaving
? const SizedBox(
height: UiConstants.iconMd,
width: UiConstants.iconMd,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
UiColors.white,
),
),
)
: null,
),
),
],
),
),
);
if (isSaving)
Container(
color: UiColors.black.withValues(alpha: 0.3),
child: const Center(
child: CircularProgressIndicator(
color: UiColors.primary,
),
),
),
],
),
);
},
),
);
@@ -225,291 +238,3 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Subwidgets
// ─────────────────────────────────────────────────────────────────────────────
/// Google Places autocomplete search field, locked to US results.
class _PlacesSearchField extends StatelessWidget {
const _PlacesSearchField({
required this.controller,
required this.focusNode,
required this.hint,
required this.onSelected,
this.enabled = true,
});
final TextEditingController controller;
final FocusNode focusNode;
final String hint;
final bool enabled;
final void Function(Prediction) onSelected;
@override
Widget build(BuildContext context) {
return GooglePlaceAutoCompleteTextField(
textEditingController: controller,
focusNode: focusNode,
googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 400,
countries: const <String>['us'],
isLatLngRequired: false,
getPlaceDetailWithLatLng: onSelected,
itemClick: (Prediction prediction) {
controller.text = prediction.description ?? '';
controller.selection = TextSelection.fromPosition(
TextPosition(offset: controller.text.length),
);
onSelected(prediction);
},
inputDecoration: InputDecoration(
hintText: hint,
hintStyle: UiTypography.body2r.textSecondary,
prefixIcon: const Icon(UiIcons.search, color: UiColors.iconSecondary, size: 20),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(UiIcons.close, size: 18, color: UiColors.iconSecondary),
onPressed: controller.clear,
)
: null,
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.primary, width: 1.5),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
),
fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
filled: true,
),
textStyle: UiTypography.body2r.textPrimary,
itemBuilder: (BuildContext context, int index, Prediction prediction) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space2,
),
child: Row(
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4.0),
),
child: const Icon(UiIcons.mapPin, size: 16, color: UiColors.primary),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
_mainText(prediction.description ?? ''),
style: UiTypography.body2m.textPrimary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_subText(prediction.description ?? '').isNotEmpty)
Text(
_subText(prediction.description ?? ''),
style: UiTypography.footnote1r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
},
);
}
/// Extracts text before first comma as the primary line.
String _mainText(String description) {
final int commaIndex = description.indexOf(',');
return commaIndex > 0 ? description.substring(0, commaIndex) : description;
}
/// Extracts text after first comma as the secondary line.
String _subText(String description) {
final int commaIndex = description.indexOf(',');
return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : '';
}
}
/// The scrollable list of location chips.
class _LocationsList extends StatelessWidget {
const _LocationsList({
required this.locations,
required this.isSaving,
required this.removeTooltip,
required this.onRemove,
});
final List<String> locations;
final bool isSaving;
final String removeTooltip;
final void Function(String) onRemove;
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
itemCount: locations.length,
separatorBuilder: (_, __) => const SizedBox(height: UiConstants.space2),
itemBuilder: (BuildContext context, int index) {
final String location = locations[index];
return _LocationChip(
label: location,
index: index + 1,
total: locations.length,
isSaving: isSaving,
removeTooltip: removeTooltip,
onRemove: () => onRemove(location),
);
},
);
}
}
/// A single location row with pin icon, label, and remove button.
class _LocationChip extends StatelessWidget {
const _LocationChip({
required this.label,
required this.index,
required this.total,
required this.isSaving,
required this.removeTooltip,
required this.onRemove,
});
final String label;
final int index;
final int total;
final bool isSaving;
final String removeTooltip;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
// Index badge
Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Text(
'$index',
style: UiTypography.footnote1m.copyWith(color: UiColors.primary),
),
),
const SizedBox(width: UiConstants.space3),
// Pin icon
const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space2),
// Location text
Expanded(
child: Text(
label,
style: UiTypography.body2m.textPrimary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Remove button
if (!isSaving)
Tooltip(
message: removeTooltip,
child: GestureDetector(
onTap: onRemove,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(UiConstants.space1),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: UiColors.bgSecondary,
shape: BoxShape.circle,
),
child: const Icon(UiIcons.close, size: 14, color: UiColors.iconSecondary),
),
),
),
),
],
),
);
}
}
/// Shows when no locations have been added yet.
class _EmptyLocationsState extends StatelessWidget {
const _EmptyLocationsState({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary),
),
const SizedBox(height: UiConstants.space4),
Text(
message,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
],
),
),
);
}
}

View File

@@ -80,7 +80,9 @@ class PersonalInfoForm extends StatelessWidget {
enabled: enabled,
keyboardType: TextInputType.phone,
),
const SizedBox(height: UiConstants.space4),
const SizedBox(height: UiConstants.space6),
const Divider(),
const SizedBox(height: UiConstants.space6),
TappableRow(
value: locationSummary,
hint: i18n.locations_hint,

View File

@@ -0,0 +1,38 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shows when no locations have been added yet.
class EmptyLocationsState extends StatelessWidget {
const EmptyLocationsState({super.key, required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary),
),
const SizedBox(height: UiConstants.space4),
Text(
message,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,95 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A single location row with pin icon, label, and remove button.
class LocationChip extends StatelessWidget {
const LocationChip({
super.key,
required this.label,
required this.index,
required this.total,
required this.isSaving,
required this.removeTooltip,
required this.onRemove,
});
final String label;
final int index;
final int total;
final bool isSaving;
final String removeTooltip;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
// Index badge
Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Text(
'$index',
style: UiTypography.footnote1m.copyWith(color: UiColors.primary),
),
),
const SizedBox(width: UiConstants.space3),
// Pin icon
const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space2),
// Location text
Expanded(
child: Text(
label,
style: UiTypography.body2m.textPrimary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Remove button
if (!isSaving)
Tooltip(
message: removeTooltip,
child: GestureDetector(
onTap: onRemove,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(UiConstants.space1),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: UiColors.bgSecondary,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.close,
size: 14,
color: UiColors.iconSecondary,
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'location_chip.dart';
/// The scrollable list of location chips.
class LocationsList extends StatelessWidget {
const LocationsList({
super.key,
required this.locations,
required this.isSaving,
required this.removeTooltip,
required this.onRemove,
});
final List<String> locations;
final bool isSaving;
final String removeTooltip;
final void Function(String) onRemove;
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
itemCount: locations.length,
separatorBuilder: (_, _) => const SizedBox(height: UiConstants.space2),
itemBuilder: (BuildContext context, int index) {
final String location = locations[index];
return LocationChip(
label: location,
index: index + 1,
total: locations.length,
isSaving: isSaving,
removeTooltip: removeTooltip,
onRemove: () => onRemove(location),
);
},
);
}
}

View File

@@ -0,0 +1,143 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:google_places_flutter/google_places_flutter.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_core/core.dart';
/// Google Places autocomplete search field, locked to US results.
class PlacesSearchField extends StatelessWidget {
const PlacesSearchField({
super.key,
required this.controller,
required this.focusNode,
required this.hint,
required this.onSelected,
this.enabled = true,
});
final TextEditingController controller;
final FocusNode focusNode;
final String hint;
final bool enabled;
final void Function(Prediction) onSelected;
/// Extracts text before first comma as the primary line.
String _mainText(String description) {
final int commaIndex = description.indexOf(',');
return commaIndex > 0 ? description.substring(0, commaIndex) : description;
}
/// Extracts text after first comma as the secondary line.
String _subText(String description) {
final int commaIndex = description.indexOf(',');
return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : '';
}
@override
Widget build(BuildContext context) {
return GooglePlaceAutoCompleteTextField(
textEditingController: controller,
focusNode: focusNode,
googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 400,
countries: const <String>['us'],
isLatLngRequired: false,
getPlaceDetailWithLatLng: onSelected,
itemClick: (Prediction prediction) {
controller.text = prediction.description ?? '';
controller.selection = TextSelection.fromPosition(
TextPosition(offset: controller.text.length),
);
onSelected(prediction);
},
inputDecoration: InputDecoration(
hintText: hint,
hintStyle: UiTypography.body2r.textSecondary,
prefixIcon: const Icon(
UiIcons.search,
color: UiColors.iconSecondary,
size: 20,
),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(
UiIcons.close,
size: 18,
color: UiColors.iconSecondary,
),
onPressed: controller.clear,
)
: null,
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.primary, width: 1.5),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
),
fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
filled: true,
),
textStyle: UiTypography.body2r.textPrimary,
itemBuilder: (BuildContext context, int index, Prediction prediction) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space2,
),
child: Row(
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4.0),
),
child: const Icon(
UiIcons.mapPin,
size: 16,
color: UiColors.primary,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
_mainText(prediction.description ?? ''),
style: UiTypography.body2m.textPrimary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_subText(prediction.description ?? '').isNotEmpty)
Text(
_subText(prediction.description ?? ''),
style: UiTypography.footnote1r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
},
);
}
}