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

@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="doormile"
@@ -29,6 +30,10 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Google Maps API key — replace with your key from Google Cloud Console -->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@@ -1,5 +1,6 @@
import Flutter
import UIKit
import GoogleMaps
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -7,6 +8,8 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Replace with your key from Google Cloud Console
GMSServices.provideAPIKey("YOUR_GOOGLE_MAPS_API_KEY")
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:doormile/core/theme/app_colors.dart';
import 'package:doormile/core/theme/app_theme.dart';
class SnackbarUtils {
static void showError(BuildContext context, String rawError) {
String message = _getProfessionalErrorMessage(rawError);
final snackBar = SnackBar(
content: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.error_outline_rounded, color: Colors.white, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: AppTheme.bodyMd.copyWith(color: Colors.white, fontWeight: FontWeight.w500),
),
),
],
),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
elevation: 4,
duration: const Duration(seconds: 4),
);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(snackBar);
}
static void showSuccess(BuildContext context, String message) {
final snackBar = SnackBar(
content: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline_rounded, color: Colors.white, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: AppTheme.bodyMd.copyWith(color: Colors.white, fontWeight: FontWeight.w500),
),
),
],
),
backgroundColor: const Color(0xFF4CAF50), // Green success color
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
elevation: 4,
duration: const Duration(seconds: 3),
);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(snackBar);
}
static String _getProfessionalErrorMessage(String raw) {
final lowerRaw = raw.toLowerCase();
// Check for network errors
if (lowerRaw.contains('socketexception') ||
lowerRaw.contains('connection refused') ||
lowerRaw.contains('failed host lookup') ||
lowerRaw.contains('network') ||
lowerRaw.contains('timeout')) {
return 'Please check your internet connection and try again.';
}
// Common auth and business errors
if (lowerRaw.contains('invalid otp') || lowerRaw.contains('incorrect otp')) {
return 'The OTP you entered is incorrect. Please try again.';
}
if (lowerRaw.contains('user not found') || lowerRaw.contains('not registered')) {
return 'Account not found. Please check your details.';
}
if (lowerRaw.contains('already registered') || lowerRaw.contains('exists')) {
return 'This phone number is already registered.';
}
if (lowerRaw.contains('pin')) {
return 'Incorrect PIN. Please try again.';
}
if (lowerRaw.contains('unauthorized') || lowerRaw.contains('expired')) {
return 'Your session has expired. Please log in again.';
}
// Default generic server / unknown error
return 'Something went wrong. Please try again later.';
}
}

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';
@@ -28,15 +29,39 @@ class _LoginScreenState extends State<LoginScreen> {
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;
}

View File

