fixes some smaller ui issues

This commit is contained in:
otsmr 2026-09-27 21:47:11 +02:00
parent 5ba1a9db88
commit 75df8840ae
4 changed files with 235 additions and 82 deletions

View file

@ -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<StoryStripItem> items;
final ValueChanged<StoryStripItem> 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),
],
);
}
}

View file

@ -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<ChatMessagesView>
}
Future<void> 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;
}
}

View file

@ -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<OwnStoryStrip> {
);
}
Future<void> _showViewers(OwnStoryItem item) async {
Future<void> _showViewers(MediaFile mediaFile) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => StoryViewersBottomSheet(mediaFile: item.mediaFile),
builder: (context) => StoryViewersBottomSheet(mediaFile: mediaFile),
);
}
@ -77,72 +76,17 @@ class _OwnStoryStripState extends State<OwnStoryStrip> {
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),
);
}
}

View file

@ -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<List<StoryStripItem>> 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<void> pump(
WidgetTester tester,
List<StoryStripItem> items, {
ValueChanged<StoryStripItem>? 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<void> 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);
});
}