feat: Implement preferred locations feature with search and display components
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user