diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index a0810981..785064bc 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/key_verification.dao.dart'; import 'package:twonly/src/database/daos/stories.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -23,6 +24,7 @@ import 'package:twonly/src/visual/components/story_preview.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/news_btn.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_list_components/story_bubble_strip.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart'; @@ -53,6 +55,7 @@ class _ChatListViewState extends State StreamSubscription>? _storiesSub; StreamSubscription>? _ownStoriesSub; final StoryExpiryTimer _ownStoryExpiry = StoryExpiryTimer(); + final StoryExpiryTimer _receivedStoryExpiry = StoryExpiryTimer(); Timer? _typingUpdateTimer; final Set _precachedMediaIds = {}; List _groupsNotPinned = []; @@ -247,6 +250,7 @@ class _ChatListViewState extends State byGroup.putIfAbsent(item.message.groupId, () => []).add(item); } setState(() => _storiesByGroup = byGroup); + _scheduleReceivedStoryExpiry(); }); _ownStoriesSub = twonlyDB.storiesDao.watchOwnStoryItems().listen((items) { if (!mounted) return; @@ -260,6 +264,30 @@ class _ChatListViewState extends State return _ownStories.where((item) => item.isActiveAt(now)).toList(); } + List get _storyBubbles { + final contactsById = { + for (final contacts in _contactsByGroup.values) + for (final contact in contacts) contact.userId: contact, + }; + final groupsById = { + for (final group in [ + ..._groupsPinned, + ..._groupsNotPinned, + ..._groupsArchived, + ]) + group.groupId: group, + }; + return buildStoryBubbleItems( + stories: _storiesByGroup.values.expand((items) => items), + labelFor: (story) => switch (contactsById[story.senderId]) { + final Contact contact => getContactDisplayName(contact), + null => + groupsById[story.message.groupId]?.groupName ?? + story.senderId.toString(), + }, + ); + } + /// Puts the avatar back when the oldest own story item runs out. void _scheduleOwnStoryExpiry() { final oldest = _activeOwnStories.firstOrNull; @@ -278,6 +306,32 @@ class _ChatListViewState extends State ); } + /// Removes an unseen story bubble at its exact 24-hour boundary even if + /// the database stream is otherwise quiet. + void _scheduleReceivedStoryExpiry() { + final now = clock.now(); + final active = + _storiesByGroup.values + .expand((items) => items) + .where((item) => !item.seen && item.isActiveAt(now)) + .toList() + ..sort((a, b) => a.postedAt.compareTo(b.postedAt)); + final oldest = active.firstOrNull; + if (oldest == null) { + _receivedStoryExpiry.cancel(); + return; + } + _receivedStoryExpiry.schedule( + postedAt: oldest.postedAt, + now: now, + onExpired: () { + if (!mounted) return; + setState(() {}); + _scheduleReceivedStoryExpiry(); + }, + ); + } + /// Labels of a chat: those of the contact for direct chats, those of the /// group itself otherwise. List _contactGroupsFor(Group group) { @@ -313,6 +367,7 @@ class _ChatListViewState extends State _storiesSub?.cancel(); _ownStoriesSub?.cancel(); _ownStoryExpiry.cancel(); + _receivedStoryExpiry.cancel(); super.dispose(); } @@ -348,6 +403,7 @@ class _ChatListViewState extends State final plan = context.select( (p) => p.plan, ); + final storyBubbles = _storyBubbles; return Scaffold( appBar: AppBar( title: Row( @@ -468,11 +524,23 @@ class _ChatListViewState extends State Expanded( child: ListView.builder( itemCount: + (storyBubbles.isNotEmpty ? 1 : 0) + _groupsPinned.length + (_groupsPinned.isNotEmpty ? 1 : 0) + _groupsNotPinned.length + (_groupsArchived.isNotEmpty ? 1 : 0), itemBuilder: (context, index) { + if (storyBubbles.isNotEmpty) { + if (index == 0) { + return StoryBubbleStrip( + items: storyBubbles, + onTap: (item) => context.push( + Routes.chatsStory(item.story.senderId), + ), + ); + } + index -= 1; + } if (index >= _groupsNotPinned.length + _groupsPinned.length + diff --git a/lib/src/visual/views/chats/chat_list_components/story_bubble_strip.comp.dart b/lib/src/visual/views/chats/chat_list_components/story_bubble_strip.comp.dart new file mode 100644 index 00000000..0b451dfd --- /dev/null +++ b/lib/src/visual/views/chats/chat_list_components/story_bubble_strip.comp.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/story_preview.comp.dart'; + +/// A contact and the first unseen item in their active story. +class StoryBubbleItem { + const StoryBubbleItem({required this.story, required this.label}); + + final StoryItem story; + final String label; +} + +/// Groups active, unseen story items into one bubble per contact. The bubble +/// opens the oldest unseen item, while contacts with newer activity come first. +List buildStoryBubbleItems({ + required Iterable stories, + required String Function(StoryItem story) labelFor, + DateTime? now, +}) { + final currentTime = now ?? clock.now(); + final unseenBySender = >{}; + for (final story in stories) { + if (!story.seen && story.isActiveAt(currentTime)) { + unseenBySender.putIfAbsent(story.senderId, () => []).add(story); + } + } + for (final stories in unseenBySender.values) { + stories.sort((a, b) => a.postedAt.compareTo(b.postedAt)); + } + final groupedStories = unseenBySender.values.toList() + ..sort((a, b) => b.last.postedAt.compareTo(a.last.postedAt)); + return [ + for (final stories in groupedStories) + StoryBubbleItem(story: stories.first, label: labelFor(stories.first)), + ]; +} + +/// The horizontally scrolling row of unseen stories at the top of the chats. +class StoryBubbleStrip extends StatelessWidget { + const StoryBubbleStrip({ + required this.items, + required this.onTap, + super.key, + }); + + final List items; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) return const SizedBox.shrink(); + return SizedBox( + height: 100, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (context, index) { + final item = items[index]; + return Semantics( + button: true, + label: item.label, + child: Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey(item.story.senderId), + borderRadius: BorderRadius.circular(12), + onTap: () => onTap(item), + child: SizedBox( + width: 70, + child: Column( + children: [ + _StoryBubble(story: item.story), + const SizedBox(height: 4), + Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +/// A circular story preview whose ring drains over the story's lifetime. +class _StoryBubble extends StatefulWidget { + const _StoryBubble({required this.story}); + + final StoryItem story; + + @override + State<_StoryBubble> createState() => _StoryBubbleState(); +} + +class _StoryBubbleState extends State<_StoryBubble> { + Timer? _progressTimer; + + @override + void initState() { + super.initState(); + _progressTimer = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _progressTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final remaining = widget.story.expiresAt.difference(clock.now()); + final progress = (remaining.inMilliseconds / storyLifetime.inMilliseconds) + .clamp(0.0, 1.0); + return SizedBox.square( + dimension: 64, + child: Stack( + alignment: Alignment.center, + children: [ + StoryPreview( + mediaFile: widget.story.mediaFile, + width: 58, + height: 58, + circle: true, + ), + Positioned.fill( + child: CircularProgressIndicator( + value: progress, + strokeWidth: 1.5, + strokeCap: StrokeCap.round, + color: context.color.primary, + backgroundColor: context.color.surfaceContainerHighest, + ), + ), + ], + ), + ); + } +} diff --git a/test/visual/components/story_bubble_strip_test.dart b/test/visual/components/story_bubble_strip_test.dart new file mode 100644 index 00000000..ad322de4 --- /dev/null +++ b/test/visual/components/story_bubble_strip_test.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:clock/clock.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/visual/components/story_preview.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_list_components/story_bubble_strip.comp.dart'; + +void main() { + late TwonlyDB database; + late Directory support; + late MediaFile media; + + setUp(() async { + support = Directory.systemTemp.createTempSync('story_bubbles_test'); + AppEnvironment.initTesting(customSupportDir: support.path); + database = TwonlyDB(NativeDatabase.memory()); + await database.customStatement( + "INSERT INTO media_files(media_id, type) VALUES ('story', 'image')", + ); + media = (await database.mediaFilesDao.getMediaFileById('story'))!; + }); + + tearDown(() async { + await database.close(); + support.deleteSync(recursive: true); + }); + + StoryItem story({ + required int senderId, + required String messageId, + required DateTime postedAt, + bool seen = false, + }) => StoryItem( + message: Message( + groupId: 'group-$senderId', + messageId: messageId, + senderId: senderId, + type: 'media', + mediaId: media.mediaId, + mediaStored: false, + mediaReopened: false, + isDeletedFromSender: false, + isWidgetMedia: false, + isStory: true, + openedAt: seen ? postedAt.add(const Duration(minutes: 1)) : null, + createdAt: postedAt, + ), + mediaFile: media, + ); + + test('only active unseen contacts get a bubble', () { + final now = DateTime(2026, 10, 6, 12); + final items = buildStoryBubbleItems( + stories: [ + story( + senderId: 1, + messageId: 'first-old', + postedAt: now.subtract(const Duration(hours: 2)), + ), + story( + senderId: 1, + messageId: 'first-new', + postedAt: now.subtract(const Duration(hours: 1)), + ), + story( + senderId: 2, + messageId: 'second', + postedAt: now.subtract(const Duration(minutes: 30)), + ), + story( + senderId: 3, + messageId: 'opened', + postedAt: now.subtract(const Duration(minutes: 10)), + seen: true, + ), + story( + senderId: 4, + messageId: 'expired', + postedAt: now.subtract(const Duration(hours: 25)), + ), + ], + labelFor: (story) => 'Contact ${story.senderId}', + now: now, + ); + + expect(items.map((item) => item.story.message.messageId), [ + 'second', + 'first-old', + ]); + expect(items.map((item) => item.label), ['Contact 2', 'Contact 1']); + }); + + testWidgets('shows a circular preview with remaining-time progress', ( + tester, + ) async { + final now = DateTime(2026, 10, 6, 12); + final item = StoryBubbleItem( + story: story( + senderId: 1, + messageId: 'half-life', + postedAt: now.subtract(const Duration(hours: 12)), + ), + label: 'Anna', + ); + StoryBubbleItem? tapped; + + await withClock(Clock.fixed(now), () async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StoryBubbleStrip( + items: [item], + onTap: (item) => tapped = item, + ), + ), + ), + ); + + expect(find.text('Anna'), findsOneWidget); + expect( + tester.widget(find.byType(StoryPreview)).circle, + true, + ); + final progress = tester.widget( + find.byType(CircularProgressIndicator), + ); + expect(progress.value, closeTo(0.5, 0.001)); + expect( + tester.getSize(find.byType(CircularProgressIndicator)), + const Size.square(64), + ); + expect(progress.strokeWidth, 1.5); + + await tester.tap(find.byKey(const ValueKey(1))); + expect(tapped, same(item)); + await tester.pumpWidget(const SizedBox()); + }); + }); +}