1042 lines
32 KiB
Dart
1042 lines
32 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
|
import '../../../core/l10n/app_localizations.dart';
|
|
import '../../../core/design/tokens.dart';
|
|
import '../../../core/design/tripz_colors.dart';
|
|
import '../../../core/di.dart';
|
|
import '../cubit/destination_cubit.dart';
|
|
import '../cubit/home_cubit.dart';
|
|
import '../data/maps_repository.dart';
|
|
import '../data/models/place_model.dart';
|
|
|
|
const int _maxStops = 2;
|
|
|
|
class DestinationScreen extends StatefulWidget {
|
|
final LatLng? currentLocation;
|
|
|
|
const DestinationScreen({super.key, this.currentLocation});
|
|
|
|
@override
|
|
State<DestinationScreen> createState() => _DestinationScreenState();
|
|
}
|
|
|
|
class _DestinationScreenState extends State<DestinationScreen> {
|
|
final _destController = TextEditingController();
|
|
final _destFocusNode = FocusNode();
|
|
final _stopControllers = <TextEditingController>[];
|
|
final _stopFocusNodes = <FocusNode>[];
|
|
int _editingStopIndex = -1;
|
|
String? _pickupAddressName;
|
|
bool _forSelf = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_destFocusNode.requestFocus();
|
|
_loadReverseGeocodePickup();
|
|
}
|
|
|
|
Future<void> _loadReverseGeocodePickup() async {
|
|
final loc = widget.currentLocation;
|
|
if (loc != null) {
|
|
try {
|
|
final repo = getIt<MapsRepository>();
|
|
final place = await repo.reverseGeocode(loc.latitude, loc.longitude);
|
|
if (mounted && place.name.isNotEmpty) {
|
|
setState(() {
|
|
_pickupAddressName = place.name;
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
void _swapLocations() {
|
|
setState(() {
|
|
final oldDest = _destController.text;
|
|
if (_stopControllers.isNotEmpty) {
|
|
_destController.text = _stopControllers.first.text;
|
|
_stopControllers.first.text = oldDest;
|
|
} else if (_pickupAddressName != null && _pickupAddressName!.isNotEmpty) {
|
|
_destController.text = _pickupAddressName!;
|
|
_pickupAddressName = oldDest.isNotEmpty ? oldDest : _pickupAddressName;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _showAiVoiceSearchDialog(BuildContext context) {
|
|
final voiceController = TextEditingController();
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (modalCtx) {
|
|
return Container(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(modalCtx).viewInsets.bottom + T.s24,
|
|
top: T.s24,
|
|
left: T.pagePadding,
|
|
right: T.pagePadding,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(T.r24)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
color: cs.primaryContainer,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(Icons.mic_rounded,
|
|
color: cs.onPrimaryContainer, size: 36),
|
|
),
|
|
const SizedBox(height: T.s16),
|
|
Text(
|
|
'البحث الصوتي الذكي (AI Voice)',
|
|
style: Theme.of(modalCtx).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: T.s8),
|
|
Text(
|
|
'قل وجهتك بصوتك أو اكتب وصف المكان (مثل: مجمع الأبحاث، الشميساني)',
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(modalCtx).textTheme.bodyMedium?.copyWith(
|
|
color: cs.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: T.s16),
|
|
TextField(
|
|
controller: voiceController,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
hintText: 'تحدث أو اكتب المكان...',
|
|
prefixIcon: const Icon(Icons.graphic_eq_rounded),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
),
|
|
),
|
|
onSubmitted: (text) {
|
|
Navigator.pop(modalCtx);
|
|
if (text.trim().isNotEmpty) {
|
|
_destController.text = text;
|
|
final loc = widget.currentLocation;
|
|
context.read<DestinationCubit>().searchVoiceDirectly(
|
|
text,
|
|
lat: loc?.latitude,
|
|
lng: loc?.longitude,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: T.s16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(modalCtx),
|
|
child: Text(l10n.cancel),
|
|
),
|
|
const SizedBox(width: T.s8),
|
|
ElevatedButton.icon(
|
|
onPressed: () {
|
|
final text = voiceController.text;
|
|
Navigator.pop(modalCtx);
|
|
if (text.trim().isNotEmpty) {
|
|
_destController.text = text;
|
|
final loc = widget.currentLocation;
|
|
context.read<DestinationCubit>().searchVoiceDirectly(
|
|
text,
|
|
lat: loc?.latitude,
|
|
lng: loc?.longitude,
|
|
);
|
|
}
|
|
},
|
|
icon: const Icon(Icons.search_rounded),
|
|
label: const Text('بحث بالذكاء الاصطناعي'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_destController.dispose();
|
|
_destFocusNode.dispose();
|
|
for (final c in _stopControllers) {
|
|
c.dispose();
|
|
}
|
|
for (final f in _stopFocusNodes) {
|
|
f.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _addStop() {
|
|
if (_stopControllers.length >= _maxStops) return;
|
|
setState(() {
|
|
_stopControllers.add(TextEditingController());
|
|
_stopFocusNodes.add(FocusNode());
|
|
_editingStopIndex = _stopControllers.length - 1;
|
|
});
|
|
_stopFocusNodes.last.requestFocus();
|
|
}
|
|
|
|
void _removeStop(int index) {
|
|
setState(() {
|
|
_stopControllers[index].dispose();
|
|
_stopFocusNodes[index].dispose();
|
|
_stopControllers.removeAt(index);
|
|
_stopFocusNodes.removeAt(index);
|
|
if (_editingStopIndex == index) {
|
|
_editingStopIndex = -1;
|
|
} else if (_editingStopIndex > index) {
|
|
_editingStopIndex--;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _selectPlace(dynamic result) {
|
|
context.pop(result);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final theme = Theme.of(context);
|
|
final cs = theme.colorScheme;
|
|
final tripz = context.tripzColors;
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
extendBodyBehindAppBar: true,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
onPressed: () => context.pop(),
|
|
icon: const Icon(Icons.arrow_forward),
|
|
color: cs.onSurface,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: cs.surface,
|
|
shadowColor: Colors.black12,
|
|
elevation: 2,
|
|
),
|
|
),
|
|
title: Text(
|
|
l10n.selectDestination,
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
centerTitle: true,
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
// Map background
|
|
Positioned.fill(
|
|
child: Container(
|
|
color: cs.surfaceContainerHighest,
|
|
child: Opacity(
|
|
opacity: 0.5,
|
|
child: Image.asset(
|
|
'assets/images/destination_selection_img.png',
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Map overlay gradient
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.black12, Colors.black38],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Draggable sheet
|
|
Positioned.fill(
|
|
child: DraggableScrollableSheet(
|
|
initialChildSize: 0.55,
|
|
minChildSize: 0.15,
|
|
maxChildSize: 0.88,
|
|
snap: true,
|
|
snapSizes: const [0.15, 0.55, 0.88],
|
|
builder: (context, scrollController) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(T.r24)),
|
|
boxShadow: const [T.shadowLg],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Drag handle
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: T.s12),
|
|
child: Container(
|
|
width: 48,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: cs.outlineVariant,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
Expanded(
|
|
child: ListView(
|
|
controller: scrollController,
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
_buildFieldsSection(
|
|
context, l10n, theme, cs, tripz),
|
|
const SizedBox(height: T.s16),
|
|
_buildSavedPlaces(context, theme, cs),
|
|
const SizedBox(height: T.s8),
|
|
_buildAutocompleteResults(context, l10n, theme, cs),
|
|
_buildPickFromMap(context, l10n, cs),
|
|
const SizedBox(height: T.s32),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFieldsSection(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
ThemeData theme,
|
|
ColorScheme cs,
|
|
TripzColors tripz,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
|
|
child: Column(
|
|
children: [
|
|
// Rider selection chip — "For me / For someone else"
|
|
Row(
|
|
children: [
|
|
FilterChip(
|
|
selected: _forSelf,
|
|
label: const Text('طلب لنفسي'),
|
|
avatar: const Icon(Icons.person_rounded, size: 16),
|
|
onSelected: (val) => setState(() => _forSelf = true),
|
|
),
|
|
const SizedBox(width: T.s8),
|
|
FilterChip(
|
|
selected: !_forSelf,
|
|
label: const Text('طلب لشخص آخر'),
|
|
avatar: const Icon(Icons.people_rounded, size: 16),
|
|
onSelected: (val) => setState(() => _forSelf = false),
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
onPressed: _swapLocations,
|
|
icon: const Icon(Icons.swap_vert_rounded),
|
|
tooltip: 'تبديل الأماكن',
|
|
color: cs.primary,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: T.s8),
|
|
|
|
// Origin — green dot, reverse geocoded
|
|
_OriginRow(
|
|
l10n: l10n,
|
|
theme: theme,
|
|
cs: cs,
|
|
tripz: tripz,
|
|
addressName: _pickupAddressName,
|
|
),
|
|
|
|
// Stops
|
|
for (var i = 0; i < _stopControllers.length; i++)
|
|
_StopRow(
|
|
index: i,
|
|
controller: _stopControllers[i],
|
|
focusNode: _stopFocusNodes[i],
|
|
isEditing: _editingStopIndex == i,
|
|
l10n: l10n,
|
|
theme: theme,
|
|
cs: cs,
|
|
tripz: tripz,
|
|
onStopTapped: () {
|
|
setState(() => _editingStopIndex = i);
|
|
context.read<DestinationCubit>().onQueryChanged('');
|
|
_stopFocusNodes[i].requestFocus();
|
|
},
|
|
onStopChanged: (v) =>
|
|
context.read<DestinationCubit>().onQueryChanged(v),
|
|
onStopRemoved: () => _removeStop(i),
|
|
),
|
|
|
|
// Dotted connector between last stop / origin and destination
|
|
_DottedConnector(color: cs.outlineVariant),
|
|
|
|
// Destination — red square, active with AI Voice Search button
|
|
_DestinationRow(
|
|
controller: _destController,
|
|
focusNode: _destFocusNode,
|
|
isEditing: _editingStopIndex == -1,
|
|
l10n: l10n,
|
|
theme: theme,
|
|
cs: cs,
|
|
tripz: tripz,
|
|
onFieldTapped: () {
|
|
setState(() => _editingStopIndex = -1);
|
|
context.read<DestinationCubit>().onQueryChanged('');
|
|
_destFocusNode.requestFocus();
|
|
},
|
|
onChanged: (v) =>
|
|
context.read<DestinationCubit>().onQueryChanged(v),
|
|
onVoiceSearchPressed: () => _showAiVoiceSearchDialog(context),
|
|
),
|
|
|
|
const SizedBox(height: T.s8),
|
|
|
|
// Add stop button
|
|
if (_stopControllers.length < _maxStops)
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton.icon(
|
|
onPressed: _addStop,
|
|
icon: const Icon(Icons.add, size: 18),
|
|
label: Text(l10n.addStop),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: cs.primary,
|
|
padding: const EdgeInsets.symmetric(vertical: T.s8),
|
|
minimumSize: Size.zero,
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: T.s12),
|
|
Divider(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
|
const SizedBox(height: T.s12),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSavedPlaces(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
ColorScheme cs,
|
|
) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return SizedBox(
|
|
height: 52,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
|
|
children: [
|
|
_SavedPlaceChip(
|
|
icon: Icons.home_rounded,
|
|
label: l10n.homePlace,
|
|
onTap: () {},
|
|
),
|
|
const SizedBox(width: T.s12),
|
|
_SavedPlaceChip(
|
|
icon: Icons.work_rounded,
|
|
label: l10n.workPlace,
|
|
onTap: () {},
|
|
),
|
|
const SizedBox(width: T.s12),
|
|
_SavedPlaceChip(
|
|
icon: Icons.star_rounded,
|
|
label: l10n.favorites,
|
|
onTap: () {},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAutocompleteResults(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
ThemeData theme,
|
|
ColorScheme cs,
|
|
) {
|
|
return BlocBuilder<DestinationCubit, DestinationState>(
|
|
builder: (context, state) {
|
|
if (state.searching) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: T.s24),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
if (state.results.isEmpty && state.query.length >= 2) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: T.s24),
|
|
child: Center(
|
|
child: Text(l10n.noResults, style: theme.textTheme.bodyLarge),
|
|
),
|
|
);
|
|
}
|
|
return Column(
|
|
children: [
|
|
for (var i = 0; i < state.results.length; i++)
|
|
_SearchResultTile(
|
|
place: state.results[i],
|
|
onTap: () => _selectPlace(state.results[i]),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPickFromMap(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
ColorScheme cs,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: T.pagePadding,
|
|
vertical: T.s8,
|
|
),
|
|
child: _PickFromMapTile(
|
|
onTap: () => _selectPlace({'pickFromMap': true}),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Origin row ──
|
|
class _OriginRow extends StatelessWidget {
|
|
final AppLocalizations l10n;
|
|
final ThemeData theme;
|
|
final ColorScheme cs;
|
|
final TripzColors tripz;
|
|
final String? addressName;
|
|
|
|
const _OriginRow({
|
|
required this.l10n,
|
|
required this.theme,
|
|
required this.cs,
|
|
required this.tripz,
|
|
this.addressName,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: T.s4),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 48,
|
|
child: Center(
|
|
child: Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: BoxDecoration(
|
|
color: tripz.success,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: tripz.success.withValues(alpha: 0.25),
|
|
width: 4,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: T.s12,
|
|
vertical: T.s12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerLow,
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
border: Border.all(
|
|
color: cs.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
child: Text(
|
|
(addressName != null && addressName!.isNotEmpty)
|
|
? addressName!
|
|
: l10n.pickupLocation,
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: (addressName != null && addressName!.isNotEmpty)
|
|
? cs.onSurface
|
|
: cs.onSurfaceVariant,
|
|
fontWeight: (addressName != null && addressName!.isNotEmpty)
|
|
? FontWeight.w600
|
|
: FontWeight.normal,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Stop row ──
|
|
class _StopRow extends StatelessWidget {
|
|
final int index;
|
|
final TextEditingController controller;
|
|
final FocusNode focusNode;
|
|
final bool isEditing;
|
|
final AppLocalizations l10n;
|
|
final ThemeData theme;
|
|
final ColorScheme cs;
|
|
final TripzColors tripz;
|
|
final VoidCallback onStopTapped;
|
|
final ValueChanged<String> onStopChanged;
|
|
final VoidCallback onStopRemoved;
|
|
|
|
const _StopRow({
|
|
required this.index,
|
|
required this.controller,
|
|
required this.focusNode,
|
|
required this.isEditing,
|
|
required this.l10n,
|
|
required this.theme,
|
|
required this.cs,
|
|
required this.tripz,
|
|
required this.onStopTapped,
|
|
required this.onStopChanged,
|
|
required this.onStopRemoved,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: T.s4),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 48,
|
|
child: Center(
|
|
child: Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: cs.onSurfaceVariant, width: 2),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: cs.onSurfaceVariant,
|
|
fontSize: 10,
|
|
height: 1,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: onStopTapped,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: isEditing ? cs.surface : cs.surfaceContainerLow,
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
border: Border.all(
|
|
color: isEditing
|
|
? cs.primary
|
|
: cs.outlineVariant.withValues(alpha: 0.3),
|
|
width: isEditing ? 2 : 1,
|
|
),
|
|
boxShadow: isEditing ? const [T.shadowSm] : null,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
readOnly: !isEditing,
|
|
decoration: InputDecoration(
|
|
hintText: '${l10n.addStop} ${index + 1}',
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: T.s12,
|
|
vertical: T.s12,
|
|
),
|
|
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
|
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: cs.onSurface,
|
|
),
|
|
onChanged: onStopChanged,
|
|
),
|
|
),
|
|
if (isEditing && controller.text.isNotEmpty)
|
|
IconButton(
|
|
onPressed: onStopRemoved,
|
|
icon: const Icon(Icons.close, size: 18),
|
|
color: cs.onSurfaceVariant,
|
|
splashRadius: 18,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Dotted connector line ──
|
|
class _DottedConnector extends StatelessWidget {
|
|
final Color color;
|
|
const _DottedConnector({required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 48,
|
|
child: CustomPaint(
|
|
size: const Size(48, 20),
|
|
painter: _DottedLinePainter(color: color),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DottedLinePainter extends CustomPainter {
|
|
final Color color;
|
|
_DottedLinePainter({required this.color});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = color
|
|
..strokeWidth = 1.5
|
|
..style = PaintingStyle.stroke;
|
|
|
|
const dashHeight = 3.0;
|
|
const gapHeight = 3.0;
|
|
double y = 4;
|
|
while (y < size.height) {
|
|
canvas.drawLine(
|
|
Offset(size.width / 2, y),
|
|
Offset(size.width / 2, y + dashHeight),
|
|
paint,
|
|
);
|
|
y += dashHeight + gapHeight;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _DottedLinePainter old) => old.color != color;
|
|
}
|
|
|
|
// ── Destination row ──
|
|
class _DestinationRow extends StatelessWidget {
|
|
final TextEditingController controller;
|
|
final FocusNode focusNode;
|
|
final bool isEditing;
|
|
final AppLocalizations l10n;
|
|
final ThemeData theme;
|
|
final ColorScheme cs;
|
|
final TripzColors tripz;
|
|
final VoidCallback onFieldTapped;
|
|
final ValueChanged<String> onChanged;
|
|
final VoidCallback? onVoiceSearchPressed;
|
|
|
|
const _DestinationRow({
|
|
required this.controller,
|
|
required this.focusNode,
|
|
required this.isEditing,
|
|
required this.l10n,
|
|
required this.theme,
|
|
required this.cs,
|
|
required this.tripz,
|
|
required this.onFieldTapped,
|
|
required this.onChanged,
|
|
this.onVoiceSearchPressed,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: T.s4),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 48,
|
|
child: Center(
|
|
child: Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: cs.error,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Icon(Icons.stop_rounded, size: 12, color: cs.onError),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: onFieldTapped,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
border: Border.all(
|
|
color: isEditing ? cs.primary : cs.outlineVariant,
|
|
width: isEditing ? 2 : 1,
|
|
),
|
|
boxShadow: isEditing ? const [T.shadowSm] : null,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
readOnly: !isEditing,
|
|
decoration: InputDecoration(
|
|
hintText: l10n.whereTo,
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: T.s16,
|
|
vertical: T.s12,
|
|
),
|
|
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
|
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: cs.onSurface,
|
|
),
|
|
onChanged: onChanged,
|
|
),
|
|
),
|
|
if (onVoiceSearchPressed != null)
|
|
IconButton(
|
|
onPressed: onVoiceSearchPressed,
|
|
icon: Icon(Icons.mic_rounded, color: cs.primary),
|
|
tooltip: 'البحث الصوتي الذكي (AI Voice)',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Saved place chip ──
|
|
class _SavedPlaceChip extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
|
|
const _SavedPlaceChip({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final theme = Theme.of(context);
|
|
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: T.s12, vertical: T.s8),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerLow,
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
shape: BoxShape.circle,
|
|
boxShadow: const [T.shadowSm],
|
|
),
|
|
child: Icon(icon, color: cs.primary, size: 20),
|
|
),
|
|
const SizedBox(width: T.s8),
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
color: cs.onSurface,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Autocomplete search result tile ──
|
|
class _SearchResultTile extends StatelessWidget {
|
|
final PlaceModel place;
|
|
final VoidCallback onTap;
|
|
|
|
const _SearchResultTile({required this.place, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final theme = Theme.of(context);
|
|
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(T.r8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: T.s12,
|
|
horizontal: T.pagePadding,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.location_on_outlined,
|
|
color: cs.onSurfaceVariant,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: T.s12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
place.name,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (place.address != null && place.address!.isNotEmpty) ...[
|
|
const SizedBox(height: T.s4),
|
|
Text(
|
|
place.address!,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: cs.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.chevron_right_rounded, color: cs.onSurfaceVariant),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Pick from map tile ──
|
|
class _PickFromMapTile extends StatelessWidget {
|
|
final VoidCallback onTap;
|
|
|
|
const _PickFromMapTile({required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final cs = Theme.of(context).colorScheme;
|
|
final theme = Theme.of(context);
|
|
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: T.s12,
|
|
vertical: T.s12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(T.r12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.map_rounded, color: cs.primary, size: T.iconMd),
|
|
const SizedBox(width: T.s12),
|
|
Text(
|
|
l10n.setLocationOnMap,
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|