diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index c2b224e..8c7b123 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -579,10 +579,19 @@ class _MessagesListState extends State<_MessagesList> { final docs = snapshot.data!.docs; final groups = _groupMessages(docs, currentUserId); - // Only auto-scroll to the bottom when new messages actually arrive, - // and not while we're mid-way through jumping to a searched message. + // Only react when new messages actually arrive, not on every rebuild. + // Flipping isRead below changes document contents but not the count, + // so the receipt write cannot re-trigger itself. if (docs.length != _lastDocCount) { _lastDocCount = docs.length; + + // Reading the chat is what clears the home screen's unread badge. + // Best-effort and deliberately not awaited: the list should render + // immediately regardless of whether the receipt write lands. + unawaited(widget.chatService.markMessagesRead(docs)); + + // Don't yank the list to the bottom while we're mid-way through + // jumping to a searched message. if (_highlightedMessageId == null) { _scrollToBottom(groups.length); } diff --git a/mobile/lib/pages/home_page.dart b/mobile/lib/pages/home_page.dart index 4e1cd90..7068329 100644 --- a/mobile/lib/pages/home_page.dart +++ b/mobile/lib/pages/home_page.dart @@ -3,8 +3,13 @@ import 'package:flutter/gestures.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:cancerlinc/components/call_number.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import 'package:cancerlinc/models/checklist.dart'; import 'package:cancerlinc/models/event.dart'; +import 'package:cancerlinc/models/referral.dart'; +import 'package:cancerlinc/services/chat_service.dart'; +import 'package:cancerlinc/services/checklist_service.dart'; import 'package:cancerlinc/services/event_service.dart'; +import 'package:cancerlinc/services/referral_service.dart'; class HomePage extends StatelessWidget { final void Function(int) onTabChange; @@ -19,11 +24,13 @@ class HomePage extends StatelessWidget { final name = FirebaseAuth.instance.currentUser?.displayName ?? "User"; return name; } - final int newMessageCount = 12; + // Held as fields so each stream is created once per HomePage instance rather + // than on every rebuild, which would make the StreamBuilders flash back to + // their loading state. + final ChatService _chatService = ChatService(); + final ChecklistService _checklistService = ChecklistService(); final EventService _eventService = EventService(); - final int completedChecklists = 10; - final int totalChecklists = 12; - final int activeReferrals = 11; + final ReferralService _referralService = ReferralService(); final String faxNumber = "804-918-0946"; final String phoneNumber = "804-562-0371"; final String addressLine1 = "200 South 3rd St,"; @@ -75,25 +82,10 @@ class HomePage extends StatelessWidget { mainAxisSpacing: 16, childAspectRatio: 1.3, children: [ - _buildCard( - label: "Chat", - icon: Icons.chat_bubble_outline, - info: "$newMessageCount new messages", - onTap: () => onTabChange(1), - ), + _buildChatCard(), _buildCalendarCard(), - _buildCard( - label: "Checklists", - icon: Icons.check_box_outlined, - info: "$completedChecklists of $totalChecklists complete", - onTap: () => onTabChange(2), - ), - _buildCard( - label: "Referrals", - icon: Icons.assignment_ind_outlined, - info: "$activeReferrals active", - onTap: () => onTabChange(3), - ), + _buildChecklistCard(), + _buildReferralsCard(), ], ), SizedBox(height: 24), @@ -371,6 +363,88 @@ class HomePage extends StatelessWidget { String _formatShortDate(DateTime d) => '${_shortMonths[d.month - 1]} ${d.day}'; + Widget _buildChatCard() { + return StreamBuilder( + stream: _chatService.streamCurrentUserUnreadCount(), + builder: (context, snapshot) { + String info; + if (snapshot.connectionState == ConnectionState.waiting) { + info = "Loading..."; + } else if (snapshot.hasError || snapshot.data == null) { + info = "No new messages"; + } else { + final count = snapshot.data!; + info = count == 0 + ? "No new messages" + : "$count new message${count == 1 ? '' : 's'}"; + } + return _buildCard( + label: "Chat", + icon: Icons.chat_bubble_outline, + info: info, + onTap: () => onTabChange(1), + ); + }, + ); + } + + Widget _buildChecklistCard() { + return StreamBuilder>( + stream: _checklistService.streamCurrentUserChecklists(archived: false), + builder: (context, snapshot) { + String info; + if (snapshot.connectionState == ConnectionState.waiting) { + info = "Loading..."; + } else if (snapshot.hasError || snapshot.data == null) { + info = "All caught up"; + } else { + // Unchecked items across every list the user has not archived. + final remaining = snapshot.data!.fold( + 0, + (total, checklist) => + total + checklist.items.where((item) => !item.checked).length, + ); + info = remaining == 0 + ? "All caught up" + : "$remaining item${remaining == 1 ? '' : 's'} left"; + } + return _buildCard( + label: "Checklists", + icon: Icons.check_box_outlined, + info: info, + onTap: () => onTabChange(2), + ); + }, + ); + } + + Widget _buildReferralsCard() { + return StreamBuilder>( + // The service already drops soft-deleted referrals, so every status + // counts here. + stream: _referralService.streamCurrentUserReferrals(), + builder: (context, snapshot) { + String info; + if (snapshot.connectionState == ConnectionState.waiting) { + info = "Loading..."; + } else if (snapshot.hasError || snapshot.data == null) { + info = "No referrals"; + } else { + final count = snapshot.data!.length; + info = count == 0 + ? "No referrals" + : "$count referral${count == 1 ? '' : 's'}"; + } + return _buildCard( + label: "Referrals", + icon: Icons.assignment_ind_outlined, + info: info, + onTap: () => onTabChange(3), + ); + }, + ); + } + Widget _buildCalendarCard() { return StreamBuilder( stream: _eventService.streamNextEvent(), diff --git a/mobile/lib/services/chat_service.dart b/mobile/lib/services/chat_service.dart index 75cce3f..2430a5f 100644 --- a/mobile/lib/services/chat_service.dart +++ b/mobile/lib/services/chat_service.dart @@ -99,6 +99,60 @@ class ChatService { .snapshots(); } + /// Streams how many messages in [chatId] were sent by someone else and have + /// not been marked read yet. + /// + /// Only `isRead` is filtered server-side: an equality-only query is covered + /// by the automatic single-field index, whereas adding `senderId` would need + /// a composite index. The sender check is therefore done client-side. + Stream streamUnreadCount(String chatId) { + return _db + .collection('chats') + .doc(chatId) + .collection('messages') + .where('isRead', isEqualTo: false) + .snapshots() + .map( + (snapshot) => snapshot.docs + .where((doc) => doc.data()['senderId'] != currentUserId) + .length, + ); + } + + /// Unread count for the current user's own chat. The chat document id is the + /// user's uid, so this needs no lookup — a chat that does not exist yet just + /// streams 0. + Stream streamCurrentUserUnreadCount() => + streamUnreadCount(currentUserId); + + /// Flips `isRead` to true on every message in [docs] the current user did not + /// send, so the home screen's unread count clears once the chat is opened. + /// + /// Read receipts are best-effort: a failure here is logged and swallowed + /// rather than surfaced, since the patient did nothing wrong and the write + /// retries on the next snapshot anyway. + Future markMessagesRead(List docs) async { + final unread = docs.where((doc) { + final data = doc.data() as Map?; + if (data == null) return false; + return data['isRead'] != true && data['senderId'] != currentUserId; + }).toList(); + + if (unread.isEmpty) return; + + final batch = _db.batch(); + for (final doc in unread) { + batch.update(doc.reference, {'isRead': true}); + } + + try { + await batch.commit(); + _debugLog('markMessagesRead marked ${unread.length} message(s) read'); + } catch (e) { + _debugLog('markMessagesRead failed: $e'); + } + } + /// Adds a message to [chatId] and updates the chat's lastMessage fields. Future sendMessage(String chatId, String content) async { await _callFunction('sendChatMessage', {