mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
fixes some smaller ui issues
This commit is contained in:
parent
5ba1a9db88
commit
75df8840ae
4 changed files with 235 additions and 82 deletions
116
lib/src/visual/components/story_strip.comp.dart
Normal file
116
lib/src/visual/components/story_strip.comp.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
|
||||||
import 'package:flutter/foundation.dart' show setEquals;
|
import 'package:flutter/foundation.dart' show setEquals;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.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/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/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/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/model/memory_item.model.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 {
|
Future<void> scrollToMessage(String messageId) async {
|
||||||
// A quoted story is not in the chat to scroll to; it opens the story at
|
// A quoted story is not in the chat to scroll to. The user's own story
|
||||||
// that item instead, for as long as the story is still there.
|
// leads to their profile, which lists it with its viewers; a contact's
|
||||||
|
// story leads nowhere.
|
||||||
if (!_data.messagesById.containsKey(messageId)) {
|
if (!_data.messagesById.containsKey(messageId)) {
|
||||||
final quoted = await twonlyDB.messagesDao
|
final quoted = await twonlyDB.messagesDao
|
||||||
.getMessageById(messageId)
|
.getMessageById(messageId)
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (quoted != null && quoted.isStory && !quoted.mediaStored) {
|
if (quoted != null && quoted.isStory && !quoted.mediaStored) {
|
||||||
final alive =
|
if (quoted.senderId == null && mounted) {
|
||||||
quoted.mediaId != null &&
|
await context.push(Routes.settingsProfile);
|
||||||
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,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.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/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.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';
|
|
||||||
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/chats/media_viewer_components/story_viewers.bottom_sheet.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>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
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) {
|
Widget build(BuildContext context) {
|
||||||
// Newest first: the item posted last is the one people see next to the
|
// Newest first: the item posted last is the one people see next to the
|
||||||
// user's name.
|
// user's name.
|
||||||
final items = _active.reversed.toList();
|
return StoryStrip(
|
||||||
if (items.isEmpty) return const SizedBox.shrink();
|
title: context.lang.storyMine,
|
||||||
final secondary = TextStyle(
|
items: [
|
||||||
fontSize: 12,
|
for (final item in _active.reversed)
|
||||||
color: context.color.onSurface.withAlpha(170),
|
StoryStripItem(
|
||||||
);
|
mediaFile: item.mediaFile,
|
||||||
return Column(
|
postedAt: item.postedAt,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
viewerCount: item.viewerCount,
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 10),
|
|
||||||
child: Text(
|
|
||||||
context.lang.storyMine,
|
|
||||||
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: () => _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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
test/visual/components/story_strip_test.dart
Normal file
100
test/visual/components/story_strip_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue