mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Replace mobile reconnect banners with skeleton shimmer (#3143)
## Summary - replace mobile connecting and reconnecting banners with element-shaped skeletons for channel lists and message timelines - add a low-contrast two-second shimmer and same-slot reveal, with reduced-motion support - align top, section, loaded-row, and skeleton label columns ## Why Connection banners shifted content and did not match the desktop loading treatment. The skeletons preserve layout and make reconnects less disruptive. ## Testing - `just mobile-check` - `just mobile-test` — 704 passed, 1 skipped - Pixel 10 visual verification in loaded and reconnecting states --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' show min;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -11,6 +12,7 @@ import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/skeleton.dart';
|
||||
import '../profile/presence_cache_provider.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
@@ -121,6 +123,7 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final messagesState = ref.watch(channelMessagesProvider(channel.id));
|
||||
final sessionStatus = ref.watch(relaySessionProvider).status;
|
||||
final readState = ref.watch(readStateProvider);
|
||||
final currentPubkey = ref
|
||||
.watch(profileProvider)
|
||||
@@ -148,6 +151,30 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
channel;
|
||||
final resolvedChannel =
|
||||
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
|
||||
final messagesNotifier = ref.read(
|
||||
channelMessagesProvider(channel.id).notifier,
|
||||
);
|
||||
final isConnectionInProgress =
|
||||
sessionStatus == SessionStatus.connecting ||
|
||||
sessionStatus == SessionStatus.reconnecting;
|
||||
final showConnectionSkeleton = useState(false);
|
||||
final shouldDebounceConnectionSkeleton =
|
||||
isConnectionInProgress &&
|
||||
(resolvedChannel.isForum || messagesNotifier.hasLoadedMessages);
|
||||
useEffect(() {
|
||||
if (!shouldDebounceConnectionSkeleton) {
|
||||
showConnectionSkeleton.value = false;
|
||||
return null;
|
||||
}
|
||||
final timer = Timer(const Duration(seconds: 2), () {
|
||||
showConnectionSkeleton.value = true;
|
||||
});
|
||||
return timer.cancel;
|
||||
}, [shouldDebounceConnectionSkeleton]);
|
||||
final showInitialConnectionSkeleton =
|
||||
!resolvedChannel.isForum &&
|
||||
isConnectionInProgress &&
|
||||
!messagesNotifier.hasLoadedMessages;
|
||||
final appBarTitleContentHeight = resolvedChannel.isDm
|
||||
? _dmAppBarTitleContentHeight(context)
|
||||
: 0.0;
|
||||
@@ -259,65 +286,84 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: resolvedChannel.isForum
|
||||
? ForumPostsView(
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ForumPostsView(
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
if (showConnectionSkeleton.value)
|
||||
Positioned(
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.xs,
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
child: _ForumConnectionSkeleton(
|
||||
status: sessionStatus,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: messagesState.when(
|
||||
loading: () => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
: SkeletonReveal(
|
||||
loading:
|
||||
showInitialConnectionSkeleton ||
|
||||
showConnectionSkeleton.value ||
|
||||
messagesState.isLoading,
|
||||
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
|
||||
skeleton: _MessageTimelineSkeleton(
|
||||
appBarTitleContentHeight: appBarTitleContentHeight,
|
||||
status: sessionStatus,
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
content: messagesState.when(
|
||||
loading: SizedBox.shrink,
|
||||
error: (e, _) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Failed to load messages',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.error,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Failed to load messages',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (events) {
|
||||
final messages = formatTimeline(
|
||||
events,
|
||||
currentPubkey: currentPubkey,
|
||||
);
|
||||
final summaries = ref
|
||||
.read(channelMessagesProvider(channel.id).notifier)
|
||||
.threadSummaries;
|
||||
final entries = buildMainTimelineEntries(
|
||||
messages,
|
||||
relaySummaries: summaries,
|
||||
);
|
||||
return _MessageList(
|
||||
entries: entries,
|
||||
allMessages: messages,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
channelId: channel.id,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: resolvedChannel.isMember,
|
||||
isArchived: resolvedChannel.isArchived,
|
||||
appBarTitleContentHeight: appBarTitleContentHeight,
|
||||
);
|
||||
},
|
||||
),
|
||||
data: (events) {
|
||||
final messages = formatTimeline(
|
||||
events,
|
||||
currentPubkey: currentPubkey,
|
||||
);
|
||||
final summaries = ref
|
||||
.read(channelMessagesProvider(channel.id).notifier)
|
||||
.threadSummaries;
|
||||
final entries = buildMainTimelineEntries(
|
||||
messages,
|
||||
relaySummaries: summaries,
|
||||
);
|
||||
return _MessageList(
|
||||
entries: entries,
|
||||
allMessages: messages,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
channelId: channel.id,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: resolvedChannel.isMember,
|
||||
isArchived: resolvedChannel.isArchived,
|
||||
appBarTitleContentHeight: appBarTitleContentHeight,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_DetailConnectionBanner(
|
||||
status: ref.watch(relaySessionProvider).status,
|
||||
),
|
||||
if (!resolvedChannel.isForum && typingEntries.isNotEmpty)
|
||||
_TypingIndicator(entries: typingEntries),
|
||||
if (!resolvedChannel.isForum &&
|
||||
|
||||
@@ -54,44 +54,160 @@ class _HeaderEphemeralBadge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailConnectionBanner extends StatelessWidget {
|
||||
class _MessageTimelineSkeleton extends StatelessWidget {
|
||||
final double appBarTitleContentHeight;
|
||||
final SessionStatus status;
|
||||
|
||||
const _DetailConnectionBanner({required this.status});
|
||||
const _MessageTimelineSkeleton({
|
||||
required this.appBarTitleContentHeight,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (status == SessionStatus.connected ||
|
||||
status == SessionStatus.disconnected) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.quarter + 2,
|
||||
),
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
final semanticsLabel = switch (status) {
|
||||
SessionStatus.connecting => 'Connecting',
|
||||
SessionStatus.reconnecting => 'Reconnecting',
|
||||
SessionStatus.connected || SessionStatus.disconnected => 'Loading',
|
||||
};
|
||||
return Semantics(
|
||||
key: const Key('channel-detail-connection-skeleton'),
|
||||
liveRegion: true,
|
||||
label: semanticsLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.xs,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Text(
|
||||
'Reconnecting…',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
itemCount: 4,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: Grid.xs),
|
||||
itemBuilder: (_, index) => _MessageSkeletonRow(index: index),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageSkeletonRow extends StatelessWidget {
|
||||
final int index;
|
||||
|
||||
const _MessageSkeletonRow({required this.index});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const authorWidths = <double>[112, 96, 128, 80];
|
||||
const lineWidths = <List<double>>[
|
||||
[280, 224],
|
||||
[272, 184],
|
||||
[232],
|
||||
[288, 216],
|
||||
];
|
||||
final availableWidth = MediaQuery.sizeOf(context).width - 88;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBar(
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SkeletonBar(width: authorWidths[index], height: 15),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
const SkeletonBar(width: 40, height: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
for (final width in lineWidths[index]) ...[
|
||||
SkeletonBar(width: min(width, availableWidth), height: 16),
|
||||
const SizedBox(height: Grid.half),
|
||||
],
|
||||
const Row(
|
||||
children: [
|
||||
SkeletonBar(width: 32, height: 16),
|
||||
SizedBox(width: Grid.xs),
|
||||
SkeletonBar(width: 32, height: 16),
|
||||
SizedBox(width: Grid.xs),
|
||||
SkeletonBar(width: 32, height: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ForumConnectionSkeleton extends StatelessWidget {
|
||||
final SessionStatus status;
|
||||
|
||||
const _ForumConnectionSkeleton({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final semanticsLabel = switch (status) {
|
||||
SessionStatus.connecting => 'Connecting',
|
||||
SessionStatus.reconnecting => 'Reconnecting',
|
||||
SessionStatus.connected || SessionStatus.disconnected => 'Loading',
|
||||
};
|
||||
return Semantics(
|
||||
key: const Key('forum-connection-skeleton'),
|
||||
liveRegion: true,
|
||||
label: semanticsLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: IgnorePointer(
|
||||
child: ExcludeFocus(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
child: SkeletonShimmer(
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SkeletonBar(
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(Radii.full),
|
||||
),
|
||||
),
|
||||
SizedBox(width: Grid.xxs),
|
||||
SkeletonBar(width: 112, height: 14),
|
||||
],
|
||||
),
|
||||
SizedBox(height: Grid.xxs),
|
||||
SkeletonBar(width: 240, height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
/// UI can show stale data instead of a blank loading spinner.
|
||||
List<NostrEvent>? _lastKnownMessages;
|
||||
|
||||
/// Whether this channel has completed at least one message history load.
|
||||
///
|
||||
/// This distinguishes a genuinely loaded empty channel from the synthetic
|
||||
/// empty value returned while the relay is not yet connected.
|
||||
bool get hasLoadedMessages => _lastKnownMessages != null;
|
||||
|
||||
Map<String, ChannelWindowThreadSummary> get threadSummaries =>
|
||||
channelWindowThreadSummaries(_windowStore);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' show pi;
|
||||
import 'dart:math' show max, min, pi;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -16,6 +16,7 @@ import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/skeleton.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
@@ -46,15 +47,13 @@ part 'channels_page/sections.dart';
|
||||
part 'channels_page/channel_tile.dart';
|
||||
part 'channels_page/sheets.dart';
|
||||
part 'channels_page/badges.dart';
|
||||
part 'channels_page/skeleton.dart';
|
||||
part 'channels_page/community.dart';
|
||||
part 'channels_page/quick_actions.dart';
|
||||
part 'channels_page/quick_actions_launcher.dart';
|
||||
|
||||
enum _QuickAction { createChannel, newDm }
|
||||
|
||||
/// Height of the [_ConnectionBanner]: vertical padding (Grid.quarter + 2) × 2
|
||||
/// plus the ~16px row content (12px spinner / labelSmall text).
|
||||
const double _kBannerHeight = 24.0;
|
||||
const double _kChannelSectionInset = Grid.gutter;
|
||||
const double _kChannelLeadingWidth = 22.0;
|
||||
const double _kChannelIconSize = 18.0;
|
||||
@@ -69,12 +68,17 @@ const double _kChannelLabelInset =
|
||||
/// sections while the labels stay on [_kChannelLabelInset].
|
||||
const double _kDmAvatarSize = _kChannelIconSize;
|
||||
|
||||
const double _kTopSectionAvatarSize = 32.0;
|
||||
|
||||
/// The top section's avatars are 32dp circles, which fill their box edge to
|
||||
/// edge; the channel rows below lead with an 18dp glyph left-aligned in a 22dp
|
||||
/// box at [_kChannelSectionInset]. Edge-aligning the two leaves the circles
|
||||
/// looking pushed outward, so the bar is pulled in to sit the avatar's centre
|
||||
/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp).
|
||||
/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp). Its
|
||||
/// label gap is derived separately so both labels land on the same 50dp column.
|
||||
const double _kTopSectionInset = Grid.twelve;
|
||||
const double _kTopSectionLabelGap =
|
||||
_kChannelLabelInset - _kTopSectionInset - _kTopSectionAvatarSize;
|
||||
const Duration _kSectionExpandDuration = Duration(milliseconds: 220);
|
||||
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
|
||||
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
|
||||
@@ -202,21 +206,20 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
return timer.cancel;
|
||||
}, [canSurfaceError]);
|
||||
|
||||
// Match desktop's degraded-state debounce: cached content remains steady
|
||||
// through brief socket flaps, and the banner appears only for a sustained
|
||||
// reconnect.
|
||||
final showConnectionBanner = useState(false);
|
||||
// Keep cached content steady through brief socket flaps. A sustained
|
||||
// reconnect swaps to element-shaped skeletons that match desktop.
|
||||
final showConnectionSkeleton = useState(false);
|
||||
final isReconnectingWithContent =
|
||||
channels != null &&
|
||||
(sessionState.status == SessionStatus.connecting ||
|
||||
sessionState.status == SessionStatus.reconnecting);
|
||||
useEffect(() {
|
||||
if (!isReconnectingWithContent) {
|
||||
showConnectionBanner.value = false;
|
||||
showConnectionSkeleton.value = false;
|
||||
return null;
|
||||
}
|
||||
final timer = Timer(const Duration(seconds: 2), () {
|
||||
showConnectionBanner.value = true;
|
||||
showConnectionSkeleton.value = true;
|
||||
});
|
||||
return timer.cancel;
|
||||
}, [isReconnectingWithContent]);
|
||||
@@ -252,7 +255,7 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
channelsAsync: channelsAsync,
|
||||
showError: showError.value,
|
||||
sessionStatus: sessionState.status,
|
||||
showConnectionBanner: showConnectionBanner.value,
|
||||
showConnectionSkeleton: showConnectionSkeleton.value,
|
||||
currentPubkey: currentPubkey,
|
||||
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
|
||||
onSelectChannel: openChannel,
|
||||
|
||||
@@ -73,52 +73,6 @@ class _EphemeralBadge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionBanner extends StatelessWidget {
|
||||
final SessionStatus status;
|
||||
|
||||
const _ConnectionBanner({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (status == SessionStatus.connected ||
|
||||
status == SessionStatus.disconnected) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isConnecting = status == SessionStatus.connecting;
|
||||
final message = isConnecting ? 'Connecting…' : 'Reconnecting…';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.quarter + 2,
|
||||
),
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Text(
|
||||
message,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final Object error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@@ -5,7 +5,7 @@ class _ChannelsBody extends StatelessWidget {
|
||||
final AsyncValue<List<Channel>> channelsAsync;
|
||||
final bool showError;
|
||||
final SessionStatus sessionStatus;
|
||||
final bool showConnectionBanner;
|
||||
final bool showConnectionSkeleton;
|
||||
final String? currentPubkey;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
@@ -15,7 +15,7 @@ class _ChannelsBody extends StatelessWidget {
|
||||
required this.channelsAsync,
|
||||
required this.showError,
|
||||
required this.sessionStatus,
|
||||
required this.showConnectionBanner,
|
||||
required this.showConnectionSkeleton,
|
||||
required this.currentPubkey,
|
||||
required this.onRefresh,
|
||||
required this.onSelectChannel,
|
||||
@@ -24,59 +24,40 @@ class _ChannelsBody extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final barHeight = frostedAppBarHeight(context);
|
||||
|
||||
if (channels != null) {
|
||||
return Stack(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
final loadedChannels = channels;
|
||||
final loading =
|
||||
showConnectionSkeleton || (loadedChannels == null && !showError);
|
||||
final content = showError && channelsAsync.hasError
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(top: barHeight),
|
||||
child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh),
|
||||
)
|
||||
: loadedChannels == null
|
||||
? const SizedBox.shrink()
|
||||
: RefreshIndicator(
|
||||
edgeOffset: barHeight,
|
||||
onRefresh: onRefresh,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
|
||||
// Extra space for the connection banner when visible.
|
||||
if (showConnectionBanner)
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: _kBannerHeight),
|
||||
),
|
||||
_SliverChannelsList(
|
||||
channels: channels!,
|
||||
channels: loadedChannels,
|
||||
currentPubkey: currentPubkey,
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: barHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: showConnectionBanner
|
||||
? _ConnectionBanner(status: sessionStatus)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// The error view is gated on a grace timer in the parent — see the
|
||||
// useEffect in ChannelsPage. While the grace window is in flight we fall
|
||||
// through to the connection banner so transient relay-cancellation errors
|
||||
// don't flash the error UI.
|
||||
if (showError && channelsAsync.hasError) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: barHeight),
|
||||
child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: barHeight),
|
||||
child: _ConnectionBanner(
|
||||
status: sessionStatus == SessionStatus.connected
|
||||
? SessionStatus.connecting
|
||||
: sessionStatus,
|
||||
return SkeletonReveal(
|
||||
loading: loading,
|
||||
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
|
||||
skeleton: _ChannelsSkeleton(
|
||||
channels: loadedChannels,
|
||||
topInset: barHeight,
|
||||
status: sessionStatus,
|
||||
),
|
||||
content: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +475,7 @@ class _CommunityIndicator extends ConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_CommunityAvatar(name: name, relayUrl: activeCommunity?.relayUrl),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
const SizedBox(width: _kTopSectionLabelGap),
|
||||
if (name != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
@@ -511,7 +511,7 @@ class _CommunityAvatar extends ConsumerWidget {
|
||||
super.key,
|
||||
required this.name,
|
||||
this.relayUrl,
|
||||
this.size = 32,
|
||||
this.size = _kTopSectionAvatarSize,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
class _ChannelsSkeleton extends StatelessWidget {
|
||||
final List<Channel>? channels;
|
||||
final double topInset;
|
||||
final SessionStatus status;
|
||||
|
||||
const _ChannelsSkeleton({
|
||||
required this.channels,
|
||||
required this.topInset,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visibleChannels =
|
||||
channels
|
||||
?.where((channel) => channel.isMember && !channel.isArchived)
|
||||
.take(8)
|
||||
.toList() ??
|
||||
const <Channel>[];
|
||||
final widths = visibleChannels
|
||||
.map(
|
||||
(channel) => min(
|
||||
240,
|
||||
max(
|
||||
88,
|
||||
24 +
|
||||
(resolveDmChannelDisplayLabel(
|
||||
channel,
|
||||
currentPubkey: null,
|
||||
).length *
|
||||
8),
|
||||
),
|
||||
).toDouble(),
|
||||
)
|
||||
.toList();
|
||||
const fallbackWidths = <double>[136, 184, 112, 160, 208, 128];
|
||||
while (widths.length < 6) {
|
||||
widths.add(fallbackWidths[widths.length % fallbackWidths.length]);
|
||||
}
|
||||
final splitAt = min(4, widths.length);
|
||||
final firstSection = widths.take(splitAt).toList();
|
||||
final secondSection = widths.skip(splitAt).toList();
|
||||
|
||||
final semanticsLabel = switch (status) {
|
||||
SessionStatus.connecting => 'Connecting',
|
||||
SessionStatus.reconnecting => 'Reconnecting',
|
||||
SessionStatus.connected || SessionStatus.disconnected => 'Loading',
|
||||
};
|
||||
return Semantics(
|
||||
key: const Key('channels-connection-skeleton'),
|
||||
liveRegion: true,
|
||||
label: semanticsLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: ListView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
topInset + Grid.twelve,
|
||||
Grid.gutter,
|
||||
80,
|
||||
),
|
||||
children: [
|
||||
_ChannelSkeletonSection(widths: firstSection),
|
||||
const SizedBox(height: Grid.xs),
|
||||
_ChannelSkeletonSection(widths: secondSection),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelSkeletonSection extends StatelessWidget {
|
||||
final List<double> widths;
|
||||
|
||||
const _ChannelSkeletonSection({required this.widths});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SkeletonBar(width: 18, height: 18),
|
||||
),
|
||||
),
|
||||
SizedBox(width: Grid.xxs),
|
||||
SkeletonBar(
|
||||
key: Key('channels-skeleton-section-label'),
|
||||
width: 88,
|
||||
height: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var index = 0; index < widths.length; index++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: _kChannelRowVerticalPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SkeletonBar(
|
||||
width: _kChannelIconSize,
|
||||
height: _kChannelIconSize,
|
||||
borderRadius: BorderRadius.circular(
|
||||
index.isEven ? Radii.xs : Radii.full,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kChannelLabelGap),
|
||||
SkeletonBar(
|
||||
key: Key('channels-skeleton-row-label-$index'),
|
||||
width: widths[index],
|
||||
height: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,16 @@ import 'text_theme.dart';
|
||||
/// Desktop uses --radius: 0.625rem (10px) as base:
|
||||
/// lg = 10px, md = 8px, sm = 6px
|
||||
class Radii {
|
||||
/// Small radius for compact UI elements.
|
||||
static const double xs = 4.0;
|
||||
static const double lg = 10.0;
|
||||
static const double md = 8.0;
|
||||
static const double sm = 6.0;
|
||||
static const double card = 12.0; // grouped settings cards
|
||||
static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs
|
||||
|
||||
/// Fully rounds pills, circles, and other capsule shapes.
|
||||
static const double full = 999.0;
|
||||
}
|
||||
|
||||
class AppTheme {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import '../theme/theme.dart';
|
||||
|
||||
part 'skeleton_bar.dart';
|
||||
part 'skeleton_mask.dart';
|
||||
part 'skeleton_reveal.dart';
|
||||
part 'skeleton_shimmer.dart';
|
||||
|
||||
const _shimmerDuration = Duration(milliseconds: 2000);
|
||||
const _skeletonOpacity = 0.1;
|
||||
const _shimmerMinOpacity = 0.5;
|
||||
const _revealDuration = Duration(milliseconds: 400);
|
||||
const _revealBlur = 2.0;
|
||||
@@ -0,0 +1,33 @@
|
||||
part of 'skeleton.dart';
|
||||
|
||||
/// A single rounded placeholder within a [SkeletonShimmer].
|
||||
class SkeletonBar extends StatelessWidget {
|
||||
final double width;
|
||||
final double height;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
const SkeletonBar({
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.borderRadius,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExcludeSemantics(
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: _SkeletonMask.isActive(context)
|
||||
? Colors.white
|
||||
: context.colors.primary.withValues(alpha: _skeletonOpacity),
|
||||
borderRadius: borderRadius ?? BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
part of 'skeleton.dart';
|
||||
|
||||
class _SkeletonMask extends InheritedWidget {
|
||||
const _SkeletonMask({required super.child});
|
||||
|
||||
static bool isActive(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_SkeletonMask>() != null;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_SkeletonMask oldWidget) => false;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
part of 'skeleton.dart';
|
||||
|
||||
/// Stacks a skeleton and its content in the same slot, then reveals the content.
|
||||
///
|
||||
/// Entering the loading state is intentionally instant so reconnects do not
|
||||
/// animate backwards. Leaving it cross-fades both layers while the skeleton
|
||||
/// blurs out and the content sharpens over 400ms.
|
||||
class SkeletonReveal extends HookWidget {
|
||||
final bool loading;
|
||||
final Widget skeleton;
|
||||
final Widget content;
|
||||
final bool shimmerEnabled;
|
||||
|
||||
const SkeletonReveal({
|
||||
required this.loading,
|
||||
required this.skeleton,
|
||||
required this.content,
|
||||
this.shimmerEnabled = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final reveal = useAnimationController(
|
||||
duration: _revealDuration,
|
||||
initialValue: loading ? 0 : 1,
|
||||
);
|
||||
final previousLoading = usePrevious(loading);
|
||||
|
||||
useEffect(() {
|
||||
if (reducedMotion || previousLoading == null) {
|
||||
reveal
|
||||
..stop()
|
||||
..value = loading ? 0 : 1;
|
||||
} else if (loading) {
|
||||
// Reset directly to the loading state. Only the reveal animates.
|
||||
reveal
|
||||
..stop()
|
||||
..value = 0;
|
||||
} else if (previousLoading) {
|
||||
reveal.forward(from: 0);
|
||||
}
|
||||
return null;
|
||||
}, [loading, reducedMotion]);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: reveal,
|
||||
builder: (context, _) {
|
||||
final progress = reveal.value;
|
||||
final skeletonBlur = reducedMotion ? 0.0 : _revealBlur * progress;
|
||||
final contentBlur = reducedMotion ? 0.0 : _revealBlur * (1 - progress);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Opacity(
|
||||
key: const Key('skeleton-reveal-content'),
|
||||
opacity: progress,
|
||||
child: ImageFiltered(
|
||||
enabled: contentBlur > 0.01,
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: contentBlur,
|
||||
sigmaY: contentBlur,
|
||||
),
|
||||
child: IgnorePointer(
|
||||
ignoring: loading || progress < 1,
|
||||
child: ExcludeFocus(
|
||||
excluding: loading || progress < 1,
|
||||
child: ExcludeSemantics(
|
||||
excluding: loading || progress < 1,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
key: const Key('skeleton-reveal-placeholder'),
|
||||
opacity: 1 - progress,
|
||||
child: ImageFiltered(
|
||||
enabled: skeletonBlur > 0.01,
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: skeletonBlur,
|
||||
sigmaY: skeletonBlur,
|
||||
),
|
||||
child: IgnorePointer(
|
||||
child: ExcludeSemantics(
|
||||
excluding: !loading,
|
||||
child: SkeletonShimmer(
|
||||
enabled: loading && shimmerEnabled,
|
||||
child: skeleton,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
part of 'skeleton.dart';
|
||||
|
||||
/// Sweeps one shared highlight across a group of skeleton elements.
|
||||
///
|
||||
/// This mirrors desktop's two-second linear shimmer. Reduced-motion users see
|
||||
/// the same element shapes without the moving highlight.
|
||||
class SkeletonShimmer extends HookWidget {
|
||||
final Widget child;
|
||||
final bool enabled;
|
||||
|
||||
const SkeletonShimmer({required this.child, this.enabled = true, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final animation = useAnimationController(duration: _shimmerDuration);
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final colors = context.colors;
|
||||
final baseColor = colors.primary.withValues(alpha: _skeletonOpacity);
|
||||
final highlightColor = colors.primary.withValues(
|
||||
alpha: _skeletonOpacity * _shimmerMinOpacity,
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (reducedMotion || !enabled) {
|
||||
animation
|
||||
..stop()
|
||||
..value = 0;
|
||||
} else {
|
||||
animation.repeat();
|
||||
}
|
||||
return animation.stop;
|
||||
}, [animation, enabled, reducedMotion]);
|
||||
|
||||
if (reducedMotion || !enabled) {
|
||||
return _SkeletonMask(
|
||||
child: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(baseColor, BlendMode.srcIn),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _SkeletonMask(
|
||||
child: RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: child,
|
||||
builder: (context, child) {
|
||||
final center = -1.5 + (animation.value * 3);
|
||||
return ShaderMask(
|
||||
blendMode: BlendMode.srcIn,
|
||||
shaderCallback: (bounds) => LinearGradient(
|
||||
begin: Alignment(center - 1, 0),
|
||||
end: Alignment(center + 1, 0),
|
||||
colors: [
|
||||
baseColor,
|
||||
baseColor,
|
||||
highlightColor,
|
||||
baseColor,
|
||||
baseColor,
|
||||
],
|
||||
stops: const [0, 0.34, 0.5, 0.66, 1],
|
||||
).createShader(bounds),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _channelId = 'test-channel';
|
||||
@@ -152,6 +153,7 @@ Widget _buildTestable({
|
||||
String? initialThreadRootId,
|
||||
Map<String, List<NostrEvent>> threadReplies = const {},
|
||||
TextScaler textScaler = TextScaler.noScaling,
|
||||
RelaySessionNotifier? relaySessionNotifier,
|
||||
}) {
|
||||
final resolvedChannel = channel ?? _testChannel;
|
||||
final fakeChannelsNotifier =
|
||||
@@ -194,6 +196,8 @@ Widget _buildTestable({
|
||||
relayClientProvider.overrideWithValue(
|
||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||
),
|
||||
if (relaySessionNotifier != null)
|
||||
relaySessionProvider.overrideWith(() => relaySessionNotifier),
|
||||
// Compose bar drafts persist through SharedPreferences.
|
||||
savedPrefsProvider.overrideWithValue(_testPrefs),
|
||||
],
|
||||
@@ -247,6 +251,175 @@ void main() {
|
||||
});
|
||||
|
||||
group('ChannelDetailPage', () {
|
||||
testWidgets('debounces same-slot reconnect skeletons before revealing', (
|
||||
tester,
|
||||
) async {
|
||||
final relaySession = _ReconnectingRelaySession();
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_textMsg(id: 'msg1', pubkey: 'alice', content: 'Existing message'),
|
||||
],
|
||||
relaySessionNotifier: relaySession,
|
||||
readStateNotifier: _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: false,
|
||||
pubkey: 'self',
|
||||
contexts: {},
|
||||
version: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Existing message'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1999));
|
||||
expect(
|
||||
tester.widget<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
await tester.pump();
|
||||
final skeleton = find.byKey(
|
||||
const Key('channel-detail-connection-skeleton'),
|
||||
);
|
||||
expect(skeleton, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: skeleton, matching: find.byType(SkeletonBar)),
|
||||
findsWidgets,
|
||||
);
|
||||
expect(find.text('Existing message'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(
|
||||
find.byKey(const Key('skeleton-reveal-placeholder')),
|
||||
)
|
||||
.opacity,
|
||||
1,
|
||||
);
|
||||
|
||||
relaySession.connect();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.widget<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
|
||||
isFalse,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(
|
||||
find.byKey(const Key('skeleton-reveal-placeholder')),
|
||||
)
|
||||
.opacity,
|
||||
closeTo(0.5, 0.01),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
|
||||
.opacity,
|
||||
closeTo(0.5, 0.01),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
|
||||
.opacity,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows the first-load connection skeleton immediately', (
|
||||
tester,
|
||||
) async {
|
||||
final relaySession = _ReconnectingRelaySession();
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: _FakeMessagesNotifier(
|
||||
const [],
|
||||
hasLoadedMessages: false,
|
||||
),
|
||||
relaySessionNotifier: relaySession,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
tester.widget<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(
|
||||
find.byKey(const Key('skeleton-reveal-placeholder')),
|
||||
)
|
||||
.opacity,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Semantics>(
|
||||
find.byKey(const Key('channel-detail-connection-skeleton')),
|
||||
)
|
||||
.properties
|
||||
.label,
|
||||
'Reconnecting',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps forum content visible with reconnect shimmer feedback', (
|
||||
tester,
|
||||
) async {
|
||||
final relaySession = _ReconnectingRelaySession();
|
||||
final forumChannel = Channel(
|
||||
id: _channelId,
|
||||
name: 'design-forum',
|
||||
channelType: 'forum',
|
||||
visibility: 'open',
|
||||
description: 'Talk through design changes',
|
||||
createdBy: 'abc123',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 5,
|
||||
isMember: true,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
channel: forumChannel,
|
||||
relaySessionNotifier: relaySession,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(SkeletonReveal), findsNothing);
|
||||
expect(find.byKey(const Key('forum-connection-skeleton')), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1999));
|
||||
expect(find.byKey(const Key('forum-connection-skeleton')), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
await tester.pump();
|
||||
final skeleton = find.byKey(const Key('forum-connection-skeleton'));
|
||||
expect(skeleton, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: skeleton, matching: find.byType(SkeletonBar)),
|
||||
findsWidgets,
|
||||
);
|
||||
expect(find.byType(SkeletonReveal), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('defers read-state mark until after build', (tester) async {
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
@@ -1696,11 +1869,18 @@ Channel _channel({required String id, required String name}) => Channel(
|
||||
|
||||
class _FakeMessagesNotifier extends ChannelMessagesNotifier {
|
||||
List<NostrEvent> _messages;
|
||||
_FakeMessagesNotifier(this._messages) : super(_channelId);
|
||||
bool _hasLoadedMessages;
|
||||
|
||||
_FakeMessagesNotifier(this._messages, {bool hasLoadedMessages = true})
|
||||
: _hasLoadedMessages = hasLoadedMessages,
|
||||
super(_channelId);
|
||||
|
||||
@override
|
||||
AsyncValue<List<NostrEvent>> build() => AsyncData(_messages);
|
||||
|
||||
@override
|
||||
bool get hasLoadedMessages => _hasLoadedMessages;
|
||||
|
||||
@override
|
||||
bool get reachedOldest => true;
|
||||
|
||||
@@ -1709,6 +1889,7 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier {
|
||||
|
||||
void setMessages(List<NostrEvent> messages) {
|
||||
_messages = messages;
|
||||
_hasLoadedMessages = true;
|
||||
state = AsyncData(messages);
|
||||
}
|
||||
}
|
||||
@@ -1721,6 +1902,22 @@ class _ErrorMessagesNotifier extends ChannelMessagesNotifier {
|
||||
AsyncError('Connection failed', StackTrace.current);
|
||||
}
|
||||
|
||||
class _ReconnectingRelaySession extends RelaySessionNotifier {
|
||||
@override
|
||||
SessionState build() =>
|
||||
const SessionState(status: SessionStatus.reconnecting);
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [];
|
||||
|
||||
void connect() {
|
||||
state = const SessionState(status: SessionStatus.connected);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeTypingNotifier extends ChannelTypingNotifier {
|
||||
final List<TypingEntry> _entries;
|
||||
_FakeTypingNotifier(this._entries) : super(_channelId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -16,8 +17,10 @@ import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/community/community_icon_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/avatar_image.dart';
|
||||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||||
|
||||
void main() {
|
||||
Widget buildTestable({
|
||||
@@ -130,6 +133,141 @@ void main() {
|
||||
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('aligns the top, section, row, and skeleton label columns', (
|
||||
tester,
|
||||
) async {
|
||||
final relaySession = _ReconnectingRelaySession();
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
],
|
||||
),
|
||||
);
|
||||
relaySession.connect();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final topLabelX = tester.getTopLeft(find.text('Community')).dx;
|
||||
final sectionLabelX = tester.getTopLeft(find.text('Channels')).dx;
|
||||
final rowLabelX = tester.getTopLeft(find.text('general')).dx;
|
||||
expect(topLabelX, sectionLabelX);
|
||||
expect(sectionLabelX, rowLabelX);
|
||||
|
||||
relaySession.setReconnecting();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump();
|
||||
|
||||
final skeletonSectionLabelX = tester
|
||||
.getTopLeft(
|
||||
find.byKey(const Key('channels-skeleton-section-label')).first,
|
||||
)
|
||||
.dx;
|
||||
final skeletonRowLabelX = tester
|
||||
.getTopLeft(
|
||||
find.byKey(const Key('channels-skeleton-row-label-0')).first,
|
||||
)
|
||||
.dx;
|
||||
expect(skeletonSectionLabelX, skeletonRowLabelX);
|
||||
expect(skeletonSectionLabelX, sectionLabelX);
|
||||
});
|
||||
|
||||
testWidgets('reveals channel content from same-slot reconnect skeletons', (
|
||||
tester,
|
||||
) async {
|
||||
final relaySession = _ReconnectingRelaySession();
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump();
|
||||
|
||||
final skeleton = find.byKey(const Key('channels-connection-skeleton'));
|
||||
expect(skeleton, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: skeleton, matching: find.byType(SkeletonBar)),
|
||||
findsWidgets,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: skeleton,
|
||||
matching: find.byType(CircularProgressIndicator),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-placeholder')))
|
||||
.opacity,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
|
||||
.opacity,
|
||||
0,
|
||||
);
|
||||
|
||||
relaySession.connect();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.widget<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
|
||||
isFalse,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-placeholder')))
|
||||
.opacity,
|
||||
closeTo(0.5, 0.01),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
|
||||
.opacity,
|
||||
closeTo(0.5, 0.01),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.text('general'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('announces neutral loading outside connection transitions', (
|
||||
tester,
|
||||
) async {
|
||||
final relaySession = _ReconnectingRelaySession(
|
||||
initialStatus: SessionStatus.connected,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _LoadingNotifier()),
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<Semantics>(
|
||||
find.byKey(const Key('channels-connection-skeleton')),
|
||||
)
|
||||
.properties
|
||||
.label,
|
||||
'Loading',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('opens the settings page supplied by the app layer', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -1261,6 +1399,41 @@ class _ErrorNotifier extends ChannelsNotifier {
|
||||
Future<List<Channel>> build() => Future.error('Connection refused');
|
||||
}
|
||||
|
||||
class _LoadingNotifier extends ChannelsNotifier {
|
||||
@override
|
||||
Future<List<Channel>> build() => Completer<List<Channel>>().future;
|
||||
}
|
||||
|
||||
class _ReconnectingRelaySession extends RelaySessionNotifier {
|
||||
final SessionStatus initialStatus;
|
||||
|
||||
_ReconnectingRelaySession({this.initialStatus = SessionStatus.reconnecting});
|
||||
|
||||
@override
|
||||
SessionState build() => SessionState(status: initialStatus);
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [];
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async => () {};
|
||||
|
||||
void connect() {
|
||||
state = const SessionState(status: SessionStatus.connected);
|
||||
}
|
||||
|
||||
void setReconnecting() {
|
||||
state = const SessionState(status: SessionStatus.reconnecting);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeProfileNotifier extends ProfileNotifier {
|
||||
@override
|
||||
Future<UserProfile?> build() async =>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
Widget buildShimmerTestable({
|
||||
bool disableAnimations = false,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: disableAnimations),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: SkeletonShimmer(
|
||||
enabled: enabled,
|
||||
child: const SkeletonBar(width: 120, height: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildRevealTestable({
|
||||
required ValueNotifier<bool> loading,
|
||||
bool disableAnimations = false,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: disableAnimations),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: loading,
|
||||
builder: (context, isLoading, _) => SkeletonReveal(
|
||||
loading: isLoading,
|
||||
skeleton: const SkeletonBar(width: 120, height: 16),
|
||||
content: const Text('Loaded content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double layerOpacity(WidgetTester tester, String key) =>
|
||||
tester.widget<Opacity>(find.byKey(Key(key))).opacity;
|
||||
|
||||
bool contentFocusExcluded(WidgetTester tester) => tester
|
||||
.widget<ExcludeFocus>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('skeleton-reveal-content')),
|
||||
matching: find.byType(ExcludeFocus),
|
||||
),
|
||||
)
|
||||
.excluding;
|
||||
|
||||
testWidgets('sweeps a highlight across skeleton elements while loading', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(buildShimmerTestable());
|
||||
|
||||
final shimmer = find.byType(SkeletonShimmer);
|
||||
expect(find.byType(SkeletonBar), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find
|
||||
.descendant(of: shimmer, matching: find.byType(RepaintBoundary))
|
||||
.first,
|
||||
);
|
||||
final beforeBytes = await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
return image.toByteData();
|
||||
});
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1000));
|
||||
final afterBytes = await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
return image.toByteData();
|
||||
});
|
||||
|
||||
expect(
|
||||
beforeBytes!.buffer.asUint8List(),
|
||||
isNot(equals(afterBytes!.buffer.asUint8List())),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps skeleton elements static for reduced motion', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(buildShimmerTestable(disableAnimations: true));
|
||||
|
||||
final shimmer = find.byType(SkeletonShimmer);
|
||||
expect(find.byType(SkeletonBar), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps skeleton elements static when shimmer is disabled', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(buildShimmerTestable(enabled: false));
|
||||
|
||||
final shimmer = find.byType(SkeletonShimmer);
|
||||
expect(find.byType(SkeletonBar), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('reveals content in the same slot with a 400ms cross-fade', (
|
||||
tester,
|
||||
) async {
|
||||
final loading = ValueNotifier(true);
|
||||
await tester.pumpWidget(buildRevealTestable(loading: loading));
|
||||
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 1);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 0);
|
||||
expect(contentFocusExcluded(tester), isTrue);
|
||||
|
||||
loading.value = false;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
layerOpacity(tester, 'skeleton-reveal-placeholder'),
|
||||
closeTo(0.5, 0.01),
|
||||
);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), closeTo(0.5, 0.01));
|
||||
expect(contentFocusExcluded(tester), isTrue);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 0);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
|
||||
expect(contentFocusExcluded(tester), isFalse);
|
||||
expect(find.text('Loaded content'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('resets to the skeleton without animating backwards', (
|
||||
tester,
|
||||
) async {
|
||||
final loading = ValueNotifier(false);
|
||||
await tester.pumpWidget(buildRevealTestable(loading: loading));
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
|
||||
|
||||
loading.value = true;
|
||||
await tester.pump();
|
||||
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 1);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 0);
|
||||
});
|
||||
|
||||
testWidgets('reveals immediately for reduced motion', (tester) async {
|
||||
final loading = ValueNotifier(true);
|
||||
await tester.pumpWidget(
|
||||
buildRevealTestable(loading: loading, disableAnimations: true),
|
||||
);
|
||||
final reveal = find.byType(SkeletonReveal);
|
||||
expect(
|
||||
find.descendant(of: reveal, matching: find.byType(ShaderMask)),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
loading.value = false;
|
||||
await tester.pump();
|
||||
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 0);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user