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: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<ChatListView>
|
|||
StreamSubscription<List<StoryItem>>? _storiesSub;
|
||||
StreamSubscription<List<OwnStoryItem>>? _ownStoriesSub;
|
||||
final StoryExpiryTimer _ownStoryExpiry = StoryExpiryTimer();
|
||||
final StoryExpiryTimer _receivedStoryExpiry = StoryExpiryTimer();
|
||||
Timer? _typingUpdateTimer;
|
||||
final Set<String> _precachedMediaIds = {};
|
||||
List<Group> _groupsNotPinned = [];
|
||||
|
|
@ -247,6 +250,7 @@ class _ChatListViewState extends State<ChatListView>
|
|||
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<ChatListView>
|
|||
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.
|
||||
void _scheduleOwnStoryExpiry() {
|
||||
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
|
||||
/// group itself otherwise.
|
||||
List<ContactGroup> _contactGroupsFor(Group group) {
|
||||
|
|
@ -313,6 +367,7 @@ class _ChatListViewState extends State<ChatListView>
|
|||
_storiesSub?.cancel();
|
||||
_ownStoriesSub?.cancel();
|
||||
_ownStoryExpiry.cancel();
|
||||
_receivedStoryExpiry.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -348,6 +403,7 @@ class _ChatListViewState extends State<ChatListView>
|
|||
final plan = context.select<PurchasesProvider, SubscriptionPlan>(
|
||||
(p) => p.plan,
|
||||
);
|
||||
final storyBubbles = _storyBubbles;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
|
|
@ -468,11 +524,23 @@ class _ChatListViewState extends State<ChatListView>
|
|||
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 +
|
||||
|
|
|
|||
|
|
@ -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