Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions mobile/lib/pages/chat_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
118 changes: 96 additions & 22 deletions mobile/lib/pages/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -371,6 +363,88 @@ class HomePage extends StatelessWidget {
String _formatShortDate(DateTime d) =>
'${_shortMonths[d.month - 1]} ${d.day}';

Widget _buildChatCard() {
return StreamBuilder<int>(
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<List<Checklist>>(
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<int>(
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<List<Referral>>(
// 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<Event?>(
stream: _eventService.streamNextEvent(),
Expand Down
54 changes: 54 additions & 0 deletions mobile/lib/services/chat_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> 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<int> 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<void> markMessagesRead(List<QueryDocumentSnapshot> docs) async {
final unread = docs.where((doc) {
final data = doc.data() as Map<String, dynamic>?;
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<void> sendMessage(String chatId, String content) async {
await _callFunction('sendChatMessage', {
Expand Down