feat: standard error handling, web bug fixes, and mock data setup
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user