mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish mobile message threads and composer (#5645)
## Summary - refine mobile message metadata, search spacing, and Activity filter semantics - add channel-parity Latest navigation and stable tail following to threads - synchronize Android composer/keyboard geometry and keep Latest spacing stable across IME transitions ## Validation - `bin/just mobile-check` - `bin/just mobile-test` (1,276 tests) - Pixel 10 install/launch and channel/thread keyboard, Latest, tail, and back-navigation review - signed iPhone install/launch workflow ## Snapshots See the review snapshots below. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
Fast Fizz
Princess Donut
parent
82f7ed1532
commit
69107dc3bf
@@ -113,7 +113,6 @@ class ActivityPage extends HookConsumerWidget {
|
||||
final readState = ref.watch(readStateProvider);
|
||||
final localState = ref.watch(inboxLocalStateProvider);
|
||||
final drafts = ref.watch(composeDraftsProvider);
|
||||
final dueReminderCount = ref.watch(dueReminderCountProvider);
|
||||
final allItems = ref.watch(inboxItemsProvider);
|
||||
final myPk = ref.watch(myPubkeyProvider);
|
||||
|
||||
@@ -380,8 +379,6 @@ class ActivityPage extends HookConsumerWidget {
|
||||
actions: [
|
||||
_ActivityActionsPill(
|
||||
filter: filter.value,
|
||||
dueReminderCount: dueReminderCount,
|
||||
draftCount: drafts.length,
|
||||
unreadOnly: unreadOnly.value,
|
||||
unreadCount: unreadVisibleCount,
|
||||
onFilterChanged: (f) => filter.value = f,
|
||||
|
||||
@@ -13,8 +13,6 @@ const _filterLabels = <InboxFilter, String>{
|
||||
|
||||
class _ActivityActionsPill extends StatelessWidget {
|
||||
final InboxFilter filter;
|
||||
final int dueReminderCount;
|
||||
final int draftCount;
|
||||
final bool unreadOnly;
|
||||
final int unreadCount;
|
||||
final ValueChanged<InboxFilter> onFilterChanged;
|
||||
@@ -23,8 +21,6 @@ class _ActivityActionsPill extends StatelessWidget {
|
||||
|
||||
const _ActivityActionsPill({
|
||||
required this.filter,
|
||||
required this.dueReminderCount,
|
||||
required this.draftCount,
|
||||
required this.unreadOnly,
|
||||
required this.unreadCount,
|
||||
required this.onFilterChanged,
|
||||
@@ -45,12 +41,7 @@ class _ActivityActionsPill extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FilterMenuButton(
|
||||
filter: filter,
|
||||
dueReminderCount: dueReminderCount,
|
||||
draftCount: draftCount,
|
||||
onChanged: onFilterChanged,
|
||||
),
|
||||
_FilterMenuButton(filter: filter, onChanged: onFilterChanged),
|
||||
_InboxOptionsButton(
|
||||
unreadOnly: unreadOnly,
|
||||
unreadCount: unreadCount,
|
||||
@@ -68,16 +59,9 @@ class _ActivityActionsPill extends StatelessWidget {
|
||||
/// inbox filter menu (`FILTER_OPTIONS`).
|
||||
class _FilterMenuButton extends StatelessWidget {
|
||||
final InboxFilter filter;
|
||||
final int dueReminderCount;
|
||||
final int draftCount;
|
||||
final ValueChanged<InboxFilter> onChanged;
|
||||
|
||||
const _FilterMenuButton({
|
||||
required this.filter,
|
||||
required this.dueReminderCount,
|
||||
required this.draftCount,
|
||||
required this.onChanged,
|
||||
});
|
||||
const _FilterMenuButton({required this.filter, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -121,12 +105,6 @@ class _FilterMenuButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (entry.key == InboxFilter.reminders &&
|
||||
dueReminderCount > 0)
|
||||
_CountBadge(count: dueReminderCount)
|
||||
else if (entry.key == InboxFilter.drafts &&
|
||||
draftCount > 0)
|
||||
_CountBadge(count: draftCount),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -154,17 +132,6 @@ class _FilterMenuButton extends StatelessWidget {
|
||||
size: 16,
|
||||
color: navigationPrimaryForeground(context),
|
||||
),
|
||||
if (dueReminderCount > 0 || draftCount > 0) ...[
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -174,33 +141,6 @@ class _FilterMenuButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _CountBadge extends StatelessWidget {
|
||||
final int count;
|
||||
|
||||
const _CountBadge({required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half + Grid.quarter,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: navigationPrimaryForeground(context),
|
||||
borderRadius: BorderRadius.circular(Grid.xxs),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overflow menu with the unread-only toggle and mark-all-read, mirroring
|
||||
/// desktop's inbox options popover.
|
||||
class _InboxOptionsButton extends StatelessWidget {
|
||||
|
||||
@@ -244,7 +244,7 @@ class _InboxRow extends HookConsumerWidget {
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor: mutedColor,
|
||||
nameStyle: activityUsernameTextStyle,
|
||||
metadataStyle:
|
||||
timestampStyle:
|
||||
activityTimestampTextStyle,
|
||||
displayNameKey: ValueKey(
|
||||
'activity-author-${item.id}',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Android keeps the message viewport fixed while the IME animates. Only this
|
||||
/// small wrapper follows the frame-by-frame inset; timelines apply the final
|
||||
/// inset once metrics settle.
|
||||
class AndroidImeLift extends StatelessWidget {
|
||||
/// The composer subtree that follows the animated Android IME inset.
|
||||
final Widget child;
|
||||
|
||||
/// Creates a wrapper that lifts [child] without resizing its surrounding
|
||||
/// message viewport on Android.
|
||||
const AndroidImeLift({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!usesFixedAndroidImeViewport) return child;
|
||||
final imeBottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final systemBottom = MediaQuery.viewPaddingOf(context).bottom;
|
||||
return Padding(
|
||||
// The composer already reserves [systemBottom]. Android's IME inset
|
||||
// includes that navigation area, so lifting by the full value leaves a
|
||||
// second safe-area gap above the keyboard.
|
||||
padding: EdgeInsets.only(bottom: max(0, imeBottom - systemBottom)),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether channel and thread scaffolds should keep a fixed viewport while
|
||||
/// Android IME insets animate, with their composer lifted independently.
|
||||
bool get usesFixedAndroidImeViewport =>
|
||||
defaultTargetPlatform == TargetPlatform.android;
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' show min;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
@@ -27,6 +26,7 @@ import '../profile/profile_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../forum/forum_posts_view.dart';
|
||||
import 'android_ime_lift.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_actions_sheet.dart';
|
||||
import 'channel_link_navigation.dart';
|
||||
@@ -44,6 +44,8 @@ import 'date_formatters.dart';
|
||||
import 'day_divider.dart';
|
||||
import 'dm_channel_labels.dart';
|
||||
import 'ephemeral_channel_display.dart';
|
||||
import 'ime_metrics_settle_observer.dart';
|
||||
import 'latest_message_button.dart';
|
||||
import 'members_sheet.dart';
|
||||
import 'message_actions.dart';
|
||||
import 'message_long_press_region.dart';
|
||||
@@ -298,6 +300,8 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
}, [channel.id, readState.isReady, readTimestamp]);
|
||||
|
||||
return FrostedScaffold(
|
||||
resizeToAvoidBottomInset:
|
||||
!usesFixedAndroidImeViewport || resolvedChannel.isForum,
|
||||
appBar: FrostedAppBar(
|
||||
iconColor: context.colors.primary,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
@@ -492,46 +496,48 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
if (showsComposer)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ComposerDockSizeReporter(
|
||||
key: const ValueKey('channel-composer-dock'),
|
||||
onHeightChanged: (height) {
|
||||
if ((composerDockHeight.value - height).abs() < 0.5) return;
|
||||
composerDockHeight.value = height;
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: typingEntries.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: typingEntries),
|
||||
),
|
||||
ComposeBar(
|
||||
channelId: channel.id,
|
||||
channelName: resolvedChannel.isDm
|
||||
? ''
|
||||
: resolvedChannel.name,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => sendMessage.call(
|
||||
channelId: channel.id,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
channel: resolvedChannel,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
),
|
||||
],
|
||||
AndroidImeLift(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ComposerDockSizeReporter(
|
||||
key: const ValueKey('channel-composer-dock'),
|
||||
onHeightChanged: (height) {
|
||||
if ((composerDockHeight.value - height).abs() < 0.5) return;
|
||||
composerDockHeight.value = height;
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: typingEntries.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: typingEntries),
|
||||
),
|
||||
ComposeBar(
|
||||
channelId: channel.id,
|
||||
channelName: resolvedChannel.isDm
|
||||
? ''
|
||||
: resolvedChannel.name,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => sendMessage.call(
|
||||
channelId: channel.id,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
channel: resolvedChannel,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -35,16 +35,23 @@ class _MessageList extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appView = View.of(context);
|
||||
final displayEntries = groupMembershipTimelineEntries(entries);
|
||||
final itemScrollController = useMemoized(ItemScrollController.new);
|
||||
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
|
||||
final isLoadingOlder = useState(false);
|
||||
final isAtLatest = useState(true);
|
||||
final settledImeBottomInset = useState(
|
||||
usesFixedAndroidImeViewport
|
||||
? appView.viewInsets.bottom / appView.devicePixelRatio
|
||||
: 0.0,
|
||||
);
|
||||
final hasUserScrolled = useState(false);
|
||||
final followsLatest = useRef(
|
||||
final followsLatest = useState(
|
||||
initialMessageId == null && initialThreadRootId == null,
|
||||
);
|
||||
final isAutoScrolling = useRef(false);
|
||||
final latestNavigationRequest = useState(0);
|
||||
final latestRealignmentQueued = useRef(false);
|
||||
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
|
||||
final previousLatestEntryId = useRef<String?>(null);
|
||||
@@ -58,6 +65,15 @@ class _MessageList extends HookConsumerWidget {
|
||||
final hasUnreadDeepLink =
|
||||
initialMessageId != null || initialThreadRootId != null;
|
||||
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
|
||||
final settledImeLift = usesFixedAndroidImeViewport
|
||||
? (settledImeBottomInset.value -
|
||||
MediaQuery.viewPaddingOf(context).bottom)
|
||||
.clamp(0.0, double.infinity)
|
||||
.toDouble()
|
||||
: settledImeBottomInset.value;
|
||||
final timelineBottomInset =
|
||||
composerBottomInset + (followsLatest.value ? settledImeLift : 0);
|
||||
final navigationBottomInset = composerBottomInset + settledImeLift;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -158,15 +174,15 @@ class _MessageList extends HookConsumerWidget {
|
||||
double latestAlignment() {
|
||||
final viewportHeight = context.size?.height ?? 0;
|
||||
return viewportHeight > 0
|
||||
? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
|
||||
? (timelineBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
Future<void> scrollToLatest() async {
|
||||
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
|
||||
followsLatest.value = true;
|
||||
hasUserScrolled.value = false;
|
||||
isAutoScrolling.value = true;
|
||||
Future<void> performLatestNavigation() async {
|
||||
if (!context.mounted || !itemScrollController.isAttached) {
|
||||
isAutoScrolling.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await itemScrollController.scrollTo(
|
||||
index: 0,
|
||||
@@ -182,6 +198,24 @@ class _MessageList extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void scrollToLatest() {
|
||||
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
|
||||
isAutoScrolling.value = true;
|
||||
followsLatest.value = true;
|
||||
hasUserScrolled.value = false;
|
||||
latestNavigationRequest.value += 1;
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
if (latestNavigationRequest.value == 0) return null;
|
||||
var cancelled = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (cancelled) return;
|
||||
unawaited(performLatestNavigation());
|
||||
});
|
||||
return () => cancelled = true;
|
||||
}, [latestNavigationRequest.value]);
|
||||
|
||||
Future<void> scrollToOldestUnread() async {
|
||||
final targetIndex = reversedIndexOf(oldestUnreadMessageId.value);
|
||||
if (targetIndex == null ||
|
||||
@@ -221,6 +255,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
|
||||
void realignLatestAfterLayoutChange() {
|
||||
if (latestRealignmentQueued.value ||
|
||||
isAutoScrolling.value ||
|
||||
!followsLatest.value ||
|
||||
hasUserScrolled.value) {
|
||||
return;
|
||||
@@ -230,6 +265,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
latestRealignmentQueued.value = false;
|
||||
if (!context.mounted ||
|
||||
!itemScrollController.isAttached ||
|
||||
isAutoScrolling.value ||
|
||||
!followsLatest.value ||
|
||||
hasUserScrolled.value ||
|
||||
latestIsAtBoundary()) {
|
||||
@@ -237,7 +273,9 @@ class _MessageList extends HookConsumerWidget {
|
||||
}
|
||||
// A dock or keyboard resize is a layout correction, not a navigation
|
||||
// action. Keeping it instant avoids restarting a smooth scroll for
|
||||
// every position report while the viewport settles.
|
||||
// every position report while the viewport settles. The rebuilt list
|
||||
// padding already owns the composer/IME offset; the default alignment
|
||||
// also keeps short timelines flush with that padding.
|
||||
itemScrollController.jumpTo(index: 0);
|
||||
});
|
||||
}
|
||||
@@ -284,15 +322,28 @@ class _MessageList extends HookConsumerWidget {
|
||||
useEffect(() {
|
||||
realignLatestAfterLayoutChange();
|
||||
return null;
|
||||
}, [composerBottomInset]);
|
||||
}, [timelineBottomInset]);
|
||||
|
||||
useEffect(() {
|
||||
final observer = _ChannelLatestMetricsObserver(
|
||||
onMetricsChanged: realignLatestAfterLayoutChange,
|
||||
final observer = ImeMetricsSettleObserver(
|
||||
onMetricsSettled: () {
|
||||
if (!usesFixedAndroidImeViewport) {
|
||||
realignLatestAfterLayoutChange();
|
||||
return;
|
||||
}
|
||||
final nextInset =
|
||||
appView.viewInsets.bottom / appView.devicePixelRatio;
|
||||
if ((settledImeBottomInset.value - nextInset).abs() >= 0.5) {
|
||||
settledImeBottomInset.value = nextInset;
|
||||
}
|
||||
},
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(observer);
|
||||
return () => WidgetsBinding.instance.removeObserver(observer);
|
||||
}, [itemScrollController]);
|
||||
return () {
|
||||
WidgetsBinding.instance.removeObserver(observer);
|
||||
observer.dispose();
|
||||
};
|
||||
}, [appView, itemScrollController]);
|
||||
|
||||
useEffect(() {
|
||||
if (initialThreadRootId == null || didOpenInitialThread.value) {
|
||||
@@ -428,7 +479,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
),
|
||||
bottom: composerBottomInset,
|
||||
bottom: timelineBottomInset,
|
||||
),
|
||||
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -548,10 +599,11 @@ class _MessageList extends HookConsumerWidget {
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: composerBottomInset + Grid.xs,
|
||||
bottom: navigationBottomInset + Grid.xs,
|
||||
child: Center(
|
||||
child: _JumpToLatestButton(
|
||||
child: LatestMessageButton(
|
||||
key: const ValueKey('channel-jump-to-latest'),
|
||||
surfaceKey: const ValueKey('channel-jump-to-latest-surface'),
|
||||
onPressed: scrollToLatest,
|
||||
),
|
||||
),
|
||||
@@ -560,73 +612,3 @@ class _MessageList extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JumpToLatestButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _JumpToLatestButton({required this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(Radii.full);
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
key: const ValueKey('channel-jump-to-latest-surface'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface.withValues(alpha: 0.5),
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: borderRadius,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowDown,
|
||||
size: 16,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'Latest',
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
|
||||
final VoidCallback onMetricsChanged;
|
||||
|
||||
_ChannelLatestMetricsObserver({required this.onMetricsChanged});
|
||||
|
||||
@override
|
||||
void didChangeMetrics() => onMetricsChanged();
|
||||
}
|
||||
|
||||
@@ -58,3 +58,11 @@ part 'compose_bar/send_button.dart';
|
||||
part 'compose_bar/layout.dart';
|
||||
part 'compose_bar/dock.dart';
|
||||
part 'compose_bar/compose_bar_widget.dart';
|
||||
|
||||
/// Callback used by channels and threads to submit composer content.
|
||||
typedef ComposeBarOnSend =
|
||||
Future<void> Function(
|
||||
String content,
|
||||
List<String> mentionPubkeys, {
|
||||
List<List<String>> mediaTags,
|
||||
});
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
part of '../compose_bar.dart';
|
||||
|
||||
/// Rich compose bar with @mention autocomplete and a markdown formatting
|
||||
/// toolbar. Used in both channel and thread views — the caller provides an
|
||||
/// [onSend] callback that handles actual message submission.
|
||||
typedef ComposeBarOnSend =
|
||||
Future<void> Function(
|
||||
String content,
|
||||
List<String> mentionPubkeys, {
|
||||
List<List<String>> mediaTags,
|
||||
});
|
||||
|
||||
class ComposeBar extends HookConsumerWidget {
|
||||
final String channelId;
|
||||
final String channelName;
|
||||
final String? hintText;
|
||||
final ComposeBarOnSend onSend;
|
||||
|
||||
/// Runs immediately before the editor requests focus, allowing a parent to
|
||||
/// prepare focus-dependent layout (for example, following a thread tail).
|
||||
final VoidCallback? onFocusRequested;
|
||||
|
||||
/// Optional thread IDs for thread-scoped typing indicators.
|
||||
final String? threadHeadId;
|
||||
final String? rootId;
|
||||
@@ -26,6 +20,7 @@ class ComposeBar extends HookConsumerWidget {
|
||||
this.hintText,
|
||||
this.threadHeadId,
|
||||
this.rootId,
|
||||
this.onFocusRequested,
|
||||
required this.onSend,
|
||||
});
|
||||
@override
|
||||
@@ -50,13 +45,22 @@ class ComposeBar extends HookConsumerWidget {
|
||||
final draftIdentity =
|
||||
'${ref.watch(relayConfigProvider).baseUrl}'
|
||||
':${ref.watch(myPubkeyProvider) ?? 'anon'}';
|
||||
final isComposerExpanded = useState(false);
|
||||
final androidImeTransitionStarted = useState(
|
||||
defaultTargetPlatform != TargetPlatform.android,
|
||||
);
|
||||
final androidImeFallbackTimer = useRef<Timer?>(null);
|
||||
final focusNode = useFocusNode();
|
||||
useEffect(
|
||||
() =>
|
||||
() => androidImeFallbackTimer.value?.cancel(),
|
||||
[androidImeFallbackTimer],
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
() => _dismissComposerKeyboard(focusNode),
|
||||
[focusNode],
|
||||
);
|
||||
final isComposerExpanded = useState(false);
|
||||
final isEmojiPickerOpen = useState(false);
|
||||
final attachmentSurface = useState(_AttachmentSurface.closed);
|
||||
final iosAttachmentPopover = useMemoized(
|
||||
@@ -101,10 +105,6 @@ class ComposeBar extends HookConsumerWidget {
|
||||
initialValue: 0,
|
||||
upperBound: 1.05,
|
||||
);
|
||||
final composerExpansionValue = useAnimation(composerExpansionController);
|
||||
final composerExpansionProgress = composerExpansionValue
|
||||
.clamp(0.0, 1.0)
|
||||
.toDouble();
|
||||
|
||||
void collapseComposer() {
|
||||
if (!isComposerExpanded.value) return;
|
||||
@@ -127,7 +127,15 @@ class ComposeBar extends HookConsumerWidget {
|
||||
useEffect(() {
|
||||
final observer = _ComposerKeyboardMetricsObserver(
|
||||
view: appView,
|
||||
onKeyboardShown: () {
|
||||
androidImeFallbackTimer.value?.cancel();
|
||||
androidImeTransitionStarted.value = true;
|
||||
},
|
||||
onKeyboardHidden: () {
|
||||
androidImeFallbackTimer.value?.cancel();
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
androidImeTransitionStarted.value = false;
|
||||
}
|
||||
collapseComposer();
|
||||
focusNode.unfocus();
|
||||
},
|
||||
@@ -138,26 +146,36 @@ class ComposeBar extends HookConsumerWidget {
|
||||
final resolvedHint =
|
||||
hintText ??
|
||||
(channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026');
|
||||
useEffect(() {
|
||||
final target = isComposerExpanded.value ? 1.0 : 0.0;
|
||||
if (reducedMotion) {
|
||||
composerExpansionController.value = target;
|
||||
} else if ((composerExpansionController.value - target).abs() > 0.001) {
|
||||
composerExpansionController.animateWith(
|
||||
SpringSimulation(
|
||||
SpringDescription.withDurationAndBounce(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
bounce: 0.08,
|
||||
useEffect(
|
||||
() {
|
||||
final target =
|
||||
isComposerExpanded.value && androidImeTransitionStarted.value
|
||||
? 1.0
|
||||
: 0.0;
|
||||
if (reducedMotion) {
|
||||
composerExpansionController.value = target;
|
||||
} else if ((composerExpansionController.value - target).abs() > 0.001) {
|
||||
composerExpansionController.animateWith(
|
||||
SpringSimulation(
|
||||
SpringDescription.withDurationAndBounce(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
bounce: 0.08,
|
||||
),
|
||||
composerExpansionController.value,
|
||||
target,
|
||||
0,
|
||||
snapToEnd: true,
|
||||
),
|
||||
composerExpansionController.value,
|
||||
target,
|
||||
0,
|
||||
snapToEnd: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [isComposerExpanded.value, reducedMotion]);
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[
|
||||
isComposerExpanded.value,
|
||||
androidImeTransitionStarted.value,
|
||||
reducedMotion,
|
||||
],
|
||||
);
|
||||
useEffect(() {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
|
||||
|
||||
@@ -816,15 +834,10 @@ class ComposeBar extends HookConsumerWidget {
|
||||
attachmentSurface.value = _AttachmentSurface.camera;
|
||||
}
|
||||
|
||||
final motionDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: Duration(
|
||||
milliseconds:
|
||||
attachmentSurface.value == _AttachmentSurface.camera ||
|
||||
attachmentSurface.value == _AttachmentSurface.photos
|
||||
? 320
|
||||
: 250,
|
||||
);
|
||||
final motionDuration = _composerMotionDuration(
|
||||
reducedMotion,
|
||||
attachmentSurface.value,
|
||||
);
|
||||
final resizeDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 140);
|
||||
@@ -839,42 +852,28 @@ class ComposeBar extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [suggestionOverlayController]);
|
||||
|
||||
void expandComposer() {
|
||||
if (isComposerExpanded.value) return;
|
||||
attachmentSurface.value = _AttachmentSurface.closed;
|
||||
isComposerExpanded.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
void expandComposer() => _expandComposer(
|
||||
context: context,
|
||||
isExpanded: isComposerExpanded,
|
||||
attachmentSurface: attachmentSurface,
|
||||
onFocusRequested: onFocusRequested,
|
||||
focusNode: focusNode,
|
||||
view: appView,
|
||||
androidImeTransitionStarted: androidImeTransitionStarted,
|
||||
androidImeFallbackTimer: androidImeFallbackTimer,
|
||||
);
|
||||
|
||||
final suggestionPanel = channelSuggestions.isNotEmpty
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('channel-suggestions'),
|
||||
child: _ChannelSuggestions(
|
||||
suggestions: channelSuggestions,
|
||||
onSelect: insertChannel,
|
||||
),
|
||||
)
|
||||
: suggestions.isNotEmpty
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('mention-suggestions'),
|
||||
child: _MentionSuggestions(
|
||||
suggestions: suggestions,
|
||||
userCache: userCache,
|
||||
currentPubkey: currentPubkey,
|
||||
isDmChannel: isDmChannel,
|
||||
onSelect: insertMention,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('no-suggestions'));
|
||||
final suggestionPanel = _composerSuggestionPanel(
|
||||
channelSuggestions: channelSuggestions,
|
||||
mentionSuggestions: suggestions,
|
||||
userCache: userCache,
|
||||
currentPubkey: currentPubkey,
|
||||
isDmChannel: isDmChannel,
|
||||
onChannelSelect: insertChannel,
|
||||
onMentionSelect: insertMention,
|
||||
);
|
||||
Widget buildOverlayPanel(_AttachmentSurface surface) {
|
||||
return _AttachmentSurfacePanel(
|
||||
key: ValueKey(
|
||||
surface == _AttachmentSurface.closed
|
||||
? 'composer-suggestions'
|
||||
: 'attachment-surface',
|
||||
),
|
||||
return _composerAttachmentPanel(
|
||||
surface: surface,
|
||||
suggestionPanel: suggestionPanel,
|
||||
onBack: () => attachmentSurface.value = _AttachmentSurface.menu,
|
||||
@@ -913,12 +912,10 @@ class ComposeBar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Suggestions and attachments live in the overlay so showing them cannot
|
||||
// reflow the composer. Both stay anchored just above the capsule.
|
||||
final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress;
|
||||
// Suggestions and attachments live in the overlay.
|
||||
final hasPendingUploads = uploadingCount.value > 0;
|
||||
return _ComposerDockFrame(
|
||||
widthFactor: composerWidthFactor,
|
||||
expansionAnimation: composerExpansionController,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -955,8 +952,7 @@ class ComposeBar extends HookConsumerWidget {
|
||||
attachmentSurface: attachmentSurface.value,
|
||||
onAttachmentTap: handleAttachmentTap,
|
||||
onExpand: expandComposer,
|
||||
expansionValue: composerExpansionValue,
|
||||
expansionProgress: composerExpansionProgress,
|
||||
expansionAnimation: composerExpansionController,
|
||||
formattingOpen: showFormatting.value,
|
||||
onCloseFormatting: () => showFormatting.value = false,
|
||||
motionDuration: motionDuration,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
part of '../compose_bar.dart';
|
||||
|
||||
class _ComposerDockFrame extends StatelessWidget {
|
||||
final double widthFactor;
|
||||
final Animation<double> expansionAnimation;
|
||||
final Widget child;
|
||||
|
||||
const _ComposerDockFrame({required this.widthFactor, required this.child});
|
||||
const _ComposerDockFrame({
|
||||
required this.expansionAnimation,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -30,10 +33,19 @@ class _ComposerDockFrame extends StatelessWidget {
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: FractionallySizedBox(
|
||||
key: const ValueKey('composer-width-transition'),
|
||||
widthFactor: widthFactor,
|
||||
child: AnimatedBuilder(
|
||||
animation: expansionAnimation,
|
||||
child: child,
|
||||
builder: (context, child) {
|
||||
final progress = expansionAnimation.value
|
||||
.clamp(0.0, 1.0)
|
||||
.toDouble();
|
||||
return FractionallySizedBox(
|
||||
key: const ValueKey('composer-width-transition'),
|
||||
widthFactor: 0.85 + 0.15 * progress,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -21,17 +21,20 @@ const _typingThrottleMs = 3000;
|
||||
|
||||
class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver {
|
||||
final FlutterView view;
|
||||
final VoidCallback onKeyboardShown;
|
||||
final VoidCallback onKeyboardHidden;
|
||||
bool _wasVisible;
|
||||
|
||||
_ComposerKeyboardMetricsObserver({
|
||||
required this.view,
|
||||
required this.onKeyboardShown,
|
||||
required this.onKeyboardHidden,
|
||||
}) : _wasVisible = view.viewInsets.bottom > 0;
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
final isVisible = view.viewInsets.bottom > 0;
|
||||
if (!_wasVisible && isVisible) onKeyboardShown();
|
||||
if (_wasVisible && !isVisible) onKeyboardHidden();
|
||||
_wasVisible = isVisible;
|
||||
}
|
||||
@@ -59,6 +62,112 @@ void _dismissComposerKeyboard(FocusNode focusNode) {
|
||||
unawaited(SystemChannels.textInput.invokeMethod<void>('TextInput.hide'));
|
||||
}
|
||||
|
||||
Duration _composerMotionDuration(
|
||||
bool reducedMotion,
|
||||
_AttachmentSurface surface,
|
||||
) => reducedMotion
|
||||
? Duration.zero
|
||||
: Duration(
|
||||
milliseconds:
|
||||
surface == _AttachmentSurface.camera ||
|
||||
surface == _AttachmentSurface.photos
|
||||
? 320
|
||||
: 250,
|
||||
);
|
||||
|
||||
void _expandComposer({
|
||||
required BuildContext context,
|
||||
required ValueNotifier<bool> isExpanded,
|
||||
required ValueNotifier<_AttachmentSurface> attachmentSurface,
|
||||
required VoidCallback? onFocusRequested,
|
||||
required FocusNode focusNode,
|
||||
required FlutterView view,
|
||||
required ValueNotifier<bool> androidImeTransitionStarted,
|
||||
required ObjectRef<Timer?> androidImeFallbackTimer,
|
||||
}) {
|
||||
if (isExpanded.value) return;
|
||||
attachmentSurface.value = _AttachmentSurface.closed;
|
||||
onFocusRequested?.call();
|
||||
isExpanded.value = true;
|
||||
// Attach the editor before requesting focus so native restoration cannot
|
||||
// reopen a composer behind a popped route.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted && isExpanded.value) focusNode.requestFocus();
|
||||
});
|
||||
if (defaultTargetPlatform != TargetPlatform.android) return;
|
||||
androidImeFallbackTimer.value?.cancel();
|
||||
if (view.viewInsets.bottom > 0) {
|
||||
androidImeTransitionStarted.value = true;
|
||||
return;
|
||||
}
|
||||
androidImeTransitionStarted.value = false;
|
||||
androidImeFallbackTimer.value = Timer(const Duration(milliseconds: 250), () {
|
||||
if (context.mounted && isExpanded.value) {
|
||||
androidImeTransitionStarted.value = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _composerSuggestionPanel({
|
||||
required List<Channel> channelSuggestions,
|
||||
required List<MentionCandidate> mentionSuggestions,
|
||||
required Map<String, UserProfile> userCache,
|
||||
required String? currentPubkey,
|
||||
required bool isDmChannel,
|
||||
required ValueChanged<Channel> onChannelSelect,
|
||||
required ValueChanged<MentionCandidate> onMentionSelect,
|
||||
}) => channelSuggestions.isNotEmpty
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('channel-suggestions'),
|
||||
child: _ChannelSuggestions(
|
||||
suggestions: channelSuggestions,
|
||||
onSelect: onChannelSelect,
|
||||
),
|
||||
)
|
||||
: mentionSuggestions.isNotEmpty
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('mention-suggestions'),
|
||||
child: _MentionSuggestions(
|
||||
suggestions: mentionSuggestions,
|
||||
userCache: userCache,
|
||||
currentPubkey: currentPubkey,
|
||||
isDmChannel: isDmChannel,
|
||||
onSelect: onMentionSelect,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('no-suggestions'));
|
||||
|
||||
Widget _composerAttachmentPanel({
|
||||
required _AttachmentSurface surface,
|
||||
required Widget suggestionPanel,
|
||||
required VoidCallback onBack,
|
||||
required VoidCallback onCamera,
|
||||
required VoidCallback onPhotos,
|
||||
required VoidCallback onVideo,
|
||||
required VoidCallback onFiles,
|
||||
required Future<void> Function(XFile image) onCapture,
|
||||
required Future<List<XFile>> Function() onPickAllPhotos,
|
||||
required Future<void> Function(List<XFile> photos) onChoosePhotos,
|
||||
required Future<void> Function(List<XFile> photos) onChooseAllPhotos,
|
||||
}) => _AttachmentSurfacePanel(
|
||||
key: ValueKey(
|
||||
surface == _AttachmentSurface.closed
|
||||
? 'composer-suggestions'
|
||||
: 'attachment-surface',
|
||||
),
|
||||
surface: surface,
|
||||
suggestionPanel: suggestionPanel,
|
||||
onBack: onBack,
|
||||
onCamera: onCamera,
|
||||
onPhotos: onPhotos,
|
||||
onVideo: onVideo,
|
||||
onFiles: onFiles,
|
||||
onCapture: onCapture,
|
||||
onPickAllPhotos: onPickAllPhotos,
|
||||
onChoosePhotos: onChoosePhotos,
|
||||
onChooseAllPhotos: onChooseAllPhotos,
|
||||
);
|
||||
|
||||
const _pastedImageMimeTypes = <String>[
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
|
||||
@@ -14,8 +14,7 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
final _AttachmentSurface attachmentSurface;
|
||||
final ValueChanged<BuildContext> onAttachmentTap;
|
||||
final VoidCallback onExpand;
|
||||
final double expansionValue;
|
||||
final double expansionProgress;
|
||||
final Animation<double> expansionAnimation;
|
||||
final bool formattingOpen;
|
||||
final VoidCallback onCloseFormatting;
|
||||
final Duration motionDuration;
|
||||
@@ -43,8 +42,7 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
required this.attachmentSurface,
|
||||
required this.onAttachmentTap,
|
||||
required this.onExpand,
|
||||
required this.expansionValue,
|
||||
required this.expansionProgress,
|
||||
required this.expansionAnimation,
|
||||
required this.formattingOpen,
|
||||
required this.onCloseFormatting,
|
||||
required this.motionDuration,
|
||||
@@ -69,184 +67,173 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
final collapsedText = trimmedDraft.isEmpty
|
||||
? resolvedHint
|
||||
: trimmedDraft.replaceAll(RegExp(r'\s+'), ' ');
|
||||
final composerRadius =
|
||||
Radii.dialog + Grid.quarter * (1 - expansionProgress);
|
||||
return Container(
|
||||
key: const ValueKey('composer-surface'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(composerRadius),
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (attachments.isNotEmpty) ...[
|
||||
_AttachmentStrip(
|
||||
attachments: attachments,
|
||||
onRemove: onRemoveAttachment,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
if (uploadError case final error?) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
final content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (attachments.isNotEmpty) ...[
|
||||
_AttachmentStrip(
|
||||
attachments: attachments,
|
||||
onRemove: onRemoveAttachment,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
if (uploadError case final error?) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
// Keep the default state out of the focus system entirely so
|
||||
// restored native focus cannot expand a newly opened channel.
|
||||
if (isExpanded)
|
||||
resizeDuration == Duration.zero
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('composer-text-height-motion'),
|
||||
child: _buildTextField(context),
|
||||
)
|
||||
: AnimatedSize(
|
||||
key: const ValueKey('composer-text-height-motion'),
|
||||
alignment: Alignment.topCenter,
|
||||
duration: resizeDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: _buildTextField(context),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
_AttachmentTrigger(
|
||||
surface: attachmentSurface,
|
||||
formattingOpen: false,
|
||||
onTap: onAttachmentTap,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: resolvedHint,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _runComposerAction(onExpand),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Grid.half,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
collapsedText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: trimmedDraft.isEmpty
|
||||
? context.colors.onSurfaceVariant
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
// Keep the default state out of the focus system entirely so
|
||||
// restored native focus cannot expand a newly opened channel.
|
||||
if (isExpanded)
|
||||
resizeDuration == Duration.zero
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('composer-text-height-motion'),
|
||||
child: _buildTextField(context),
|
||||
)
|
||||
: AnimatedSize(
|
||||
key: const ValueKey('composer-text-height-motion'),
|
||||
alignment: Alignment.topCenter,
|
||||
duration: resizeDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: _buildTextField(context),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
_AttachmentTrigger(
|
||||
surface: attachmentSurface,
|
||||
formattingOpen: false,
|
||||
onTap: onAttachmentTap,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: resolvedHint,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _runComposerAction(onExpand),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
collapsedText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: trimmedDraft.isEmpty
|
||||
? context.colors.onSurfaceVariant
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
_SendButton(
|
||||
isDisabled: !canSend || hasPendingUploads,
|
||||
isSending: isSending,
|
||||
onTap: onSend,
|
||||
),
|
||||
],
|
||||
),
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: expansionValue,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isExpanded,
|
||||
child: Opacity(
|
||||
opacity: expansionProgress,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, Grid.xxs * (1 - expansionProgress)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Row(
|
||||
children: [
|
||||
_AttachmentTrigger(
|
||||
surface: attachmentSurface,
|
||||
formattingOpen: formattingOpen,
|
||||
onTap: (triggerContext) {
|
||||
if (formattingOpen) {
|
||||
onCloseFormatting();
|
||||
} else {
|
||||
onAttachmentTap(triggerContext);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
_SendButton(
|
||||
isDisabled: !canSend || hasPendingUploads,
|
||||
isSending: isSending,
|
||||
onTap: onSend,
|
||||
),
|
||||
],
|
||||
),
|
||||
_ExpandedComposerActionsMotion(
|
||||
animation: expansionAnimation,
|
||||
isExpanded: isExpanded,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Row(
|
||||
children: [
|
||||
_AttachmentTrigger(
|
||||
surface: attachmentSurface,
|
||||
formattingOpen: formattingOpen,
|
||||
onTap: (triggerContext) {
|
||||
if (formattingOpen) {
|
||||
onCloseFormatting();
|
||||
} else {
|
||||
onAttachmentTap(triggerContext);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: motionDuration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder: (currentChild, previousChildren) => Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [...previousChildren, ?currentChild],
|
||||
),
|
||||
child: formattingOpen
|
||||
? _FormattingToolbar(onFormat: onFormat)
|
||||
: Row(
|
||||
key: const ValueKey('standard-actions'),
|
||||
children: [
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.atSign,
|
||||
onTap: onMention,
|
||||
),
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.hash,
|
||||
onTap: onChannel,
|
||||
),
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.smilePlus,
|
||||
onTap: onEmoji,
|
||||
),
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.aLargeSmall,
|
||||
onTap: onOpenFormatting,
|
||||
),
|
||||
const Spacer(),
|
||||
_SendButton(
|
||||
isDisabled: !canSend || hasPendingUploads,
|
||||
isSending: isSending,
|
||||
onTap: onSend,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: motionDuration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder:
|
||||
(currentChild, previousChildren) => Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
...previousChildren,
|
||||
?currentChild,
|
||||
],
|
||||
),
|
||||
child: formattingOpen
|
||||
? _FormattingToolbar(onFormat: onFormat)
|
||||
: Row(
|
||||
key: const ValueKey('standard-actions'),
|
||||
children: [
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.atSign,
|
||||
onTap: onMention,
|
||||
),
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.hash,
|
||||
onTap: onChannel,
|
||||
),
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.smilePlus,
|
||||
onTap: onEmoji,
|
||||
),
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.aLargeSmall,
|
||||
onTap: onOpenFormatting,
|
||||
),
|
||||
const Spacer(),
|
||||
_SendButton(
|
||||
isDisabled:
|
||||
!canSend || hasPendingUploads,
|
||||
isSending: isSending,
|
||||
onTap: onSend,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return AnimatedBuilder(
|
||||
animation: expansionAnimation,
|
||||
child: content,
|
||||
builder: (context, child) {
|
||||
final progress = expansionAnimation.value.clamp(0.0, 1.0).toDouble();
|
||||
final composerRadius = Radii.dialog + Grid.quarter * (1 - progress);
|
||||
return Container(
|
||||
key: const ValueKey('composer-surface'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(composerRadius),
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,6 +275,46 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ExpandedComposerActionsMotion extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final bool isExpanded;
|
||||
final Widget child;
|
||||
|
||||
const _ExpandedComposerActionsMotion({
|
||||
required this.animation,
|
||||
required this.isExpanded,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: child,
|
||||
builder: (context, child) {
|
||||
final value = animation.value;
|
||||
final progress = value.clamp(0.0, 1.0).toDouble();
|
||||
return ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: value,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isExpanded,
|
||||
child: Opacity(
|
||||
opacity: progress,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, Grid.xxs * (1 - progress)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag the compose bar downward to put the keyboard away.
|
||||
///
|
||||
/// Continues the gesture the message list starts: once your finger reaches the
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// How long Android viewport metrics must stay quiet before layout correction.
|
||||
const androidImeMetricsSettleDelay = Duration(milliseconds: 120);
|
||||
|
||||
/// Coalesces Android's frame-by-frame IME metrics into one settled callback.
|
||||
///
|
||||
/// iOS continues to receive callbacks immediately. Android sends viewport
|
||||
/// metrics throughout the keyboard animation; doing list realignment for each
|
||||
/// delivery competes with the IME transition and can drop frames.
|
||||
class ImeMetricsSettleObserver with WidgetsBindingObserver {
|
||||
/// Runs after Android metrics remain quiet for [androidSettleDelay], or
|
||||
/// immediately for each metrics change on other platforms.
|
||||
final VoidCallback onMetricsSettled;
|
||||
|
||||
/// The quiet period used to coalesce Android IME animation metrics.
|
||||
final Duration androidSettleDelay;
|
||||
Timer? _androidTimer;
|
||||
|
||||
/// Creates an observer that coalesces Android IME metric updates.
|
||||
ImeMetricsSettleObserver({
|
||||
required this.onMetricsSettled,
|
||||
this.androidSettleDelay = androidImeMetricsSettleDelay,
|
||||
});
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
onMetricsSettled();
|
||||
return;
|
||||
}
|
||||
_androidTimer?.cancel();
|
||||
_androidTimer = Timer(androidSettleDelay, onMetricsSettled);
|
||||
}
|
||||
|
||||
/// Cancels any pending Android metrics-settlement callback.
|
||||
void dispose() {
|
||||
_androidTimer?.cancel();
|
||||
_androidTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
/// Shared channel/thread control for returning to the newest message.
|
||||
class LatestMessageButton extends StatelessWidget {
|
||||
/// Returns the message list to its newest item.
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// Optional key for inspecting or measuring the decorated glass surface.
|
||||
final Key? surfaceKey;
|
||||
|
||||
/// Creates the shared control for returning to the newest message.
|
||||
const LatestMessageButton({
|
||||
required this.onPressed,
|
||||
this.surfaceKey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(Radii.full);
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
key: surfaceKey,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface.withValues(alpha: 0.5),
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: borderRadius,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowDown,
|
||||
size: 16,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'Latest',
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,6 @@ part of 'thread_detail_page.dart';
|
||||
|
||||
int _threadTailIndex(int replyCount) => replyCount;
|
||||
|
||||
double _threadTailTrailingBoundary({
|
||||
required bool hasComposerDock,
|
||||
required double viewportHeight,
|
||||
required double dockHeight,
|
||||
}) {
|
||||
if (!hasComposerDock) return 1.001;
|
||||
if (!viewportHeight.isFinite ||
|
||||
viewportHeight <= 0 ||
|
||||
!dockHeight.isFinite ||
|
||||
dockHeight <= 0) {
|
||||
return double.negativeInfinity;
|
||||
}
|
||||
return 1 - (dockHeight / viewportHeight) + 0.001;
|
||||
}
|
||||
|
||||
void _resumeThreadTailFollow({
|
||||
required bool Function() isVisible,
|
||||
required ObjectRef<bool> userOptedOut,
|
||||
@@ -63,15 +48,6 @@ ThreadSummary _buildNestedSummary(
|
||||
);
|
||||
}
|
||||
|
||||
class _ThreadTailMetricsObserver with WidgetsBindingObserver {
|
||||
final VoidCallback onMetricsChanged;
|
||||
|
||||
_ThreadTailMetricsObserver({required this.onMetricsChanged});
|
||||
|
||||
@override
|
||||
void didChangeMetrics() => onMetricsChanged();
|
||||
}
|
||||
|
||||
/// Serializes deferred tail work behind the latest user scroll intent.
|
||||
class _ThreadTailIntent {
|
||||
var _generation = 0;
|
||||
@@ -86,6 +62,18 @@ class _ThreadTailIntent {
|
||||
|
||||
void endDrag() => isDragging = false;
|
||||
|
||||
void scheduleNextFrame({
|
||||
required bool allowed,
|
||||
required bool Function() revalidate,
|
||||
required VoidCallback action,
|
||||
}) {
|
||||
if (!allowed) return;
|
||||
final generation = ++_generation;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (generation == _generation && revalidate()) action();
|
||||
});
|
||||
}
|
||||
|
||||
void schedule({
|
||||
required bool allowed,
|
||||
required bool Function() revalidate,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
part of '../thread_detail_page.dart';
|
||||
|
||||
/// Tappable summary row shown below a reply that itself has replies.
|
||||
/// Pushes a new [ThreadDetailPage] for the nested thread.
|
||||
class _NestedThreadSummaryRow extends ConsumerWidget {
|
||||
final ThreadSummary summary;
|
||||
final TimelineMessage replyMessage;
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const _NestedThreadSummaryRow({
|
||||
required this.summary,
|
||||
required this.replyMessage,
|
||||
required this.allMessages,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.isMember,
|
||||
required this.isArchived,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: replyMessage,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
|
||||
padding: const EdgeInsets.only(
|
||||
left: messageAvatarSize + messageAvatarContentGap,
|
||||
top: Grid.half,
|
||||
bottom: Grid.xs,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Stacked participant avatars.
|
||||
SizedBox(
|
||||
width:
|
||||
32.0 +
|
||||
(summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0,
|
||||
height: 32,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = 0; i < summary.participantPubkeys.length; i++)
|
||||
Positioned(
|
||||
left: i * 20.0,
|
||||
child: SmallAvatar(
|
||||
pubkey: summary.participantPubkeys[i],
|
||||
userCache: userCache,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text:
|
||||
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
|
||||
style: replyPreviewTextStyle.copyWith(
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
if (summary.lastReplyAt case final lastReplyAt?) ...[
|
||||
TextSpan(
|
||||
text: ' · ',
|
||||
style: replyPreviewTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text:
|
||||
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
|
||||
style: replyPreviewTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
part of '../thread_detail_page.dart';
|
||||
|
||||
double _threadTailAlignmentForViewport({
|
||||
required double fullHeight,
|
||||
required double imeBottomInset,
|
||||
required bool usesFixedImeViewport,
|
||||
required double bottomInset,
|
||||
}) {
|
||||
// iOS resizes the Scaffold body around the keyboard and removes that inset
|
||||
// from the body's MediaQuery. List alignment is relative to that smaller
|
||||
// viewport, so using fullHeight leaves the final reply behind the composer.
|
||||
final viewportHeight =
|
||||
(fullHeight - (usesFixedImeViewport ? 0 : imeBottomInset))
|
||||
.clamp(1.0, double.infinity)
|
||||
.toDouble();
|
||||
return (1 - bottomInset / viewportHeight).clamp(0.0, 1.0).toDouble();
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
part of '../thread_detail_page.dart';
|
||||
|
||||
class _ThreadMessage extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final Map<String, String> channelNames;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool showAuthor;
|
||||
final bool isHighlighted;
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
/// Whether this is the message the thread hangs off, which keeps a standing
|
||||
/// "+" where replies only get one once they carry a reaction.
|
||||
final bool isThreadHead;
|
||||
|
||||
const _ThreadMessage({
|
||||
required this.message,
|
||||
required this.channelNames,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.showAuthor,
|
||||
this.isHighlighted = false,
|
||||
this.allMessages,
|
||||
this.isMember = false,
|
||||
this.isArchived = false,
|
||||
this.isThreadHead = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
ref.read(userCacheProvider.notifier).get(pk);
|
||||
final displayName = profile?.label ?? shortPubkey(message.pubkey);
|
||||
final canManageMessage =
|
||||
currentPubkey?.toLowerCase() == pk ||
|
||||
(profile?.ownerPubkey != null &&
|
||||
profile?.ownerPubkey == currentPubkey?.toLowerCase());
|
||||
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = <String, String>{};
|
||||
final agentMentionPubkeys = <String>{};
|
||||
for (final mpk in message.mentionPubkeys) {
|
||||
final normalizedPubkey = mpk.toLowerCase();
|
||||
final p = userCache[normalizedPubkey];
|
||||
if (p?.displayName != null) {
|
||||
mentionNames[normalizedPubkey] = p!.displayName!;
|
||||
}
|
||||
if (knownAgentPubkeys.contains(normalizedPubkey)) {
|
||||
agentMentionPubkeys.add(normalizedPubkey);
|
||||
}
|
||||
}
|
||||
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: message.mentionPubkeys,
|
||||
profileMentionNames: mentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
void openMessageActions(Rect anchorRect) {
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: DecoratedBox(
|
||||
key: ValueKey('thread-message-${message.id}'),
|
||||
decoration: BoxDecoration(
|
||||
color: isHighlighted
|
||||
? context.colors.primary.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
// The media carousel intentionally continues through the list's
|
||||
// trailing gutter. InkWell still clips its ink to [borderRadius],
|
||||
// while leaving overflowing message content visible.
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('thread-message-row-${message.id}'),
|
||||
onLongPress: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? 0 : Grid.xxs,
|
||||
bottom: showAuthor ? 0 : Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
showUserProfileSheet(context, message.pubkey),
|
||||
child: _Avatar(profile: profile, pubkey: message.pubkey),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(profile),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'thread-message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'thread-message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'thread-message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(viewerContext).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onChannelTap: (targetChannelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: targetChannelId,
|
||||
currentChannelId: channelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton:
|
||||
isMember &&
|
||||
!isArchived &&
|
||||
(isThreadHead || message.reactions.isNotEmpty),
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final UserProfile? profile;
|
||||
final String pubkey;
|
||||
|
||||
const _Avatar({required this.profile, required this.pubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: messageAvatarSize / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,7 @@ const _searchTitleReturnDuration = Duration(milliseconds: 80);
|
||||
const _searchCancelEnterDuration = Duration(milliseconds: 80);
|
||||
const _searchCancelExitDuration = Duration(milliseconds: 60);
|
||||
const _searchIdleFieldTopInset = Grid.half;
|
||||
const _searchActiveFieldTopOffset = 42.0;
|
||||
const _searchBottomOverlap =
|
||||
_searchActiveFieldTopOffset + _searchIdleFieldTopInset;
|
||||
const _searchControlsToFiltersGap = Grid.xxs;
|
||||
const _searchFilterChipVerticalPadding = Grid.xxs;
|
||||
const _searchFilterBarVerticalPadding = Grid.xxs;
|
||||
const _searchHeaderFiltersMinHeight = Grid.xl;
|
||||
@@ -130,15 +128,17 @@ class SearchPage extends HookConsumerWidget {
|
||||
final idleSearchFieldHeight = _idleSearchFieldHeight(context);
|
||||
final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context);
|
||||
final searchActiveFieldRightInset = _searchActiveFieldRightInset(context);
|
||||
final searchBottomOverlap =
|
||||
_searchIdleFieldTopInset +
|
||||
compactSearchFieldHeight +
|
||||
_searchControlsToFiltersGap;
|
||||
// Cancel remains an accessible target without giving the text action a
|
||||
// visual button treatment.
|
||||
final searchControlHeight = compactSearchFieldHeight > Grid.xl
|
||||
? compactSearchFieldHeight
|
||||
: Grid.xl;
|
||||
final searchHeaderBottomHeight = isSearchEditing.value
|
||||
? _searchIdleFieldTopInset +
|
||||
compactSearchFieldHeight +
|
||||
searchHeaderFiltersHeight
|
||||
? searchHeaderFiltersHeight + _searchControlsToFiltersGap
|
||||
: idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs;
|
||||
final topSectionHeight = frostedAppBarHeight(
|
||||
context,
|
||||
@@ -295,7 +295,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
bottomHeight: searchHeaderBottomHeight,
|
||||
bottomOverlap: _searchBottomOverlap,
|
||||
bottomOverlap: searchBottomOverlap,
|
||||
bottom: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
@@ -308,7 +308,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
: Grid.gutter,
|
||||
top: isSearchEditing.value
|
||||
? _searchIdleFieldTopInset
|
||||
: _searchBottomOverlap + _searchIdleFieldTopInset,
|
||||
: searchBottomOverlap + _searchIdleFieldTopInset,
|
||||
height: isSearchEditing.value
|
||||
? compactSearchFieldHeight
|
||||
: idleSearchFieldHeight,
|
||||
@@ -360,7 +360,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
? Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: _searchBottomOverlap),
|
||||
padding: EdgeInsets.only(top: searchBottomOverlap),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('search-header-filters'),
|
||||
height: searchHeaderFiltersHeight,
|
||||
|
||||
@@ -43,8 +43,14 @@ const messageMetadataTextStyle = TextStyle(
|
||||
letterSpacing: 0,
|
||||
);
|
||||
|
||||
/// Message timestamps share the secondary author metadata style.
|
||||
const messageTimestampTextStyle = messageMetadataTextStyle;
|
||||
/// Message timestamps: 13.1sp regular, one step below 15sp author names.
|
||||
const messageTimestampTextStyle = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 13.1,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 17 / 13.1,
|
||||
letterSpacing: 0,
|
||||
);
|
||||
|
||||
/// Compact reply previews: 13.1sp regular on a 17sp line height.
|
||||
const replyPreviewTextStyle = TextStyle(
|
||||
@@ -121,8 +127,8 @@ const systemMessageBodyTextStyle = TextStyle(
|
||||
/// Activity sender names share the primary author style.
|
||||
const activityUsernameTextStyle = messageUsernameTextStyle;
|
||||
|
||||
/// Activity timestamps share the secondary author metadata style.
|
||||
const activityTimestampTextStyle = messageMetadataTextStyle;
|
||||
/// Activity timestamps share the compact message timestamp treatment.
|
||||
const activityTimestampTextStyle = messageTimestampTextStyle;
|
||||
|
||||
/// Activity context labels: 13.1sp medium on a 17sp line height.
|
||||
const activityContextTextStyle = TextStyle(
|
||||
|
||||
@@ -16,7 +16,7 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
/// Color applied to [displayName].
|
||||
final Color nameColor;
|
||||
|
||||
/// Color applied to the username, separator, and [timestamp].
|
||||
/// Color applied to the username and [timestamp].
|
||||
final Color metadataColor;
|
||||
|
||||
/// Optional callback invoked when [displayName] is tapped.
|
||||
@@ -37,6 +37,9 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
/// Base text style for secondary metadata, with [metadataColor] applied.
|
||||
final TextStyle metadataStyle;
|
||||
|
||||
/// Base text style for [timestamp], with [metadataColor] applied.
|
||||
final TextStyle timestampStyle;
|
||||
|
||||
/// Creates an inline author row with optional username and tap handling.
|
||||
const MessageAuthorMeta({
|
||||
super.key,
|
||||
@@ -51,6 +54,7 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
this.timestampKey,
|
||||
this.nameStyle = messageUsernameTextStyle,
|
||||
this.metadataStyle = messageMetadataTextStyle,
|
||||
this.timestampStyle = messageTimestampTextStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -62,6 +66,9 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
normalizedUsername != displayName.trim();
|
||||
final resolvedNameStyle = nameStyle.copyWith(color: nameColor);
|
||||
final resolvedMetadataStyle = metadataStyle.copyWith(color: metadataColor);
|
||||
final resolvedTimestampStyle = timestampStyle.copyWith(
|
||||
color: metadataColor,
|
||||
);
|
||||
|
||||
Widget authorName = Text(
|
||||
displayName,
|
||||
@@ -97,9 +104,7 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: Grid.half),
|
||||
Text('·', style: resolvedMetadataStyle),
|
||||
const SizedBox(width: Grid.half),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
|
||||
child: Text(
|
||||
@@ -107,7 +112,7 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
key: timestampKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: resolvedMetadataStyle,
|
||||
style: resolvedTimestampStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:buzz/features/activity/activity_page.dart';
|
||||
import 'package:buzz/features/activity/activity_provider.dart';
|
||||
import 'package:buzz/features/activity/compose_drafts_provider.dart';
|
||||
import 'package:buzz/features/activity/feed_item.dart';
|
||||
import 'package:buzz/features/activity/inbox_item.dart';
|
||||
import 'package:buzz/features/activity/reminders_provider.dart';
|
||||
@@ -117,6 +118,8 @@ void main() {
|
||||
TextScaler? textScaler,
|
||||
EdgeInsets mediaPadding = EdgeInsets.zero,
|
||||
ValueListenable<int>? tabReselection,
|
||||
List<ComposeDraft> drafts = const [],
|
||||
List<Reminder> reminders = const [],
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -135,7 +138,10 @@ void main() {
|
||||
readStateProvider.overrideWith(
|
||||
() => _FakeReadStateNotifier(readContexts),
|
||||
),
|
||||
remindersProvider.overrideWith(() => _FakeRemindersNotifier(const [])),
|
||||
composeDraftsProvider.overrideWith(
|
||||
() => _FakeComposeDraftsNotifier(drafts),
|
||||
),
|
||||
remindersProvider.overrideWith(() => _FakeRemindersNotifier(reminders)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
@@ -386,6 +392,65 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('filter stays indicator-free when drafts and reminders exist', (
|
||||
tester,
|
||||
) async {
|
||||
final dueReminder = Reminder(
|
||||
id: 'reminder-1',
|
||||
notBefore: now - 1,
|
||||
status: 'pending',
|
||||
target: const ReminderTarget(
|
||||
eventId: 'm1',
|
||||
channelId: 'ch1',
|
||||
preview: 'Follow up',
|
||||
authorPubkey: 'alice_pk',
|
||||
),
|
||||
note: null,
|
||||
createdAt: now - 60,
|
||||
eventId: 'reminder-event-1',
|
||||
);
|
||||
final draft = ComposeDraft(
|
||||
key: 'ch1',
|
||||
channelId: 'ch1',
|
||||
threadHeadId: null,
|
||||
text: 'Unsent review note',
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(drafts: [draft], reminders: [dueReminder]),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final filterTrigger = find.byKey(const ValueKey('activity-filter-menu'));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: filterTrigger,
|
||||
matching: find.byWidgetPredicate((widget) {
|
||||
if (widget is! Container) return false;
|
||||
final constraints = widget.constraints;
|
||||
return constraints?.minWidth == 6 && constraints?.minHeight == 6;
|
||||
}),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(filterTrigger);
|
||||
await tester.pumpAndSettle();
|
||||
final filterPopover = find.byKey(const ValueKey('activity-filter-popover'));
|
||||
expect(
|
||||
find.descendant(of: filterPopover, matching: find.text('1')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(of: filterPopover, matching: find.text('Drafts')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('draft-row-ch1')), findsOneWidget);
|
||||
expect(find.text('Unsent review note'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('rows lead with sender, contextual label, and preview', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -434,8 +499,12 @@ void main() {
|
||||
expect(usernameText.style?.fontSize, messageMetadataTextStyle.fontSize);
|
||||
expect(usernameText.style?.fontWeight, FontWeight.w400);
|
||||
expect(usernameText.style?.height, messageMetadataTextStyle.height);
|
||||
expect(timestampText.style?.fontSize, messageMetadataTextStyle.fontSize);
|
||||
expect(timestampText.style?.fontSize, activityTimestampTextStyle.fontSize);
|
||||
expect(timestampText.style?.fontWeight, FontWeight.w400);
|
||||
expect(
|
||||
timestampText.style?.fontSize,
|
||||
lessThan(usernameText.style!.fontSize!),
|
||||
);
|
||||
|
||||
final avatars = tester.widgetList<AvatarImage>(find.byType(AvatarImage));
|
||||
expect(avatars, isNotEmpty);
|
||||
@@ -909,3 +978,11 @@ class _FakeRemindersNotifier extends RemindersNotifier {
|
||||
@override
|
||||
Future<List<Reminder>> build() async => _reminders;
|
||||
}
|
||||
|
||||
class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
|
||||
final List<ComposeDraft> _drafts;
|
||||
_FakeComposeDraftsNotifier(this._drafts);
|
||||
|
||||
@override
|
||||
List<ComposeDraft> build() => _drafts;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:buzz/features/channels/android_ime_lift.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('lifts only the composer on Android', (tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await tester.pumpWidget(_testLayout());
|
||||
|
||||
expect(
|
||||
tester.getBottomLeft(find.byKey(const ValueKey('composer'))).dy,
|
||||
280,
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('leaves the iOS composer on the scaffold resize path', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await tester.pumpWidget(_testLayout());
|
||||
|
||||
expect(
|
||||
tester.getBottomLeft(find.byKey(const ValueKey('composer'))).dy,
|
||||
400,
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('does not double-count Android navigation safe area', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_testLayout(viewPadding: 24, reservesViewPadding: true),
|
||||
);
|
||||
|
||||
final keyboardTop = 400 - 120;
|
||||
final composerBottom = tester
|
||||
.getBottomLeft(find.byKey(const ValueKey('composer')))
|
||||
.dy;
|
||||
expect(keyboardTop - composerBottom, Grid.xxs);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _testLayout({double viewPadding = 0, bool reservesViewPadding = false}) {
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(
|
||||
viewInsets: const EdgeInsets.only(bottom: 120),
|
||||
viewPadding: EdgeInsets.only(bottom: viewPadding),
|
||||
),
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
height: 400,
|
||||
child: AndroidImeLift(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: reservesViewPadding ? viewPadding + Grid.xxs : 0,
|
||||
),
|
||||
child: const SizedBox(
|
||||
key: ValueKey('composer'),
|
||||
width: 100,
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import 'package:buzz/features/channels/composer_dock_size_reporter.dart';
|
||||
import 'package:buzz/features/channels/date_formatters.dart';
|
||||
import 'package:buzz/features/channels/day_divider.dart';
|
||||
import 'package:buzz/features/channels/emoji_picker.dart';
|
||||
import 'package:buzz/features/channels/ime_metrics_settle_observer.dart';
|
||||
import 'package:buzz/features/channels/reaction_row.dart';
|
||||
import 'package:buzz/features/channels/thread_detail_page.dart';
|
||||
import 'package:buzz/features/channels/thread_replies_provider.dart';
|
||||
@@ -36,6 +37,7 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
|
||||
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
|
||||
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
|
||||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||||
@@ -762,6 +764,10 @@ void main() {
|
||||
expect(find.text('Forum threads are not on mobile yet'), findsNothing);
|
||||
// The compose bar for stream messages should not appear.
|
||||
expect(find.text('Message…'), findsNothing);
|
||||
final scaffold = tester.widget<FrostedScaffold>(
|
||||
find.byType(FrostedScaffold).first,
|
||||
);
|
||||
expect(scaffold.resizeToAvoidBottomInset, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('renders video attachments from imeta tags in the timeline', (
|
||||
@@ -1145,8 +1151,22 @@ void main() {
|
||||
expect(aliceUsername.style?.fontSize, messageMetadataTextStyle.fontSize);
|
||||
expect(aliceUsername.style?.fontWeight, FontWeight.w400);
|
||||
expect(aliceUsername.style?.height, messageMetadataTextStyle.height);
|
||||
expect(aliceTimestamp.style?.fontSize, messageMetadataTextStyle.fontSize);
|
||||
expect(
|
||||
aliceTimestamp.style?.fontSize,
|
||||
messageTimestampTextStyle.fontSize,
|
||||
);
|
||||
expect(aliceTimestamp.style?.fontWeight, FontWeight.w400);
|
||||
expect(
|
||||
aliceTimestamp.style?.fontSize,
|
||||
lessThan(aliceText.style!.fontSize!),
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('message-row-msg1')),
|
||||
matching: find.text('·'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
final helloContent = findRichText('Hello world!');
|
||||
final helloText = tester.widget<RichText>(helloContent);
|
||||
expect(
|
||||
@@ -2285,6 +2305,61 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'seeds an already-visible Android keyboard into the channel tail layout',
|
||||
(tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final messages = [
|
||||
for (var i = 0; i < 20; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: i == 19
|
||||
? List.filled(8, 'Tall latest message').join('\n')
|
||||
: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final latestMessage = find.byKey(
|
||||
const ValueKey('channel-message-group-msg19'),
|
||||
);
|
||||
final composerDock = find.byKey(
|
||||
const ValueKey('channel-composer-dock'),
|
||||
);
|
||||
expect(latestMessage, findsOneWidget);
|
||||
expect(
|
||||
tester.getBottomLeft(latestMessage).dy,
|
||||
closeTo(tester.getTopLeft(composerDock).dy, 1),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-latest')),
|
||||
findsNothing,
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('keeps a short followed tail flush through composer resize', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -2491,6 +2566,182 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Latest reveals the channel tail while the Android keyboard stays open',
|
||||
(tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final messages = [
|
||||
for (var i = 0; i < 40; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Message #general'));
|
||||
await tester.pump();
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pump();
|
||||
await tester.pump(androidImeMetricsSettleDelay);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(textField.focusNode?.hasFocus, isTrue);
|
||||
|
||||
final messageList = find.byKey(
|
||||
const ValueKey('channel-message-list'),
|
||||
);
|
||||
final messageListElement = tester.element(messageList);
|
||||
UserScrollNotification(
|
||||
metrics: FixedScrollMetrics(
|
||||
minScrollExtent: 0,
|
||||
maxScrollExtent: 100,
|
||||
pixels: 0,
|
||||
viewportDimension: 100,
|
||||
axisDirection: AxisDirection.down,
|
||||
devicePixelRatio: 1,
|
||||
),
|
||||
context: messageListElement,
|
||||
direction: ScrollDirection.reverse,
|
||||
).dispatch(messageListElement);
|
||||
tester
|
||||
.widget<ScrollablePositionedList>(messageList)
|
||||
.itemScrollController!
|
||||
.jumpTo(index: 39);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-latest')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('channel-jump-to-latest')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final latestMessage = find.byKey(
|
||||
const ValueKey('channel-message-group-msg39'),
|
||||
);
|
||||
final composerDock = find.byKey(
|
||||
const ValueKey('channel-composer-dock'),
|
||||
);
|
||||
expect(latestMessage, findsOneWidget);
|
||||
expect(textField.focusNode?.hasFocus, isTrue);
|
||||
expect(
|
||||
tester.getBottomLeft(latestMessage).dy,
|
||||
closeTo(tester.getTopLeft(composerDock).dy, 1),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-latest')),
|
||||
findsNothing,
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'keeps the Latest gap stable above the Android composer and keyboard',
|
||||
(tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final initialMessages = [
|
||||
for (var i = 0; i < 40; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: initialMessages,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final messageList = find.byKey(
|
||||
const ValueKey('channel-message-list'),
|
||||
);
|
||||
final messageListElement = tester.element(messageList);
|
||||
UserScrollNotification(
|
||||
metrics: FixedScrollMetrics(
|
||||
minScrollExtent: 0,
|
||||
maxScrollExtent: 100,
|
||||
pixels: 0,
|
||||
viewportDimension: 100,
|
||||
axisDirection: AxisDirection.down,
|
||||
devicePixelRatio: 1,
|
||||
),
|
||||
context: messageListElement,
|
||||
direction: ScrollDirection.reverse,
|
||||
).dispatch(messageListElement);
|
||||
tester
|
||||
.widget<ScrollablePositionedList>(messageList)
|
||||
.itemScrollController!
|
||||
.jumpTo(index: 39);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final latestSurface = find.byKey(
|
||||
const ValueKey('channel-jump-to-latest-surface'),
|
||||
);
|
||||
final composerDock = find.byKey(
|
||||
const ValueKey('channel-composer-dock'),
|
||||
);
|
||||
double latestGap() =>
|
||||
tester.getTopLeft(composerDock).dy -
|
||||
tester.getBottomLeft(latestSurface).dy;
|
||||
|
||||
final collapsedGap = latestGap();
|
||||
expect(collapsedGap, closeTo(Grid.xs, 0.5));
|
||||
|
||||
await tester.tap(find.text('Message #general'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pump();
|
||||
await tester.pump(androidImeMetricsSettleDelay);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(latestGap(), closeTo(collapsedGap, 0.5));
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'keeps follow mode off while a tall newest message stays visible',
|
||||
(tester) async {
|
||||
@@ -4292,6 +4543,20 @@ void main() {
|
||||
.dy;
|
||||
expect(headY, lessThan(oldestReplyY));
|
||||
expect(oldestReplyY, lessThan(newestReplyY));
|
||||
final threadTimestamp = tester.widget<Text>(
|
||||
find.byKey(const ValueKey('thread-message-timestamp-thread-root')),
|
||||
);
|
||||
expect(
|
||||
threadTimestamp.style?.fontSize,
|
||||
messageTimestampTextStyle.fontSize,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('thread-message-row-thread-root')),
|
||||
matching: find.text('·'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('thread keeps its tail above a growing composer dock', (
|
||||
@@ -4376,12 +4641,90 @@ void main() {
|
||||
// that metrics change too.
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump(androidImeMetricsSettleDelay);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
tester.getBottomLeft(latestReply).dy,
|
||||
lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const ValueKey('thread-message-list')),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.position
|
||||
.isScrollingNotifier
|
||||
.value,
|
||||
isFalse,
|
||||
reason: 'Keyboard layout correction must not start a scroll animation.',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('short thread keeps its head stable when the keyboard opens', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final rootEvent = _textMsg(
|
||||
id: 'thread-root',
|
||||
pubkey: 'alice',
|
||||
content: 'A short thread',
|
||||
createdAt: 1000,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [rootEvent],
|
||||
threadReplies: const {'thread-root': []},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final threadHead = formatTimeline([rootEvent]).single;
|
||||
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: [threadHead],
|
||||
channelId: _channelId,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final head = find.byKey(
|
||||
const ValueKey('thread-message-group-thread-root'),
|
||||
);
|
||||
final initialHeadY = tester.getTopLeft(head).dy;
|
||||
expect(initialHeadY, lessThan(300));
|
||||
|
||||
await tester.tap(find.text('Reply in thread…').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pump();
|
||||
await tester.pump(androidImeMetricsSettleDelay);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(head).dy,
|
||||
closeTo(initialHeadY, 1),
|
||||
reason: 'A fully visible short thread should remain head-anchored.',
|
||||
);
|
||||
});
|
||||
|
||||
for (final replyCount in [0, 1]) {
|
||||
@@ -4898,7 +5241,11 @@ void main() {
|
||||
final list = find.byKey(const ValueKey('thread-message-list'));
|
||||
final listHeight = tester.getSize(list).height;
|
||||
final mediaQueryHeight = MediaQuery.sizeOf(tester.element(list)).height;
|
||||
expect(listHeight, lessThan(mediaQueryHeight));
|
||||
expect(
|
||||
listHeight,
|
||||
closeTo(mediaQueryHeight, 0.5),
|
||||
reason: 'Android keeps the thread viewport fixed behind the IME.',
|
||||
);
|
||||
|
||||
completer.complete(replies);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -5114,6 +5461,10 @@ void main() {
|
||||
find.byKey(const ValueKey('thread-message-group-thread-root')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
completer.complete(replies);
|
||||
await tester.pump();
|
||||
@@ -5988,8 +6339,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-message-group-reply-29')),
|
||||
findsNothing,
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsOneWidget,
|
||||
reason: 'Browsing away from the tail should offer Latest.',
|
||||
);
|
||||
final visibleBeforeResize = tester
|
||||
.widgetList<Widget>(
|
||||
@@ -6051,7 +6403,7 @@ void main() {
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'deep-linking an older reply does not resume tail following on keyboard resize',
|
||||
'deep-link stays put through passive resize until composer focus follows tail',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -6115,13 +6467,427 @@ void main() {
|
||||
const ValueKey('thread-message-group-reply-5'),
|
||||
);
|
||||
expect(target, findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Reply in thread…').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(target, findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Reply in thread…').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-message-group-reply-29')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('iOS composer focus and Latest fully reveal the final reply', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewPadding = const FakeViewPadding(bottom: 20);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final rootEvent = _textMsg(
|
||||
id: 'thread-root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final replies = [
|
||||
for (var i = 0; i < 30; i++)
|
||||
_textMsg(
|
||||
id: 'reply-$i',
|
||||
pubkey: 'bob',
|
||||
content: 'Reply $i',
|
||||
createdAt: 1100 + i,
|
||||
extraTags: const [
|
||||
['e', 'thread-root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [rootEvent],
|
||||
threadReplies: {'thread-root': replies},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final threadHead = formatTimeline([rootEvent]).single;
|
||||
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: [threadHead],
|
||||
channelId: _channelId,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Reply in thread…').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final latestReply = find.byKey(
|
||||
const ValueKey('thread-message-group-reply-29'),
|
||||
);
|
||||
final composerSurface = find.byKey(const ValueKey('composer-surface'));
|
||||
final focusedReplyBottom = tester.getBottomLeft(latestReply).dy;
|
||||
final composerTop = tester.getTopLeft(composerSurface).dy;
|
||||
|
||||
final list = find.byKey(const ValueKey('thread-message-list'));
|
||||
final listElement = tester.element(list);
|
||||
ScrollStartNotification(
|
||||
metrics: FixedScrollMetrics(
|
||||
minScrollExtent: 0,
|
||||
maxScrollExtent: 100,
|
||||
pixels: 0,
|
||||
viewportDimension: 100,
|
||||
axisDirection: AxisDirection.down,
|
||||
devicePixelRatio: 1,
|
||||
),
|
||||
context: listElement,
|
||||
dragDetails: DragStartDetails(),
|
||||
).dispatch(listElement);
|
||||
tester
|
||||
.widget<ScrollablePositionedList>(list)
|
||||
.itemScrollController!
|
||||
.jumpTo(index: 5);
|
||||
await tester.pumpAndSettle();
|
||||
final latestButton = find.byKey(const ValueKey('thread-jump-to-latest'));
|
||||
final latestButtonWasVisible = latestButton.evaluate().length == 1;
|
||||
await tester.tap(latestButton);
|
||||
await tester.pumpAndSettle();
|
||||
final latestReplyBottom = tester.getBottomLeft(latestReply).dy;
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
|
||||
expect(
|
||||
focusedReplyBottom,
|
||||
lessThanOrEqualTo(composerTop),
|
||||
reason: 'Focusing the composer must retain the final reply above it.',
|
||||
);
|
||||
expect(latestButtonWasVisible, isTrue);
|
||||
expect(
|
||||
latestReplyBottom,
|
||||
lessThanOrEqualTo(composerTop),
|
||||
reason: 'Latest must reveal the final reply above the iOS composer.',
|
||||
);
|
||||
});
|
||||
|
||||
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
|
||||
testWidgets(
|
||||
'thread composer focus returns to the tail on ${platform.name}',
|
||||
(tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.viewPadding = const FakeViewPadding(bottom: 20);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final rootEvent = _textMsg(
|
||||
id: 'thread-root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final replies = [
|
||||
for (var i = 0; i < 30; i++)
|
||||
_textMsg(
|
||||
id: 'reply-$i',
|
||||
pubkey: 'bob',
|
||||
content: 'Reply $i',
|
||||
createdAt: 1100 + i,
|
||||
extraTags: const [
|
||||
['e', 'thread-root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [rootEvent],
|
||||
threadReplies: {'thread-root': replies},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final threadHead = formatTimeline([rootEvent]).single;
|
||||
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: [threadHead],
|
||||
channelId: _channelId,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
initialMessageId: 'reply-5',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Reply in thread…').hitTestable());
|
||||
await tester.pump();
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pump();
|
||||
if (platform == TargetPlatform.android) {
|
||||
await tester.pump(androidImeMetricsSettleDelay);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final latestReply = find.byKey(
|
||||
const ValueKey('thread-message-group-reply-29'),
|
||||
);
|
||||
final composerSurface = find.byKey(
|
||||
const ValueKey('composer-surface'),
|
||||
);
|
||||
final focusNode = tester
|
||||
.widget<TextField>(find.byType(TextField))
|
||||
.focusNode!;
|
||||
final latestReplyBottom = tester.getBottomLeft(latestReply).dy;
|
||||
final composerTop = tester.getTopLeft(composerSurface).dy;
|
||||
final latestButtonIsVisible = find
|
||||
.byKey(const ValueKey('thread-jump-to-latest'))
|
||||
.evaluate()
|
||||
.isNotEmpty;
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
expect(latestReplyBottom, lessThanOrEqualTo(composerTop));
|
||||
expect(latestButtonIsVisible, isFalse);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'thread shows Latest after browsing history and returns to tail',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final rootEvent = _textMsg(
|
||||
id: 'thread-root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final replies = [
|
||||
for (var i = 0; i < 30; i++)
|
||||
_textMsg(
|
||||
id: 'reply-$i',
|
||||
pubkey: 'bob',
|
||||
content: 'Reply $i',
|
||||
createdAt: 1100 + i,
|
||||
extraTags: const [
|
||||
['e', 'thread-root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [rootEvent],
|
||||
threadReplies: {'thread-root': replies},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final threadHead = formatTimeline([rootEvent]).single;
|
||||
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: [threadHead],
|
||||
channelId: _channelId,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final list = find.byKey(const ValueKey('thread-message-list'));
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-message-group-reply-29')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.drag(list, const Offset(0, 500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
final threadScrollable = tester.state<ScrollableState>(
|
||||
find.descendant(of: list, matching: find.byType(Scrollable)).first,
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('thread-jump-to-latest')));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(
|
||||
threadScrollable.position.isScrollingNotifier.value,
|
||||
isTrue,
|
||||
reason: 'An explicit Latest tap should retain its navigation motion.',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-message-group-reply-29')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsNothing,
|
||||
);
|
||||
final settledTailY = tester
|
||||
.getTopLeft(find.byKey(const ValueKey('thread-tail-anchor')))
|
||||
.dy;
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(
|
||||
tester
|
||||
.getTopLeft(find.byKey(const ValueKey('thread-tail-anchor')))
|
||||
.dy,
|
||||
closeTo(settledTailY, 0.5),
|
||||
reason: 'Latest must not be followed by a corrective rebound.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'a newly sent thread reply follows the tail without animation',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final rootEvent = _textMsg(
|
||||
id: 'thread-root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final replies = [
|
||||
for (var i = 0; i < 20; i++)
|
||||
_textMsg(
|
||||
id: 'reply-$i',
|
||||
pubkey: 'bob',
|
||||
content: 'Reply $i',
|
||||
createdAt: 1100 + i,
|
||||
extraTags: const [
|
||||
['e', 'thread-root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
];
|
||||
final messagesNotifier = _FakeMessagesNotifier([rootEvent]);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [rootEvent],
|
||||
messagesNotifier: messagesNotifier,
|
||||
threadReplies: {'thread-root': replies},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
'self': UserProfile(pubkey: 'self', displayName: 'Me'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final threadHead = formatTimeline([rootEvent]).single;
|
||||
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: [threadHead],
|
||||
channelId: _channelId,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final list = find.byKey(const ValueKey('thread-message-list'));
|
||||
final threadScrollable = tester.state<ScrollableState>(
|
||||
find.descendant(of: list, matching: find.byType(Scrollable)).first,
|
||||
);
|
||||
final localReply = _textMsg(
|
||||
id: 'reply-local',
|
||||
pubkey: 'self',
|
||||
content: 'My new reply',
|
||||
createdAt: 2000,
|
||||
extraTags: const [
|
||||
['e', 'thread-root', '', 'reply'],
|
||||
],
|
||||
);
|
||||
|
||||
messagesNotifier.setMessages([rootEvent, localReply]);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-message-group-reply-local')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
threadScrollable.position.isScrollingNotifier.value,
|
||||
isFalse,
|
||||
reason: 'Reply-driven tail correction must be instant.',
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-jump-to-latest')),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ Widget _buildComposeBar({
|
||||
List<CustomEmoji> customEmoji = const <CustomEmoji>[],
|
||||
RelayConfigNotifier Function()? relayConfig,
|
||||
PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(),
|
||||
VoidCallback? onFocusRequested,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
@@ -227,7 +228,11 @@ Widget _buildComposeBar({
|
||||
body: SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ComposeBar(channelId: 'channel-1', onSend: onSend),
|
||||
child: ComposeBar(
|
||||
channelId: 'channel-1',
|
||||
onFocusRequested: onFocusRequested,
|
||||
onSend: onSend,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -526,6 +531,110 @@ void main() {
|
||||
expect(find.byIcon(LucideIcons.aLargeSmall), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('notifies focus intent before attaching the focused field', (
|
||||
tester,
|
||||
) async {
|
||||
var focusRequested = false;
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onFocusRequested: () => focusRequested = true,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Message\u2026'));
|
||||
expect(focusRequested, isTrue);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).focusNode!.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('starts Android composer motion with the first IME metrics', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
final widthFinder = find.byKey(
|
||||
const ValueKey('composer-width-transition'),
|
||||
);
|
||||
final compactWidth = tester.getSize(widthFinder).width;
|
||||
|
||||
await tester.tap(find.text('Message\u2026'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
|
||||
expect(tester.getSize(widthFinder).width, closeTo(compactWidth, 0.1));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
|
||||
expect(tester.getSize(widthFinder).width, greaterThan(compactWidth));
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('expands Android composer when the IME is already visible', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(() {
|
||||
tester.view.reset();
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
});
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
final widthFinder = find.byKey(
|
||||
const ValueKey('composer-width-transition'),
|
||||
);
|
||||
final compactWidth = tester.getSize(widthFinder).width;
|
||||
|
||||
await tester.tap(find.text('Message\u2026'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(widthFinder).width, greaterThan(compactWidth));
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('returns to the compact capsule when the keyboard drops', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -705,6 +814,9 @@ void main() {
|
||||
|
||||
await tester.tap(find.text('Message\u2026'));
|
||||
await tester.pump();
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:buzz/features/channels/ime_metrics_settle_observer.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('coalesces Android IME metrics until the viewport settles', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
var callbacks = 0;
|
||||
final observer = ImeMetricsSettleObserver(
|
||||
onMetricsSettled: () => callbacks += 1,
|
||||
);
|
||||
addTearDown(observer.dispose);
|
||||
|
||||
observer.didChangeMetrics();
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
observer.didChangeMetrics();
|
||||
await tester.pump(const Duration(milliseconds: 119));
|
||||
expect(callbacks, 0);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
expect(callbacks, 1);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('keeps non-Android metrics callbacks immediate', (tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
var callbacks = 0;
|
||||
final observer = ImeMetricsSettleObserver(
|
||||
onMetricsSettled: () => callbacks += 1,
|
||||
);
|
||||
addTearDown(observer.dispose);
|
||||
|
||||
observer.didChangeMetrics();
|
||||
observer.didChangeMetrics();
|
||||
|
||||
expect(callbacks, 2);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -316,6 +316,14 @@ void main() {
|
||||
reason: 'The active field translates upward into the title row.',
|
||||
);
|
||||
expect(find.byKey(const Key('search-header-filters')), findsOneWidget);
|
||||
final filtersRect = tester.getRect(
|
||||
find.byKey(const Key('search-header-filters')),
|
||||
);
|
||||
expect(
|
||||
filtersRect.top - focusedRect.bottom,
|
||||
Grid.xxs,
|
||||
reason: 'Filters keep one compact spacing token below the controls.',
|
||||
);
|
||||
final settledSlide = tester.widget<SlideTransition>(
|
||||
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
|
||||
);
|
||||
@@ -330,6 +338,12 @@ void main() {
|
||||
expect(iconScale.scale, lessThan(1));
|
||||
expect(movingField.top, Grid.half);
|
||||
final appBarRect = tester.getRect(find.byType(FrostedAppBar));
|
||||
expect(
|
||||
appBarRect.bottom - filtersRect.bottom,
|
||||
closeTo(Grid.xxs + 1, 0.01),
|
||||
reason:
|
||||
'The filter row keeps the same spacing below it, plus the divider.',
|
||||
);
|
||||
expect(
|
||||
appBarRect.contains(focusedRect.center),
|
||||
isTrue,
|
||||
|
||||
@@ -34,12 +34,15 @@ void main() {
|
||||
);
|
||||
expectStyle(
|
||||
messageTimestampTextStyle,
|
||||
fontSize: 15,
|
||||
fontSize: 13.1,
|
||||
fontWeight: FontWeight.w400,
|
||||
lineHeight: 17,
|
||||
letterSpacing: 0,
|
||||
);
|
||||
expect(messageTimestampTextStyle, messageMetadataTextStyle);
|
||||
expect(
|
||||
messageTimestampTextStyle.fontSize,
|
||||
lessThan(messageUsernameTextStyle.fontSize!),
|
||||
);
|
||||
expectStyle(
|
||||
replyPreviewTextStyle,
|
||||
fontSize: 13.1,
|
||||
@@ -87,7 +90,7 @@ void main() {
|
||||
);
|
||||
expectStyle(
|
||||
activityTimestampTextStyle,
|
||||
fontSize: 15,
|
||||
fontSize: 13.1,
|
||||
fontWeight: FontWeight.w400,
|
||||
lineHeight: 17,
|
||||
letterSpacing: 0,
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('keeps the separator and timestamp next to a short name', (
|
||||
testWidgets('keeps the timestamp next to a short name without a separator', (
|
||||
tester,
|
||||
) async {
|
||||
const displayNameKey = Key('author-display-name');
|
||||
@@ -31,11 +31,14 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final displayNameRect = tester.getRect(find.byKey(displayNameKey));
|
||||
final separatorRect = tester.getRect(find.text('·'));
|
||||
final timestampRect = tester.getRect(find.byKey(timestampKey));
|
||||
|
||||
expect(separatorRect.left - displayNameRect.right, Grid.half);
|
||||
expect(timestampRect.left - separatorRect.right, Grid.half);
|
||||
expect(find.text('·'), findsNothing);
|
||||
expect(timestampRect.left - displayNameRect.right, Grid.xxs);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(timestampKey)).style?.fontSize,
|
||||
messageTimestampTextStyle.fontSize,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -84,6 +87,7 @@ void main() {
|
||||
testWidgets('constrains long metadata at large accessible text sizes', (
|
||||
tester,
|
||||
) async {
|
||||
const displayNameKey = Key('author-display-name');
|
||||
const timestampKey = Key('author-timestamp');
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -98,6 +102,7 @@ void main() {
|
||||
displayName: 'A very long display name',
|
||||
username: 'a-very-long-username',
|
||||
timestamp: 'Mar 15, 2025',
|
||||
displayNameKey: displayNameKey,
|
||||
timestampKey: timestampKey,
|
||||
nameColor: Colors.black,
|
||||
metadataColor: Colors.grey,
|
||||
@@ -112,6 +117,20 @@ void main() {
|
||||
final timestamp = tester.widget<Text>(find.byKey(timestampKey));
|
||||
expect(timestamp.maxLines, 1);
|
||||
expect(timestamp.overflow, TextOverflow.ellipsis);
|
||||
final nameRichText = tester.widget<RichText>(
|
||||
find.descendant(
|
||||
of: find.byKey(displayNameKey),
|
||||
matching: find.byType(RichText),
|
||||
),
|
||||
);
|
||||
final timestampRichText = tester.widget<RichText>(
|
||||
find.descendant(
|
||||
of: find.byKey(timestampKey),
|
||||
matching: find.byType(RichText),
|
||||
),
|
||||
);
|
||||
expect(nameRichText.textScaler.scale(15), 30);
|
||||
expect(timestampRichText.textScaler.scale(13.1), 26.2);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user