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); + }); +}