show storry bubble

This commit is contained in:
otsmr 2026-10-06 22:26:53 +02:00
parent 854121c8c4
commit 7b5ca07e2f
3 changed files with 366 additions and 0 deletions

View file

@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.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/key_verification.dao.dart';
import 'package:twonly/src/database/daos/stories.dao.dart'; import 'package:twonly/src/database/daos/stories.dao.dart';
import 'package:twonly/src/database/twonly.db.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/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/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/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/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/chats/media_viewer_components/story_expiry_timer.dart';
import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart';
@ -53,6 +55,7 @@ class _ChatListViewState extends State<ChatListView>
StreamSubscription<List<StoryItem>>? _storiesSub; StreamSubscription<List<StoryItem>>? _storiesSub;
StreamSubscription<List<OwnStoryItem>>? _ownStoriesSub; StreamSubscription<List<OwnStoryItem>>? _ownStoriesSub;
final StoryExpiryTimer _ownStoryExpiry = StoryExpiryTimer(); final StoryExpiryTimer _ownStoryExpiry = StoryExpiryTimer();
final StoryExpiryTimer _receivedStoryExpiry = StoryExpiryTimer();
Timer? _typingUpdateTimer; Timer? _typingUpdateTimer;
final Set<String> _precachedMediaIds = {}; final Set<String> _precachedMediaIds = {};
List<Group> _groupsNotPinned = []; List<Group> _groupsNotPinned = [];
@ -247,6 +250,7 @@ class _ChatListViewState extends State<ChatListView>
byGroup.putIfAbsent(item.message.groupId, () => []).add(item); byGroup.putIfAbsent(item.message.groupId, () => []).add(item);
} }
setState(() => _storiesByGroup = byGroup); setState(() => _storiesByGroup = byGroup);
_scheduleReceivedStoryExpiry();
}); });
_ownStoriesSub = twonlyDB.storiesDao.watchOwnStoryItems().listen((items) { _ownStoriesSub = twonlyDB.storiesDao.watchOwnStoryItems().listen((items) {
if (!mounted) return; if (!mounted) return;
@ -260,6 +264,30 @@ class _ChatListViewState extends State<ChatListView>
return _ownStories.where((item) => item.isActiveAt(now)).toList(); return _ownStories.where((item) => item.isActiveAt(now)).toList();
} }
List<StoryBubbleItem> get _storyBubbles {
final contactsById = <int, Contact>{
for (final contacts in _contactsByGroup.values)
for (final contact in contacts) contact.userId: contact,
};
final groupsById = <String, Group>{
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. /// Puts the avatar back when the oldest own story item runs out.
void _scheduleOwnStoryExpiry() { void _scheduleOwnStoryExpiry() {
final oldest = _activeOwnStories.firstOrNull; final oldest = _activeOwnStories.firstOrNull;
@ -278,6 +306,32 @@ class _ChatListViewState extends State<ChatListView>
); );
} }
/// 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 /// Labels of a chat: those of the contact for direct chats, those of the
/// group itself otherwise. /// group itself otherwise.
List<ContactGroup> _contactGroupsFor(Group group) { List<ContactGroup> _contactGroupsFor(Group group) {
@ -313,6 +367,7 @@ class _ChatListViewState extends State<ChatListView>
_storiesSub?.cancel(); _storiesSub?.cancel();
_ownStoriesSub?.cancel(); _ownStoriesSub?.cancel();
_ownStoryExpiry.cancel(); _ownStoryExpiry.cancel();
_receivedStoryExpiry.cancel();
super.dispose(); super.dispose();
} }
@ -348,6 +403,7 @@ class _ChatListViewState extends State<ChatListView>
final plan = context.select<PurchasesProvider, SubscriptionPlan>( final plan = context.select<PurchasesProvider, SubscriptionPlan>(
(p) => p.plan, (p) => p.plan,
); );
final storyBubbles = _storyBubbles;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
@ -468,11 +524,23 @@ class _ChatListViewState extends State<ChatListView>
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: itemCount:
(storyBubbles.isNotEmpty ? 1 : 0) +
_groupsPinned.length + _groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0) + (_groupsPinned.isNotEmpty ? 1 : 0) +
_groupsNotPinned.length + _groupsNotPinned.length +
(_groupsArchived.isNotEmpty ? 1 : 0), (_groupsArchived.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) { 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 >= if (index >=
_groupsNotPinned.length + _groupsNotPinned.length +
_groupsPinned.length + _groupsPinned.length +

View file

@ -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<StoryBubbleItem> buildStoryBubbleItems({
required Iterable<StoryItem> stories,
required String Function(StoryItem story) labelFor,
DateTime? now,
}) {
final currentTime = now ?? clock.now();
final unseenBySender = <int, List<StoryItem>>{};
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<StoryBubbleItem> items;
final ValueChanged<StoryBubbleItem> 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,
),
),
],
),
);
}
}

View file

@ -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<StoryPreview>(find.byType(StoryPreview)).circle,
true,
);
final progress = tester.widget<CircularProgressIndicator>(
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());
});
});
}