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:
klopez4212
2026-07-27 19:57:07 +01:00
committed by GitHub
parent 4d8b676bb2
commit 01c23810fa
17 changed files with 1221 additions and 183 deletions
@@ -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);
+15 -12
View File
@@ -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,
),
],
),
),
],
);
}
}
+5
View File
@@ -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 {
+17
View File
@@ -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);
});
}