mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
show storry bubble
This commit is contained in:
parent
854121c8c4
commit
7b5ca07e2f
3 changed files with 366 additions and 0 deletions
|
|
@ -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 +
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
test/visual/components/story_bubble_strip_test.dart
Normal file
144
test/visual/components/story_bubble_strip_test.dart
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue