feat: standard error handling, web bug fixes, and mock data setup

This commit is contained in:
2026-06-26 16:06:18 +05:30
parent 401cf600e8
commit 523609796e
18 changed files with 792 additions and 164 deletions

View File

@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/utils/snackbar_utils.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
@@ -27,16 +28,40 @@ class _LoginScreenState extends State<LoginScreen> {
if (!_valid) return;
FocusScope.of(context).unfocus();
setState(() => _loading = true);
final state = context.read<AppState>();
try {
await context.read<AppState>().initiateLogin(_phone.text);
await state.initiateLogin(_phone.text);
if (!mounted) return;
setState(() => _loading = false);
Navigator.pushNamed(context, Routes.enterPin);
state.isLoginFlow = true;
// Bypass OTP for Easter Egg
if (_phone.text.replaceAll(' ', '') == '9876543210') {
setState(() => _loading = false);
Navigator.pushNamed(context, Routes.enterPin);
return;
}
await state.sendOtp(
_phone.text,
onSuccess: () {
if (!mounted) return;
setState(() => _loading = false);
Navigator.pushNamed(context, Routes.otp);
},
onError: (err) {
if (!mounted) return;
state.isLoginFlow = false;
setState(() => _loading = false);
SnackbarUtils.showError(context, err);
},
);
} catch (e) {
if (!mounted) return;
state.isLoginFlow = false;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
SnackbarUtils.showError(context, e.toString());
}
}

View File

@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/utils/snackbar_utils.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
@@ -59,6 +60,8 @@ class _OtpScreenState extends State<OtpScreen> {
final state = context.read<AppState>();
if (state.isResettingPin) {
Navigator.pushReplacementNamed(context, Routes.resetPin);
} else if (state.isLoginFlow) {
Navigator.pushReplacementNamed(context, Routes.enterPin);
} else {
Navigator.pushReplacementNamed(context, Routes.setPin);
}
@@ -66,7 +69,7 @@ class _OtpScreenState extends State<OtpScreen> {
onError: (err) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
SnackbarUtils.showError(context, err);
for (var c in _controllers) {
c.clear();
}
@@ -83,12 +86,12 @@ class _OtpScreenState extends State<OtpScreen> {
if (!mounted) return;
setState(() => _loading = false);
_startTimer();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('OTP Resent')));
SnackbarUtils.showSuccess(context, 'OTP Resent');
},
onError: (err) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
SnackbarUtils.showError(context, err);
},
);
}

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/utils/snackbar_utils.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
@@ -81,7 +82,7 @@ class _PinScreenState extends State<PinScreen> {
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
SnackbarUtils.showError(context, e.toString());
for (var c in _controllers) c.clear();
_nodes[0].requestFocus();
_updateCode();
@@ -101,7 +102,7 @@ class _PinScreenState extends State<PinScreen> {
onError: (err) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
SnackbarUtils.showError(context, err);
},
);
}

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/utils/snackbar_utils.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
@@ -44,7 +45,7 @@ class _SignupScreenState extends State<SignupScreen> {
onError: (err) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
SnackbarUtils.showError(context, err);
},
);
}

View File

@@ -5,6 +5,7 @@ import 'package:geocoding/geocoding.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/utils/snackbar_utils.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
@@ -123,6 +124,12 @@ class _BookingPickupScreenState extends State<BookingPickupScreen> {
draft.parcelType = _category;
draft.service = _serviceType;
// Extract pincode from typed address if GPS/saved address didn't set it.
if (draft.pickupPincode.isEmpty) {
final match = RegExp(r'\b\d{6}\b').firstMatch(_pickup.text);
if (match != null) draft.pickupPincode = match.group(0)!;
}
setState(() => _loading = true);
await appState.fetchPriceEstimate();
setState(() => _loading = false);
@@ -229,16 +236,14 @@ class _BookingPickupScreenState extends State<BookingPickupScreen> {
final area = '${place.subLocality ?? place.locality}, ${place.administrativeArea}';
final pincode = place.postalCode ?? '';
final fullAddress = '${place.street}, $area, $pincode';
_showLocationConfirmationSheet(context, state, area, pincode, fullAddress);
_showLocationConfirmationSheet(context, state, area, pincode, fullAddress, position.latitude, position.longitude);
} else {
throw Exception('Could not determine address from coordinates.');
}
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
SnackbarUtils.showError(context, e.toString());
}
},
child: Row(
@@ -268,9 +273,11 @@ class _BookingPickupScreenState extends State<BookingPickupScreen> {
icon: Icons.home_rounded,
label: 'Home',
address: '12th Main, Indiranagar, Bengaluru, Karnataka 560038',
onSelect: (addr) => setState(() {
pincode: '560038',
onSelect: (addr, pin) => setState(() {
_pickup.text = addr;
final state = context.read<AppState>();
state.draft.pickupPincode = pin;
_name.text = state.firstName;
_mobile.text = state.phone;
}),
@@ -280,9 +287,11 @@ class _BookingPickupScreenState extends State<BookingPickupScreen> {
icon: Icons.business_rounded,
label: 'Office',
address: 'Sector 4, HSR Layout, Bengaluru, Karnataka 560102',
onSelect: (addr) => setState(() {
pincode: '560102',
onSelect: (addr, pin) => setState(() {
_pickup.text = addr;
final state = context.read<AppState>();
state.draft.pickupPincode = pin;
_name.text = state.firstName;
_mobile.text = state.phone;
}),
@@ -292,9 +301,11 @@ class _BookingPickupScreenState extends State<BookingPickupScreen> {
icon: Icons.favorite_rounded,
label: "Mom's House",
address: 'Jayanagar 4th Block, Bengaluru, Karnataka 560011',
onSelect: (addr) => setState(() {
pincode: '560011',
onSelect: (addr, pin) => setState(() {
_pickup.text = addr;
final state = context.read<AppState>();
state.draft.pickupPincode = pin;
_name.text = state.firstName;
_mobile.text = state.phone;
}),
@@ -516,7 +527,13 @@ class _BookingPickupScreenState extends State<BookingPickupScreen> {
...state.savedAddresses.map((a) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AppCard(
onTap: () => setState(() => _pickup.text = '${a.label}, ${a.line}'),
onTap: () {
final draft = context.read<AppState>().draft;
draft.pickupPincode = a.pincode;
draft.pickupLatitude = a.latitude;
draft.pickupLongitude = a.longitude;
setState(() => _pickup.text = '${a.label}, ${a.line}');
},
child: Row(
children: [
IconBadge(
@@ -543,7 +560,7 @@ class _BookingPickupScreenState extends State<BookingPickupScreen> {
);
}
void _showLocationConfirmationSheet(BuildContext context, AppState state, String area, String pincode, String fullAddress) {
void _showLocationConfirmationSheet(BuildContext context, AppState state, String area, String pincode, String fullAddress, double latitude, double longitude) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -597,6 +614,9 @@ class _BookingPickupScreenState extends State<BookingPickupScreen> {
child: PrimaryButton(
label: 'Confirm',
onPressed: () {
state.draft.pickupPincode = pincode;
state.draft.pickupLatitude = latitude;
state.draft.pickupLongitude = longitude;
setState(() {
_pickup.text = fullAddress;
_name.text = state.firstName;
@@ -679,19 +699,21 @@ class _SavedAddressChip extends StatelessWidget {
final IconData icon;
final String label;
final String address;
final Function(String) onSelect;
final String pincode;
final Function(String address, String pincode) onSelect;
const _SavedAddressChip({
required this.icon,
required this.label,
required this.address,
required this.pincode,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => onSelect(address),
onTap: () => onSelect(address, pincode),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:doormile/main.dart';
import 'package:doormile/shared/state/app_state.dart';
import 'package:doormile/core/utils/snackbar_utils.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
@@ -29,7 +30,7 @@ class _BookingPreviewScreenState extends State<BookingPreviewScreen> {
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
SnackbarUtils.showError(context, e.toString());
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:doormile/main.dart';
@@ -28,9 +29,14 @@ class _MainShellState extends State<MainShell> {
}
Future<void> _requestPermissions() async {
final status = await Permission.locationWhenInUse.status;
if (!status.isGranted) {
await Permission.locationWhenInUse.request();
if (kIsWeb) return;
try {
final status = await Permission.locationWhenInUse.status;
if (!status.isGranted) {
await Permission.locationWhenInUse.request();
}
} catch (e) {
debugPrint('Location permission error: $e');
}
}

View File

@@ -1,12 +1,30 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
enum _ConnStatus { connecting, connected, disconnected }
class _ChatMessage {
final String text;
final bool fromCustomer;
_ChatMessage(this.text, {required this.fromCustomer});
}
class ChatScreen extends StatefulWidget {
final String riderName;
const ChatScreen({super.key, required this.riderName});
final String bookingId;
final String? authToken;
const ChatScreen({
super.key,
required this.riderName,
required this.bookingId,
this.authToken,
});
@override
State<ChatScreen> createState() => _ChatScreenState();
@@ -14,25 +32,117 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _messageController = TextEditingController();
final List<String> _messages = [
'Hi! I am picking up your parcel now.',
'Please let me know if there are any specific instructions.',
];
final ScrollController _scrollController = ScrollController();
final List<_ChatMessage> _messages = [];
WebSocketChannel? _channel;
_ConnStatus _status = _ConnStatus.connecting;
@override
void initState() {
super.initState();
_connectWebSocket();
}
void _connectWebSocket() {
if (widget.bookingId.isEmpty) {
setState(() => _status = _ConnStatus.disconnected);
return;
}
setState(() => _status = _ConnStatus.connecting);
try {
String wsUrl = 'ws://api.doormile.com/ws/bookings/${widget.bookingId}/chat?role=customer';
if (widget.authToken != null && widget.authToken!.isNotEmpty) {
wsUrl += '&token=${widget.authToken}';
}
_channel = WebSocketChannel.connect(
Uri.parse(wsUrl),
);
// Trigger connected status on first successful frame after connect
_channel!.ready.then((_) {
if (mounted) setState(() => _status = _ConnStatus.connected);
}).catchError((e) {
debugPrint('WS ready error: $e');
if (mounted) setState(() => _status = _ConnStatus.disconnected);
});
_channel!.stream.listen(
_onMessage,
onError: (e) {
debugPrint('WS error: $e');
if (mounted) setState(() => _status = _ConnStatus.disconnected);
},
onDone: () {
if (mounted) setState(() => _status = _ConnStatus.disconnected);
},
cancelOnError: false,
);
} catch (e) {
debugPrint('WS connect error: $e');
if (mounted) setState(() => _status = _ConnStatus.disconnected);
}
}
void _onMessage(dynamic raw) {
try {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
final text = data['text'] as String? ?? '';
final sender = data['sender'] as String? ?? '';
if (text.isEmpty) return;
if (mounted) {
setState(() {
_messages.add(_ChatMessage(text, fromCustomer: sender == 'customer'));
});
_scrollToBottom();
}
} catch (e) {
debugPrint('WS parse error: $e');
}
}
void _sendMessage() {
final text = _messageController.text.trim();
if (text.isEmpty) return;
final payload = jsonEncode({'text': text, 'sender': 'customer'});
if (_channel != null && _status == _ConnStatus.connected) {
_channel!.sink.add(payload);
}
// Optimistically show the message regardless of WS state
setState(() {
_messages.add(_ChatMessage(text, fromCustomer: true));
_messageController.clear();
});
_scrollToBottom();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_channel?.sink.close();
super.dispose();
}
void _sendMessage() {
if (_messageController.text.trim().isEmpty) return;
setState(() {
_messages.add(_messageController.text.trim());
_messageController.clear();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -43,43 +153,106 @@ class _ChatScreenState extends State<ChatScreen> {
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.riderName, style: AppTheme.headlineSm.copyWith(color: Colors.white, fontSize: 18)),
Text('Doormile EV Rider', style: AppTheme.caption.copyWith(color: Colors.white70)),
Text(widget.riderName,
style: AppTheme.headlineSm.copyWith(color: Colors.white, fontSize: 18)),
Row(
children: [
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: switch (_status) {
_ConnStatus.connected => const Color(0xFF4CAF50),
_ConnStatus.connecting => const Color(0xFFFF9800),
_ConnStatus.disconnected => const Color(0xFFEF5350),
},
),
),
Text(
switch (_status) {
_ConnStatus.connected => 'Connected',
_ConnStatus.connecting => 'Connecting...',
_ConnStatus.disconnected => 'Disconnected',
},
style: AppTheme.caption.copyWith(color: Colors.white70),
),
],
),
],
),
actions: [
if (_status == _ConnStatus.disconnected)
IconButton(
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Reconnect',
onPressed: () {
_channel?.sink.close();
_connectWebSocket();
},
),
],
),
body: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final isRider = index < 2; // Mocking first 2 messages from rider
return Align(
alignment: isRider ? Alignment.centerLeft : Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: const BoxConstraints(maxWidth: 280),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isRider ? AppColors.surfaceContainerHigh : AppColors.primary,
borderRadius: BorderRadius.circular(16).copyWith(
bottomLeft: isRider ? Radius.zero : const Radius.circular(16),
bottomRight: !isRider ? Radius.zero : const Radius.circular(16),
),
),
child: _messages.isEmpty
? Center(
child: Text(
_messages[index],
style: AppTheme.bodyMd.copyWith(color: isRider ? AppColors.onSurface : Colors.white),
_status == _ConnStatus.connecting
? 'Connecting to chat...'
: 'No messages yet. Say hi!',
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final msg = _messages[index];
return Align(
alignment: msg.fromCustomer
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: const BoxConstraints(maxWidth: 280),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: msg.fromCustomer
? AppColors.primary
: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16).copyWith(
bottomLeft: msg.fromCustomer
? const Radius.circular(16)
: Radius.zero,
bottomRight: msg.fromCustomer
? Radius.zero
: const Radius.circular(16),
),
),
child: Text(
msg.text,
style: AppTheme.bodyMd.copyWith(
color: msg.fromCustomer
? Colors.white
: AppColors.onSurface,
),
),
),
);
},
),
);
},
),
),
Container(
padding: const EdgeInsets.all(16).copyWith(bottom: MediaQuery.of(context).padding.bottom + 16),
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).padding.bottom + 16,
),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: AppColors.surfaceContainer)),
@@ -89,6 +262,8 @@ class _ChatScreenState extends State<ChatScreen> {
Expanded(
child: TextField(
controller: _messageController,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
decoration: InputDecoration(
hintText: 'Message ${widget.riderName}...',
border: OutlineInputBorder(
@@ -97,7 +272,8 @@ class _ChatScreenState extends State<ChatScreen> {
),
filled: true,
fillColor: AppColors.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
contentPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
),

View File

@@ -1,28 +1,157 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:doormile/shared/models/models.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/utils/snackbar_utils.dart';
import 'package:doormile/core/theme/app_theme.dart';
import 'package:doormile/core/widgets/common.dart';
class LiveTrackingScreen extends StatelessWidget {
class LiveTrackingScreen extends StatefulWidget {
final Order? order;
const LiveTrackingScreen({super.key, this.order});
@override
State<LiveTrackingScreen> createState() => _LiveTrackingScreenState();
}
class _LiveTrackingScreenState extends State<LiveTrackingScreen> {
static const _defaultCenter = LatLng(11.02, 76.95);
WebSocketChannel? _channel;
GoogleMapController? _mapController;
LatLng _milerPosition = _defaultCenter;
Set<Marker> _markers = {};
int _etaSeconds = 0;
String _milerName = 'Assigning Miler...';
String _status = 'In transit';
Timer? _etaTimer;
@override
void initState() {
super.initState();
_connectWebSocket();
}
void _connectWebSocket() {
final bookingId = widget.order?.id;
if (bookingId == null || bookingId.isEmpty) return;
try {
_channel = WebSocketChannel.connect(
Uri.parse('ws://api.doormile.com/ws/bookings/$bookingId/track'),
);
_channel!.stream.listen(
_onMessage,
onError: (e) => debugPrint('WS error: $e'),
onDone: () => debugPrint('WS closed'),
cancelOnError: false,
);
} catch (e) {
debugPrint('WS connect error: $e');
}
}
void _onMessage(dynamic raw) {
final Map<String, dynamic> data;
try {
data = jsonDecode(raw as String) as Map<String, dynamic>;
} catch (e) {
debugPrint('WS parse error: $e');
return;
}
final lat = (data['lat'] as num?)?.toDouble();
final lon = (data['lon'] as num?)?.toDouble();
final etaMinutes = (data['eta_minutes'] as num?)?.toInt();
final milerName = data['miler_name'] as String?;
final status = data['status'] as String?;
bool positionChanged = false;
if (!mounted) return;
setState(() {
if (milerName != null && milerName.isNotEmpty) {
_milerName = milerName;
}
if (status != null && status.isNotEmpty) {
_status = status;
}
if (lat != null && lon != null && (lat != 0.0 || lon != 0.0)) {
_milerPosition = LatLng(lat, lon);
positionChanged = true;
_markers = {
Marker(
markerId: const MarkerId('miler'),
position: _milerPosition,
infoWindow: InfoWindow(title: _milerName),
),
};
}
if (etaMinutes != null) {
_etaSeconds = etaMinutes * 60;
}
});
if (etaMinutes != null) _restartEtaTimer();
if (positionChanged) {
_mapController?.animateCamera(
CameraUpdate.newLatLng(_milerPosition),
);
}
}
void _restartEtaTimer() {
_etaTimer?.cancel();
if (_etaSeconds <= 0) return;
_etaTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {
if (_etaSeconds > 0) _etaSeconds--;
});
});
}
String get _etaLabel {
if (_etaSeconds > 0) {
return '${(_etaSeconds / 60).ceil()} min';
}
return widget.order?.eta ?? '';
}
@override
void dispose() {
_etaTimer?.cancel();
_channel?.sink.close();
_mapController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final id = order?.id ?? 'DM-CHN-BLR-7741';
final eta = order?.eta ?? '2:30 PM';
final id = widget.order?.id ?? 'DM-CHN-BLR-7741';
return Scaffold(
backgroundColor: AppColors.surface,
body: Stack(
children: [
// Faux map
Positioned.fill(
child: CustomPaint(painter: _MapPainter()),
child: GoogleMap(
initialCameraPosition: const CameraPosition(
target: _defaultCenter,
zoom: 14,
),
markers: _markers,
onMapCreated: (controller) => _mapController = controller,
myLocationButtonEnabled: false,
zoomControlsEnabled: false,
),
),
// Top bar
SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
@@ -46,7 +175,6 @@ class LiveTrackingScreen extends StatelessWidget {
),
),
),
// Bottom detail sheet
Align(
alignment: Alignment.bottomCenter,
child: Container(
@@ -81,14 +209,14 @@ class LiveTrackingScreen extends StatelessWidget {
),
),
StatusChip(
label: 'In transit',
label: _status,
color: AppColors.tertiary,
bg: AppColors.tertiaryFixed.withValues(alpha: 0.5),
),
],
),
const SizedBox(height: 4),
Text('ETA $eta',
Text('ETA $_etaLabel',
style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
const SizedBox(height: 16),
const _MileStepper(activeIndex: 2),
@@ -139,7 +267,7 @@ class LiveTrackingScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Rahul Sharma',
Text(_milerName,
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
Row(
children: [
@@ -195,7 +323,14 @@ class LiveTrackingScreen extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () => Navigator.pushNamed(context, '/chat', arguments: 'Rahul Sharma'),
onPressed: () => Navigator.pushNamed(
context,
'/chat',
arguments: {
'milerName': _milerName,
'bookingId': widget.order?.id ?? '',
},
),
icon: const Icon(Icons.chat_bubble_outline, size: 18),
label: const Text('Chat'),
style: ElevatedButton.styleFrom(
@@ -216,13 +351,7 @@ class LiveTrackingScreen extends StatelessWidget {
}
void _toast(BuildContext context, String msg) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(msg),
backgroundColor: AppColors.inverseSurface,
behavior: SnackBarBehavior.floating,
));
SnackbarUtils.showError(context, msg);
}
}
@@ -274,7 +403,9 @@ class _MileStepper extends StatelessWidget {
height: 3,
color: i == _stops.length - 1
? Colors.transparent
: (i < activeIndex ? _stops[i + 1].$2 : AppColors.surfaceContainerHigh),
: (i < activeIndex
? _stops[i + 1].$2
: AppColors.surfaceContainerHigh),
),
),
],
@@ -292,71 +423,3 @@ class _MileStepper extends StatelessWidget {
);
}
}
/// Lightweight stylised street map with a dashed delivery route.
class _MapPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final bg = Paint()..color = const Color(0xFFEDEAE9);
canvas.drawRect(Offset.zero & size, bg);
final road = Paint()
..color = Colors.white
..strokeWidth = 10;
final thin = Paint()
..color = const Color(0xFFE0DCDB)
..strokeWidth = 4;
for (double x = 30; x < size.width; x += 70) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), thin);
}
for (double y = 80; y < size.height; y += 70) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), thin);
}
canvas.drawLine(Offset(0, size.height * 0.35),
Offset(size.width, size.height * 0.42), road);
canvas.drawLine(Offset(size.width * 0.4, 0),
Offset(size.width * 0.5, size.height * 0.7), road);
// Dashed route
final routePaint = Paint()
..color = AppColors.primary
..strokeWidth = 4
..style = PaintingStyle.stroke;
final path = Path()
..moveTo(size.width * 0.2, size.height * 0.6)
..quadraticBezierTo(size.width * 0.35, size.height * 0.3,
size.width * 0.55, size.height * 0.35)
..quadraticBezierTo(size.width * 0.75, size.height * 0.4,
size.width * 0.7, size.height * 0.18);
_drawDashed(canvas, path, routePaint);
// Origin & destination markers
final dot = Paint()..color = AppColors.primary;
canvas.drawCircle(Offset(size.width * 0.2, size.height * 0.6), 7, dot);
canvas.drawCircle(Offset(size.width * 0.2, size.height * 0.6), 3,
Paint()..color = Colors.white);
canvas.drawCircle(Offset(size.width * 0.7, size.height * 0.18), 9,
Paint()..color = AppColors.primary);
// Vehicle marker (mid route)
final mid = Offset(size.width * 0.55, size.height * 0.35);
canvas.drawCircle(mid, 16, Paint()..color = AppColors.primary.withValues(alpha: 0.2));
canvas.drawCircle(mid, 9, Paint()..color = AppColors.tertiary);
}
void _drawDashed(Canvas canvas, Path path, Paint paint) {
const dash = 8.0;
const gap = 6.0;
for (final metric in path.computeMetrics()) {
double dist = 0;
while (dist < metric.length) {
canvas.drawPath(
metric.extractPath(dist, dist + dash), paint);
dist += dash + gap;
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}