From 9096fba60f2f7dea05f29477598f374cfa9bf123 Mon Sep 17 00:00:00 2001 From: VidurShah Date: Wed, 19 Aug 2026 16:15:41 -0400 Subject: [PATCH] feat(mobile): render real homepage stats Replace the hardcoded placeholders in the home screen's stat grid (12 new messages, 10 of 12 checklists, 11 referrals) with the signed-in patient's own numbers, so the tiles stop showing figures unrelated to the account. - Wire the Chat, Checklists and Referrals tiles to StreamBuilders, following the shape _buildCalendarCard already used: Chat counts unread messages from staff, Checklists counts unchecked items across non-archived lists, and Referrals counts every non-deleted referral regardless of status. - Add ChatService.streamUnreadCount, filtering server-side on isRead only. That equality-only query is covered by the automatic single-field index, whereas also filtering senderId would need a new composite index, so the sender check is done client-side. - Add ChatService.markMessagesRead and call it when the message list renders, so opening the chat actually clears the count. firestore.rules already lets each side flip isRead on messages they did not send, so this needs no rules or backend change. The write reuses the docs the list already streamed, is best-effort, and sits behind the existing doc-count guard so it cannot re-trigger itself. Closes #72 --- mobile/lib/pages/chat_page.dart | 13 ++- mobile/lib/pages/home_page.dart | 118 +++++++++++++++++++++----- mobile/lib/services/chat_service.dart | 54 ++++++++++++ 3 files changed, 161 insertions(+), 24 deletions(-) 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', {