From 75df8840aef0357f2fba0802ca03b91fad6f0525 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 27 Sep 2026 21:47:11 +0200 Subject: [PATCH] fixes some smaller ui issues --- .../visual/components/story_strip.comp.dart | 116 ++++++++++++++++++ .../views/chats/chat_messages.view.dart | 19 +-- .../profile/own_story_strip.comp.dart | 82 ++----------- test/visual/components/story_strip_test.dart | 100 +++++++++++++++ 4 files changed, 235 insertions(+), 82 deletions(-) create mode 100644 lib/src/visual/components/story_strip.comp.dart create mode 100644 test/visual/components/story_strip_test.dart diff --git a/lib/src/visual/components/story_strip.comp.dart b/lib/src/visual/components/story_strip.comp.dart new file mode 100644 index 00000000..edd71d3d --- /dev/null +++ b/lib/src/visual/components/story_strip.comp.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/story_preview.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_list_components/last_message_time.comp.dart'; + +/// One item in a [StoryStrip]. +class StoryStripItem { + const StoryStripItem({ + required this.mediaFile, + required this.postedAt, + required this.viewerCount, + }); + + final MediaFile mediaFile; + final DateTime postedAt; + final int viewerCount; +} + +/// The user's story as a row of previews, newest first, each with how long +/// ago it was posted and how many have viewed it. Nothing is shown while +/// there are no [items]. +class StoryStrip extends StatelessWidget { + const StoryStrip({ + required this.title, + required this.items, + required this.onTap, + super.key, + }); + + final String title; + + /// Newest first. + final List items; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) return const SizedBox.shrink(); + final secondary = TextStyle( + fontSize: 12, + color: context.color.onSurface.withAlpha(170), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 10), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ), + SizedBox( + height: 150, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final item = items[index]; + return GestureDetector( + key: ValueKey(item.mediaFile.mediaId), + onTap: () => onTap(item), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StoryPreview( + mediaFile: item.mediaFile, + width: 72, + height: 110, + ), + const SizedBox(height: 6), + SizedBox( + width: 72, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Gives way to the view count on a long label. + Flexible( + child: DefaultTextStyle.merge( + style: secondary, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + child: LastMessageTimeComp( + dateTime: item.postedAt, + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 4), + FaIcon( + FontAwesomeIcons.eye, + size: 11, + color: secondary.color, + ), + const SizedBox(width: 4), + Text('${item.viewerCount}', style: secondary), + ], + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + const SizedBox(height: 10), + ], + ); + } +} diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 091ddfb8..0c059ffd 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:collection'; -import 'package:clock/clock.dart'; import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -11,7 +10,6 @@ import 'package:twonly/globals.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/stories.dao.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/memory_item.model.dart'; @@ -565,22 +563,17 @@ class _ChatMessagesViewState extends State } Future scrollToMessage(String messageId) async { - // A quoted story is not in the chat to scroll to; it opens the story at - // that item instead, for as long as the story is still there. + // A quoted story is not in the chat to scroll to. The user's own story + // leads to their profile, which lists it with its viewers; a contact's + // story leads nowhere. if (!_data.messagesById.containsKey(messageId)) { final quoted = await twonlyDB.messagesDao .getMessageById(messageId) .getSingleOrNull(); if (quoted != null && quoted.isStory && !quoted.mediaStored) { - final alive = - quoted.mediaId != null && - clock.now().isBefore(quoted.createdAt.add(storyLifetime)); - if (!alive || !mounted) return; - final senderId = quoted.senderId; - await context.push( - senderId == null ? Routes.chatsOwnStory : Routes.chatsStory(senderId), - extra: quoted.mediaId, - ); + if (quoted.senderId == null && mounted) { + await context.push(Routes.settingsProfile); + } return; } } diff --git a/lib/src/visual/views/settings/profile/own_story_strip.comp.dart b/lib/src/visual/views/settings/profile/own_story_strip.comp.dart index 73c10a69..84b9e5c1 100644 --- a/lib/src/visual/views/settings/profile/own_story_strip.comp.dart +++ b/lib/src/visual/views/settings/profile/own_story_strip.comp.dart @@ -2,12 +2,11 @@ import 'dart:async'; import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/stories.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/components/story_preview.comp.dart'; -import 'package:twonly/src/visual/views/chats/chat_list_components/last_message_time.comp.dart'; +import 'package:twonly/src/visual/components/story_strip.comp.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_viewers.bottom_sheet.dart'; @@ -65,11 +64,11 @@ class _OwnStoryStripState extends State { ); } - Future _showViewers(OwnStoryItem item) async { + Future _showViewers(MediaFile mediaFile) async { await showModalBottomSheet( context: context, backgroundColor: Colors.transparent, - builder: (context) => StoryViewersBottomSheet(mediaFile: item.mediaFile), + builder: (context) => StoryViewersBottomSheet(mediaFile: mediaFile), ); } @@ -77,72 +76,17 @@ class _OwnStoryStripState extends State { Widget build(BuildContext context) { // Newest first: the item posted last is the one people see next to the // user's name. - final items = _active.reversed.toList(); - if (items.isEmpty) return const SizedBox.shrink(); - final secondary = TextStyle( - fontSize: 12, - color: context.color.onSurface.withAlpha(170), - ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 10), - child: Text( - context.lang.storyMine, - style: Theme.of(context).textTheme.titleMedium, + return StoryStrip( + title: context.lang.storyMine, + items: [ + for (final item in _active.reversed) + StoryStripItem( + mediaFile: item.mediaFile, + postedAt: item.postedAt, + viewerCount: item.viewerCount, ), - ), - SizedBox( - height: 150, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: items.length, - separatorBuilder: (_, _) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final item = items[index]; - return GestureDetector( - key: ValueKey(item.mediaFile.mediaId), - onTap: () => _showViewers(item), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - StoryPreview( - mediaFile: item.mediaFile, - width: 72, - height: 110, - ), - const SizedBox(height: 6), - SizedBox( - width: 72, - child: Row( - children: [ - DefaultTextStyle.merge( - style: secondary, - child: LastMessageTimeComp( - dateTime: item.postedAt, - ), - ), - const Spacer(), - FaIcon( - FontAwesomeIcons.eye, - size: 11, - color: secondary.color, - ), - const SizedBox(width: 4), - Text('${item.viewerCount}', style: secondary), - ], - ), - ), - ], - ), - ); - }, - ), - ), - const SizedBox(height: 10), ], + onTap: (item) => _showViewers(item.mediaFile), ); } } diff --git a/test/visual/components/story_strip_test.dart b/test/visual/components/story_strip_test.dart new file mode 100644 index 00000000..a6e70ab8 --- /dev/null +++ b/test/visual/components/story_strip_test.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +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/twonly.db.dart'; +import 'package:twonly/src/localization/generated/app_localizations.dart'; +import 'package:twonly/src/visual/components/story_preview.comp.dart'; +import 'package:twonly/src/visual/components/story_strip.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_list_components/last_message_time.comp.dart'; + +void main() { + late TwonlyDB database; + late Directory support; + + setUp(() async { + support = Directory.systemTemp.createTempSync('story_strip_test'); + AppEnvironment.initTesting(customSupportDir: support.path); + database = TwonlyDB(NativeDatabase.memory()); + await database.customStatement( + "INSERT INTO media_files(media_id, type) VALUES ('new', 'image'), ('old', 'video')", + ); + }); + + tearDown(() async { + await database.close(); + support.deleteSync(recursive: true); + }); + + Future> items(WidgetTester tester) async { + final files = await tester.runAsync( + () async => [ + (await database.mediaFilesDao.getMediaFileById('new'))!, + (await database.mediaFilesDao.getMediaFileById('old'))!, + ], + ); + final now = DateTime.now(); + return [ + StoryStripItem( + mediaFile: files![0], + postedAt: now.subtract(const Duration(minutes: 5)), + viewerCount: 3, + ), + StoryStripItem( + mediaFile: files[1], + postedAt: now.subtract(const Duration(hours: 3)), + viewerCount: 3, + ), + ]; + } + + Future pump( + WidgetTester tester, + List items, { + ValueChanged? onTap, + }) => tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StoryStrip( + title: 'Story', + items: items, + onTap: onTap ?? (_) {}, + ), + ), + ), + ); + + // The time labels tick; taking the strip down stops them. + Future unmount(WidgetTester tester) => + tester.pumpWidget(const SizedBox()); + + testWidgets('without a story the strip takes no room', (tester) async { + await pump(tester, const []); + expect(find.text('Story'), findsNothing); + expect(find.byType(StoryPreview), findsNothing); + }); + + testWidgets('items show how many viewed them', (tester) async { + await pump(tester, await items(tester)); + expect(find.text('3'), findsNWidgets(2)); + // The time takes all the width the count leaves, not a share of it. + final label = tester.getSize(find.byType(LastMessageTimeComp).first); + final count = tester.getSize( + find.ancestor(of: find.text('3').first, matching: find.byType(Row)).first, + ); + expect(label.width + count.width, closeTo(72, 0.5)); + await unmount(tester); + }); + + testWidgets('tapping an item hands it back', (tester) async { + StoryStripItem? tapped; + await pump(tester, await items(tester), onTap: (item) => tapped = item); + await tester.tap(find.byType(StoryPreview).last); + expect(tapped?.mediaFile.mediaId, 'old'); + await unmount(tester); + }); +}