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: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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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