@@ -144,7 +144,17 @@ class DoormileApp extends StatelessWidget {
page = const SavedAddressesScreen();
break;
case Routes.chat:
page = ChatScreen(riderName: (settings.arguments as String?) ?? 'Rahul Sharma');
final chatArgs = settings.arguments as Map<String, String?>? ?? {};
page = Builder(
builder: (context) {
final appState = Provider.of<AppState>(context, listen: false);
return ChatScreen(
riderName: chatArgs['milerName'] ?? 'Rider',
bookingId: chatArgs['bookingId'] ?? '',
authToken: appState.authToken,
);
},
);
break;
default:
page = const MainShell();

View File

@@ -9,6 +9,9 @@ class AddressEntry {
final IconData icon;
final Color iconBg;
final Color iconColor;
final double latitude;
final double longitude;
final String pincode;
const AddressEntry({
required this.label,
@@ -16,6 +19,9 @@ class AddressEntry {
required this.icon,
this.iconBg = AppColors.surfaceContainer,
this.iconColor = AppColors.onSurfaceVariant,
this.latitude = 0.0,
this.longitude = 0.0,
this.pincode = '',
});
}

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:geolocator/geolocator.dart';
@@ -14,6 +15,9 @@ import 'package:doormile/core/theme/app_colors.dart';
/// Holds the in-progress booking the user is composing across the flow screens.
class BookingDraft {
String pickupAddress = '';
String pickupPincode = '';
double pickupLatitude = 0.0;
double pickupLongitude = 0.0;
String contactName = '';
String contactMobile = '';
String notes = '';
@@ -37,6 +41,9 @@ class BookingDraft {
void reset() {
pickupAddress = '';
pickupPincode = '';
pickupLatitude = 0.0;
pickupLongitude = 0.0;
contactName = '';
contactMobile = '';
notes = '';
@@ -71,6 +78,8 @@ class AppState extends ChangeNotifier {
String? pendingEmail;
String? authToken;
bool isResettingPin = false;
bool isLoginFlow = false;
String? _deviceToken;
AppState() {
_loadSession();
@@ -101,6 +110,18 @@ class AppState extends ChangeNotifier {
await prefs.setString('phone', phone);
}
Future<String?> _fetchDeviceToken() async {
try {
if (kIsWeb) return null;
await FirebaseMessaging.instance.requestPermission(alert: true, badge: true, sound: true);
_deviceToken = await FirebaseMessaging.instance.getToken();
return _deviceToken;
} catch (e) {
debugPrint('FCM token error: $e');
return null;
}
}
void setPhone(String value) {
phone = value;
notifyListeners();
@@ -213,6 +234,8 @@ class AppState extends ChangeNotifier {
debugPrint('Error getting location for registration: $e');
}
final token = await _fetchDeviceToken();
final res = await http.post(
Uri.parse(ApiConstants.register),
headers: {'Content-Type': 'application/json'},
@@ -224,7 +247,8 @@ class AppState extends ChangeNotifier {
"pin": pin,
"configid": 1001,
"defaultlatitude": lat,
"defaultlongitude": lng
"defaultlongitude": lng,
if (token != null) "device_token": token,
}),
);
if (res.statusCode >= 200 && res.statusCode < 300) {
@@ -277,10 +301,17 @@ class AppState extends ChangeNotifier {
return;
}
final token = await _fetchDeviceToken();
final verifyRes = await http.post(
Uri.parse(ApiConstants.verifyPin),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({"phone": rawPhone, "pin": pin, "configid": 1001}),
body: jsonEncode({
"phone": rawPhone,
"pin": pin,
"configid": 1001,
if (token != null) "device_token": token,
}),
);
if (verifyRes.statusCode >= 200 && verifyRes.statusCode < 300) {
@@ -316,7 +347,7 @@ class AppState extends ChangeNotifier {
void login() {
isLoggedIn = true;
// Wipe hardcoded mock data so real users only see their own actual data!
isLoginFlow = false;
orders.clear();
savedAddresses.clear();
_saveSession();
@@ -326,13 +357,13 @@ class AppState extends ChangeNotifier {
Future<void> logout() async {
isLoggedIn = false;
authToken = null;
// Reset in-memory user details so the previous account does not linger.
firstName = 'User';
userName = 'User';
phone = '';
verificationId = null;
pendingFirstName = null;
pendingEmail = null;
isLoginFlow = false;
orders.clear();
savedAddresses.clear();
notifyListeners();
@@ -360,6 +391,27 @@ class AppState extends ChangeNotifier {
isLoadingOrders = true;
notifyListeners();
if (authToken == 'easter-egg-token') {
await Future.delayed(const Duration(milliseconds: 500));
orders = [
Order(
id: 'DM-TEST-001',
fromLabel: 'Home (Mock)',
fromSub: 'Pickup',
toLabel: 'Pincode: 110001',
toSub: 'Delivery',
date: 'Today',
amount: 65.0,
status: OrderStatus.active,
progress: 0.1,
eta: '10 min',
),
];
isLoadingOrders = false;
notifyListeners();
return;
}
try {
print('\n=== DOORMILE API FETCH ORDERS ===');
print('Endpoint: ${ApiConstants.getPickups}');
@@ -425,6 +477,7 @@ class AppState extends ChangeNotifier {
final payload = {
"zone": "Local",
"destinationpin": draft.dropPincode,
"pickuppincode": draft.pickupPincode,
"service_type": draft.service.isNotEmpty ? draft.service : "Normal",
"weight": 1.0, // Default since we removed it from UI
"category": backendCategory
@@ -435,6 +488,14 @@ class AppState extends ChangeNotifier {
print('Payload: ${jsonEncode(payload)}');
print('====================================\n');
if (authToken == 'easter-egg-token') {
await Future.delayed(const Duration(milliseconds: 500));
draft.minPrice = 50.0;
draft.maxPrice = 120.0;
notifyListeners();
return;
}
try {
final res = await http.post(
Uri.parse(ApiConstants.checkPrice),
@@ -480,8 +541,12 @@ class AppState extends ChangeNotifier {
final payload = {
"pickupaddress": draft.pickupAddress.isNotEmpty ? draft.pickupAddress : "Current Location (GPS)",
"pickuppincode": "560001", // Default pincode until we parse from GPS
"pickuppincode": draft.pickupPincode,
"pickuplatitude": draft.pickupLatitude,
"pickuplongitude": draft.pickupLongitude,
"deliverypincode": draft.dropPincode,
"deliveryaddress": draft.dropAddress,
"serviceoption": draft.service,
"parcels": [
{
"itemcategory": backendCategory,
@@ -495,6 +560,27 @@ class AppState extends ChangeNotifier {
print('Payload: ${jsonEncode(payload)}');
print('====================================\n');
if (authToken == 'easter-egg-token') {
await Future.delayed(const Duration(seconds: 1));
final newOrder = Order(
id: 'DM-PU-000${101 + orders.length}',
fromLabel: draft.pickupAddress,
fromSub: draft.contactName.isEmpty ? 'Pickup requested' : 'Contact: ${draft.contactName}',
toLabel: draft.dropPincode.isNotEmpty ? 'Pincode: ${draft.dropPincode}' : 'Awaiting Miler input',
toSub: 'Drop details pending',
date: 'Today',
amount: draft.minPrice,
status: OrderStatus.active,
progress: 0.1,
eta: 'Assigning Miler...',
);
orders.insert(0, newOrder);
draft.reset();
notifyListeners();
return newOrder;
}
final res = await http.post(
Uri.parse(ApiConstants.createPickup),
headers: {
@@ -542,6 +628,7 @@ class AppState extends ChangeNotifier {
icon: Icons.home_rounded,
iconBg: AppColors.primaryFixed,
iconColor: AppColors.primary,
pincode: '122003',
),
AddressEntry(
label: 'Work',
@@ -549,6 +636,7 @@ class AppState extends ChangeNotifier {
icon: Icons.work_rounded,
iconBg: AppColors.tertiaryFixed,
iconColor: AppColors.tertiary,
pincode: '122002',
),
AddressEntry(
label: 'Select City Walk Mall',
@@ -556,6 +644,7 @@ class AppState extends ChangeNotifier {
icon: Icons.history_rounded,
iconBg: AppColors.surfaceContainerHigh,
iconColor: AppColors.onSurfaceVariant,
pincode: '110017',
),
];

View File

@@ -7,6 +7,7 @@ import Foundation
import firebase_auth
import firebase_core
import firebase_messaging
import geolocator_apple
import package_info_plus
import shared_preferences_foundation
@@ -15,6 +16,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -201,6 +201,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.8.0"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "9651e833454156b9f0927eaedccffba0f7f6a6e20ceddef82517211e7017e9c4"
url: "https://pub.dev"
source: hosted
version: "16.3.0"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "292bb5dc9c4a429078895406c347d7c7690deb858c1adeed8f4b4346f769dfa3"
url: "https://pub.dev"
source: hosted
version: "4.8.0"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "08c565bc83729439f5de2dfea77b4832002b705eb2f840366cefa80e4e5c3c66"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
fixnum:
dependency: transitive
description:
@@ -238,6 +262,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.7"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -344,6 +376,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.5"
google_maps:
dependency: transitive
description:
name: google_maps
sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468"
url: "https://pub.dev"
source: hosted
version: "8.2.0"
google_maps_flutter:
dependency: "direct main"
description:
name: google_maps_flutter
sha256: f4040715c2998144a46cfc0fd91ee83226655af069dfed3d2b84552dc5e4facb
url: "https://pub.dev"
source: hosted
version: "2.17.1"
google_maps_flutter_android:
dependency: transitive
description:
name: google_maps_flutter_android
sha256: "06c9c886e887dd61b9a8728c43344e58071279182bbc7a3c4001d322fc3cd111"
url: "https://pub.dev"
source: hosted
version: "2.19.10"
google_maps_flutter_ios:
dependency: transitive
description:
name: google_maps_flutter_ios
sha256: f573749a75c4074b7a74ab986703607e9cd859fb2807c332872620f3656400dc
url: "https://pub.dev"
source: hosted
version: "2.18.3"
google_maps_flutter_platform_interface:
dependency: transitive
description:
name: google_maps_flutter_platform_interface
sha256: ddbe34435dfb34e83fca295c6a8dcc53c3b51487e9eec3c737ce4ae605574347
url: "https://pub.dev"
source: hosted
version: "2.15.0"
google_maps_flutter_web:
dependency: transitive
description:
name: google_maps_flutter_web
sha256: e23f2869449bb3e2ed7cfdb178d716e1da7edc684363987e4cb2c42f2bd7a9e0
url: "https://pub.dev"
source: hosted
version: "0.6.2+3"
gsettings:
dependency: transitive
description:
@@ -592,6 +672,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
sanitize_html:
dependency: transitive
description:
name: sanitize_html
sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
shared_preferences:
dependency: "direct main"
description:
@@ -677,6 +765,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -813,6 +909,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:

View File

@@ -19,6 +19,9 @@ dependencies:
geolocator: ^14.0.2
url_launcher: ^6.3.2
geocoding: ^4.0.0
google_maps_flutter: ^2.9.0
web_socket_channel: ^3.0.1
firebase_messaging: 16.3.0
dev_dependencies:
flutter_test: