From bfea7201f7129d3a44f6564c10a98c24227f22af Mon Sep 17 00:00:00 2001 From: Julius Joska Date: Sun, 11 Oct 2026 15:31:53 +0200 Subject: [PATCH] fix(chat): clear unread state for messages read in an open chat Messages arriving while a conversation was open were never marked as read, so the chat list kept showing them as unread after going back. The screen now marks incoming messages read while it is on top and the app is in the foreground, again when the app returns to the foreground, and on leaving. Read state is now monotonic in Conversation.updateMessage, so a stale copy of a message (attachment download, delayed resend) can no longer flip it back to unread. Also create the swipe-to-reply animation controller lazily instead of in dispose(). --- .../conversation/conversation_screen.dart | 60 ++++++++++++++++++- app/lib/models/conversation.dart | 10 +++- app/test/conversation_read_flag_test.dart | 37 ++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 app/test/conversation_read_flag_test.dart diff --git a/app/lib/features/conversation/conversation_screen.dart b/app/lib/features/conversation/conversation_screen.dart index de189ec..1b0088e 100644 --- a/app/lib/features/conversation/conversation_screen.dart +++ b/app/lib/features/conversation/conversation_screen.dart @@ -64,7 +64,8 @@ class ConversationScreen extends ConsumerStatefulWidget { ConsumerState createState() => _ConversationScreenState(); } -class _ConversationScreenState extends ConsumerState { +class _ConversationScreenState extends ConsumerState + with WidgetsBindingObserver { final _messageController = TextEditingController(); final _scrollController = ScrollController(); final _searchController = TextEditingController(); @@ -133,9 +134,43 @@ class _ConversationScreenState extends ConsumerState { _drafts = ref.read(draftsProvider.notifier); _restoreDraft(); _messageController.addListener(_scheduleDraftSave); + _conversations = ref.read(conversationsProvider.notifier); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark()); } + /// Notifier zachycený v initState: v dispose už `ref` použít nejde. + late final ConversationsNotifier _conversations; + + /// Zpráva, která přijde do otevřeného chatu, je přečtená, jen když ji + /// uživatel opravdu vidí: appka v popředí a tahle obrazovka navrchu + /// (ne pod profilem kontaktu nebo náhledem fotky). + bool get _isSeenNow => + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed && + (_route?.isCurrent ?? false); + + // Route uložená v didChangeDependencies: lifecycle callback může přijít + // i ve chvíli, kdy je element už deaktivovaný a hledat předka nesmí. + ModalRoute? _route; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _route = ModalRoute.of(context); + } + + void _markIfSeen() { + if (!mounted || !_isSeenNow) return; + _conversations.markAsRead(widget.contactId); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Zprávy, které přišly se zhasnutým displejem nad otevřeným chatem, + // uživatel uvidí až po návratu do appky. + if (state == AppLifecycleState.resumed) _markIfSeen(); + } + /// Zapamatuje si první nepřečtenou zprávu a teprve pak konverzaci označí /// za přečtenou — jinak by řádek „nové zprávy“ nikdy nebylo kam dát. Future _captureUnreadAndMark() async { @@ -185,6 +220,10 @@ class _ConversationScreenState extends ConsumerState { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + // Odchod z chatu: všechno, co bylo na obrazovce, je přečtené – i zprávy + // dorazivší těsně před odchodem, které listener nestihl označit. + _conversations.markAsRead(widget.contactId); _draftTimer?.cancel(); // Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text // upravované zprávy. @@ -214,6 +253,17 @@ class _ConversationScreenState extends ConsumerState { .where((c) => c.publicIdentity.id == widget.contactId) .firstOrNull; final conversationAsync = ref.watch(conversationProvider(widget.contactId)); + // Nová příchozí zpráva v otevřeném chatu se rovnou čte; jinak by po + // návratu do seznamu svítila jako nepřečtená. + // První načtení vynechá: to obslouží _captureUnreadAndMark, které si + // nejdřív zapamatuje řádek „N nových zpráv“. + ref.listen(conversationProvider(widget.contactId), (prev, next) { + if (prev?.valueOrNull == null) return; + final conv = next.valueOrNull; + final myId = ref.read(identityProvider).valueOrNull?.id; + if (conv == null || myId == null || conv.unreadCount(myId) == 0) return; + _markIfSeen(); + }); final identity = ref.watch(identityProvider).valueOrNull; // Skupinová konverzace: contactId == gid final group = ref.watch(groupProvider(widget.contactId)).valueOrNull; @@ -2150,7 +2200,11 @@ class _SwipeToReplyState extends State<_SwipeToReply> static const _max = 84.0; static const _trigger = 58.0; - late final AnimationController _back = AnimationController( + // Kontroler vzniká až při prvním tahu. Dřív ho `late final` vytvořil + // teprve dispose() u bublin, které nikdo netáhl – nad deaktivovaným + // elementem, což v debug buildu padá na hledání TickerMode. + AnimationController? _backCtl; + AnimationController get _back => _backCtl ??= AnimationController( vsync: this, duration: const Duration(milliseconds: 180)) ..addListener(() => setState(() => _dx = _from * (1 - _back.value))); double _dx = 0; @@ -2159,7 +2213,7 @@ class _SwipeToReplyState extends State<_SwipeToReply> @override void dispose() { - _back.dispose(); + _backCtl?.dispose(); super.dispose(); } diff --git a/app/lib/models/conversation.dart b/app/lib/models/conversation.dart index 59eb236..98827d4 100644 --- a/app/lib/models/conversation.dart +++ b/app/lib/models/conversation.dart @@ -55,9 +55,17 @@ class Conversation { /// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…). /// Neexistující id se tiše ignoruje (zpráva mohla být smazána). + /// + /// Přečtení je jednosměrné: aktualizace nesmí vrátit už přečtenou zprávu + /// mezi nepřečtené. Volající často drží kopii zprávy načtenou před + /// otevřením chatu (stahování přílohy, dodatečné odeslání) a bez téhle + /// pojistky by zápis po návratu ze stahování obnovil odznak nepřečtených. void updateMessage(Message message) { final index = messages.indexWhere((m) => m.id == message.id); - if (index >= 0) messages[index] = message; + if (index < 0) return; + messages[index] = messages[index].isRead && !message.isRead + ? message.copyWith(isRead: true) + : message; } /// Označ všechny zprávy jako přečtené diff --git a/app/test/conversation_read_flag_test.dart b/app/test/conversation_read_flag_test.dart new file mode 100644 index 0000000..a736e6e --- /dev/null +++ b/app/test/conversation_read_flag_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/models/conversation.dart'; +import 'package:sepot/models/message.dart'; + +/// Read state only moves forward. Updates that carry an older copy of the +/// message (attachment download finishing, delivery status) must not flip a +/// message the user has already read back to unread. +void main() { + Message zprava({bool isRead = false}) => Message( + id: 'm1', + senderId: 'alice', + recipientId: 'bob', + type: MessageType.text, + content: 'hi', + timestamp: DateTime.parse('2026-10-10T09:00:00.000'), + isRead: isRead, + ); + + test('stale unread copy does not undo a read message', () { + final c = Conversation(contactId: 'alice')..addMessage(zprava()); + c.markAllRead('bob'); + + c.updateMessage(zprava().copyWith(content: 'hi (edited)')); + + expect(c.messages.single.isRead, isTrue); + expect(c.messages.single.content, 'hi (edited)'); + expect(c.unreadCount('bob'), 0); + }); + + test('update can still mark an unread message as read', () { + final c = Conversation(contactId: 'alice')..addMessage(zprava()); + + c.updateMessage(zprava(isRead: true)); + + expect(c.unreadCount('bob'), 0); + }); +}