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

@@ -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),
),
),
),