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,
|
'email': staff.email,
|
||||||
'phone': staff.phone,
|
'phone': staff.phone,
|
||||||
'preferredLocations':
|
'preferredLocations':
|
||||||
staff.address != null
|
staff.preferredLocations != null
|
||||||
? <String>[staff.address!]
|
? List<String>.from(staff.preferredLocations!)
|
||||||
: <String>[], // TODO: Map correctly when Staff entity supports list
|
: <String>[],
|
||||||
'avatar': staff.avatar,
|
'avatar': staff.avatar,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,8 +111,8 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
|||||||
'email': updatedStaff.email,
|
'email': updatedStaff.email,
|
||||||
'phone': updatedStaff.phone,
|
'phone': updatedStaff.phone,
|
||||||
'preferredLocations':
|
'preferredLocations':
|
||||||
updatedStaff.address != null
|
updatedStaff.preferredLocations != null
|
||||||
? <String>[updatedStaff.address!]
|
? List<String>.from(updatedStaff.preferredLocations!)
|
||||||
: <String>[],
|
: <String>[],
|
||||||
'avatar': updatedStaff.avatar,
|
'avatar': updatedStaff.avatar,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.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:google_places_flutter/model/prediction.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
import '../blocs/personal_info_bloc.dart';
|
import '../blocs/personal_info_bloc.dart';
|
||||||
import '../blocs/personal_info_event.dart';
|
import '../blocs/personal_info_event.dart';
|
||||||
import '../blocs/personal_info_state.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.
|
/// The maximum number of preferred locations a staff member can add.
|
||||||
const int _kMaxLocations = 5;
|
const int _kMaxLocations = 5;
|
||||||
@@ -80,7 +82,6 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
|
|||||||
message: i18n.preferred_locations.save_success,
|
message: i18n.preferred_locations.save_success,
|
||||||
type: UiSnackbarType.success,
|
type: UiSnackbarType.success,
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
|
||||||
} else if (state.status == PersonalInfoStatus.error) {
|
} else if (state.status == PersonalInfoStatus.error) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
@@ -98,25 +99,14 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.background,
|
backgroundColor: UiColors.background,
|
||||||
appBar: AppBar(
|
appBar: UiAppBar(
|
||||||
backgroundColor: UiColors.bgPopup,
|
title: i18n.preferred_locations.title,
|
||||||
elevation: 0,
|
showBackButton: true,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: Stack(
|
||||||
child: Column(
|
children: [
|
||||||
|
SafeArea(
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// ── Description
|
// ── Description
|
||||||
@@ -138,7 +128,7 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
|
|||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UiConstants.space5,
|
horizontal: UiConstants.space5,
|
||||||
),
|
),
|
||||||
child: _PlacesSearchField(
|
child: PlacesSearchField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
focusNode: _searchFocusNode,
|
focusNode: _searchFocusNode,
|
||||||
hint: i18n.preferred_locations.search_hint,
|
hint: i18n.preferred_locations.search_hint,
|
||||||
@@ -187,11 +177,11 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
|
|||||||
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
|
||||||
// ── Locations list / empty state
|
// Locations list / empty state
|
||||||
Expanded(
|
Expanded(
|
||||||
child: locations.isEmpty
|
child: locations.isEmpty
|
||||||
? _EmptyLocationsState(message: i18n.preferred_locations.empty_state)
|
? EmptyLocationsState(message: i18n.preferred_locations.empty_state)
|
||||||
: _LocationsList(
|
: LocationsList(
|
||||||
locations: locations,
|
locations: locations,
|
||||||
isSaving: isSaving,
|
isSaving: isSaving,
|
||||||
removeTooltip: i18n.preferred_locations.remove_tooltip,
|
removeTooltip: i18n.preferred_locations.remove_tooltip,
|
||||||
@@ -199,19 +189,42 @@ class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Save button
|
// Save button
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: UiButton.primary(
|
child: UiButton.primary(
|
||||||
text: i18n.preferred_locations.save_button,
|
text: isSaving ? null : i18n.preferred_locations.save_button,
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
onPressed: isSaving ? null : () => _save(context, bloc, state),
|
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,
|
enabled: enabled,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
TappableRow(
|
TappableRow(
|
||||||
value: locationSummary,
|
value: locationSummary,
|
||||||
hint: i18n.locations_hint,
|
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