diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index c3f46d65f..88d84bd1f 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channels_provider.dart'; @@ -83,7 +85,16 @@ class ActivityPage extends HookConsumerWidget { body = const _LoadingSkeleton(); } - return Scaffold(body: SafeArea(child: body)); + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Activity')), + body: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: body, + ), + ), + ); } List _filteredItems(HomeFeedResponse feed, _Filter filter) { diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index c6cde15f2..c26615a35 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -7,6 +7,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/profile_provider.dart'; import '../profile/user_cache_provider.dart'; @@ -92,8 +94,8 @@ class ChannelDetailPage extends HookConsumerWidget { return null; }, [channel.id]); - return Scaffold( - appBar: AppBar( + return FrostedScaffold( + appBar: FrostedAppBar( title: resolvedChannel.isDm ? _DmAppBarTitle( channel: resolvedChannel, @@ -108,11 +110,28 @@ class ChannelDetailPage extends HookConsumerWidget { ), const SizedBox(width: Grid.half), Expanded( - child: Text( - resolvedChannel.displayLabel( - currentPubkey: currentPubkey, - ), - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + resolvedChannel.displayLabel( + currentPubkey: currentPubkey, + ), + overflow: TextOverflow.ellipsis, + ), + if (resolvedChannel.isStream) + Text( + resolvedChannel.description.isNotEmpty + ? resolvedChannel.description + : '${resolvedChannel.memberCount} member${resolvedChannel.memberCount == 1 ? '' : 's'}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ], @@ -153,9 +172,6 @@ class ChannelDetailPage extends HookConsumerWidget { ), body: Column( children: [ - _DetailConnectionBanner( - status: ref.watch(relaySessionProvider).status, - ), Expanded( child: resolvedChannel.isForum ? ForumPostsView( @@ -163,13 +179,22 @@ class ChannelDetailPage extends HookConsumerWidget { currentPubkey: currentPubkey, ) : messagesState.when( - loading: () => - const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text( - 'Failed to load messages', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, + loading: () => Padding( + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + ), + child: const Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + ), + child: Center( + child: Text( + 'Failed to load messages', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), ), ), ), @@ -190,6 +215,9 @@ class ChannelDetailPage extends HookConsumerWidget { }, ), ), + _DetailConnectionBanner( + status: ref.watch(relaySessionProvider).status, + ), if (!resolvedChannel.isForum && typingEntries.isNotEmpty) _TypingIndicator(entries: typingEntries), if (!resolvedChannel.isForum && @@ -304,9 +332,11 @@ class _MessageList extends HookConsumerWidget { return ListView.builder( controller: scrollController, reverse: true, - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.xxs, + padding: EdgeInsets.only( + left: Grid.xs, + right: Grid.xs, + top: frostedAppBarHeight(context), + bottom: Grid.xxs, ), itemCount: entries.length + (isLoadingOlder.value ? 1 : 0), itemBuilder: (context, index) { @@ -1065,9 +1095,7 @@ class _DmAppBarTitle extends ConsumerWidget { }, shape: BoxShape.circle, border: Border.all( - color: - context.theme.appBarTheme.backgroundColor ?? - context.theme.scaffoldBackgroundColor, + color: context.colors.surface, width: 1.5, ), ), @@ -1080,6 +1108,7 @@ class _DmAppBarTitle extends ConsumerWidget { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( channel.displayLabel(currentPubkey: currentPubkey), @@ -1089,8 +1118,8 @@ class _DmAppBarTitle extends ConsumerWidget { ), Text( presenceLabel, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, ), ), ], diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 94396fae7..3dccffbdf 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -8,6 +8,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; import '../settings/settings_page.dart'; @@ -20,6 +22,10 @@ import 'channels_provider.dart'; enum _QuickAction { createChannel, createForum, 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; + class ChannelsPage extends HookConsumerWidget { const ChannelsPage({super.key}); @@ -90,15 +96,13 @@ class ChannelsPage extends HookConsumerWidget { } } - return Scaffold( - appBar: AppBar( - titleSpacing: Grid.xs, - title: Text( - '\u{1F331} Sprout', - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + return FrostedScaffold( + appBar: FrostedAppBar( + leading: const Padding( + padding: EdgeInsets.only(left: Grid.xs), + child: Text('\u{1F331}', style: TextStyle(fontSize: 28)), ), + title: const SizedBox.shrink(), actions: [ ProfileAvatar( onTap: () => Navigator.of(context).push( @@ -114,35 +118,91 @@ class ChannelsPage extends HookConsumerWidget { shape: const CircleBorder(), child: const Icon(LucideIcons.plus), ), - body: channels != null - ? Column( - children: [ - _ConnectionBanner(status: sessionState.status), - Expanded( - child: _ChannelsList( - channels: channels, - currentPubkey: currentPubkey, - onSelectChannel: openChannel, - ), - ), - ], - ) - : channelsAsync.hasError - ? _ErrorView( - error: channelsAsync.error!, - onRetry: () => ref.read(channelsProvider.notifier).refresh(), - ) - : const _ConnectionBanner(status: SessionStatus.connecting), + body: _ChannelsBody( + channels: channels, + channelsAsync: channelsAsync, + sessionStatus: sessionState.status, + currentPubkey: currentPubkey, + onRefresh: () => ref.read(channelsProvider.notifier).refresh(), + onSelectChannel: openChannel, + ), ); } } -class _ChannelsList extends HookConsumerWidget { +class _ChannelsBody extends StatelessWidget { + final List? channels; + final AsyncValue> channelsAsync; + final SessionStatus sessionStatus; + final String? currentPubkey; + final Future Function() onRefresh; + final Future Function(Channel channel) onSelectChannel; + + const _ChannelsBody({ + required this.channels, + required this.channelsAsync, + required this.sessionStatus, + required this.currentPubkey, + required this.onRefresh, + required this.onSelectChannel, + }); + + @override + Widget build(BuildContext context) { + final barHeight = frostedAppBarHeight(context); + + if (channels != null) { + return Stack( + children: [ + RefreshIndicator( + onRefresh: onRefresh, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: barHeight)), + // Extra space for the connection banner when visible. + if (sessionStatus != SessionStatus.connected && + sessionStatus != SessionStatus.disconnected) + const SliverToBoxAdapter( + child: SizedBox(height: _kBannerHeight), + ), + _SliverChannelsList( + channels: channels!, + currentPubkey: currentPubkey, + onSelectChannel: onSelectChannel, + ), + ], + ), + ), + Positioned( + top: barHeight, + left: 0, + right: 0, + child: _ConnectionBanner(status: sessionStatus), + ), + ], + ); + } + + if (channelsAsync.hasError) { + return Padding( + padding: EdgeInsets.only(top: barHeight), + child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh), + ); + } + + return Padding( + padding: EdgeInsets.only(top: barHeight), + child: const _ConnectionBanner(status: SessionStatus.connecting), + ); + } +} + +class _SliverChannelsList extends HookConsumerWidget { final List channels; final String? currentPubkey; final Future Function(Channel channel) onSelectChannel; - const _ChannelsList({ + const _SliverChannelsList({ required this.channels, required this.currentPubkey, required this.onSelectChannel, @@ -167,10 +227,9 @@ class _ChannelsList extends HookConsumerWidget { final forumsExpanded = useState(true); final dmsExpanded = useState(true); - return RefreshIndicator( - onRefresh: () => ref.read(channelsProvider.notifier).refresh(), - child: ListView( - padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80), + return SliverPadding( + padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80), + sliver: SliverList.list( children: [ if (visibleChannels.isEmpty) const _EmptyState() diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fd5550c3a..818722483 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_management_provider.dart'; @@ -86,15 +88,17 @@ class ThreadDetailPage extends HookConsumerWidget { } }); - return Scaffold( - appBar: AppBar(title: const Text('Thread')), + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Thread')), body: Column( children: [ Expanded( child: ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.xxs, + padding: EdgeInsets.only( + left: Grid.xs, + right: Grid.xs, + top: frostedAppBarHeight(context), + bottom: Grid.xxs, ), itemCount: replies.length + 1, // +1 for thread head itemBuilder: (context, index) { diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index 4f13c7325..708c5ea31 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; import '../channels/channel.dart'; import '../channels/compose_bar.dart'; import 'forum_models.dart'; @@ -57,12 +58,18 @@ class ForumPostsView extends HookConsumerWidget { ) : null, body: postsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text( - 'Failed to load posts', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, + loading: () => Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: const Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: Center( + child: Text( + 'Failed to load posts', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), ), ), ), @@ -80,7 +87,12 @@ class ForumPostsView extends HookConsumerWidget { await ref.read(forumPostsProvider(channel.id).future); }, child: ListView.separated( - padding: const EdgeInsets.all(Grid.xs), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + left: Grid.xs, + right: Grid.xs, + bottom: Grid.xs, + ), itemCount: posts.length, separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs), diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index cadaa45f0..473b4341c 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -5,6 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/compose_bar.dart'; import '../channels/message_content.dart'; import '../profile/user_cache_provider.dart'; @@ -55,8 +57,8 @@ class ForumThreadPage extends HookConsumerWidget { .value ?? false; - return Scaffold( - appBar: AppBar( + return FrostedScaffold( + appBar: FrostedAppBar( title: const Text('Thread'), actions: [ if (isOwnPost) @@ -69,12 +71,18 @@ class ForumThreadPage extends HookConsumerWidget { ], ), body: threadAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text( - 'Failed to load thread', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, + loading: () => Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: const Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: Center( + child: Text( + 'Failed to load thread', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), ), ), ), @@ -208,7 +216,10 @@ class _ThreadContent extends HookConsumerWidget { children: [ Expanded( child: ListView( - padding: const EdgeInsets.only(bottom: Grid.xs), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + bottom: Grid.xs, + ), children: [ // Original post _OriginalPost(post: post), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 7fdfcdcc4..d5243649c 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; @@ -34,9 +36,9 @@ class SearchPage extends HookConsumerWidget { () => textController.text.isNotEmpty, ); - return Scaffold( - appBar: AppBar( - titleSpacing: Grid.xs, + return FrostedScaffold( + resizeToAvoidBottomInset: true, + appBar: FrostedAppBar( title: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: Grid.half), @@ -77,6 +79,7 @@ class SearchPage extends HookConsumerWidget { ), body: Column( children: [ + SizedBox(height: frostedAppBarHeight(context)), _FilterChips( active: activeFilter.value, onChanged: (f) => activeFilter.value = f, diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 10e3b4d4e..7a0fac434 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -7,6 +7,8 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import 'theme_picker_page.dart'; class SettingsPage extends HookConsumerWidget { @@ -18,10 +20,15 @@ class SettingsPage extends HookConsumerWidget { final selectedAccent = ref.watch(accentProvider); final selectedScheme = ref.watch(schemeProvider); - return Scaffold( - appBar: AppBar(title: const Text('Settings')), + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Settings')), body: ListView( - padding: const EdgeInsets.all(Grid.xs), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + left: Grid.xs, + right: Grid.xs, + bottom: Grid.xs, + ), children: [ // Connection info Text('Connection', style: context.textTheme.titleMedium), diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index 755af24e8..e1bf81cf6 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; class ThemePickerPage extends HookConsumerWidget { const ThemePickerPage({super.key}); @@ -64,10 +66,11 @@ class ThemePickerPage extends HookConsumerWidget { return null; }, const []); - return Scaffold( - appBar: AppBar(title: const Text('Color Scheme')), + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Color Scheme')), body: Column( children: [ + SizedBox(height: frostedAppBarHeight(context)), // Always-visible search bar Padding( padding: const EdgeInsets.symmetric( diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index c9d8d5951..05579deb1 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -53,10 +53,6 @@ class AppTheme { ); } - /// Legacy getters for backwards compatibility. - static ThemeData get lightTheme => light(); - static ThemeData get darkTheme => dark(); - static ThemeData _buildTheme({ required ColorScheme scheme, required AppColors appColors, @@ -71,10 +67,11 @@ class AppTheme { fontFamily: 'Geist', textTheme: textTheme, appBarTheme: AppBarTheme( - backgroundColor: scheme.surface, + backgroundColor: Colors.transparent, foregroundColor: scheme.onSurface, surfaceTintColor: Colors.transparent, elevation: 0, + scrolledUnderElevation: 0, titleTextStyle: textTheme.titleMedium?.copyWith( color: scheme.onSurface, ), diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart new file mode 100644 index 000000000..a6dd0cf11 --- /dev/null +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -0,0 +1,111 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../theme/theme.dart'; + +/// Height of the frosted app bar content area below the safe area. +const _kBarContentHeight = Grid.xxs + 32 + Grid.xxs; // 48 + +/// Returns the total height of the [FrostedAppBar] including safe area padding. +/// +/// Use this to add top spacing to body content so it starts below the bar. +double frostedAppBarHeight(BuildContext context) { + return MediaQuery.paddingOf(context).top + _kBarContentHeight; +} + +/// A frosted-glass floating app bar designed to sit inside a [Stack]. +/// +/// Renders as a [Positioned] widget pinned to the top of its parent Stack. +/// Content scrolls underneath with a translucent backdrop blur effect. +class FrostedAppBar extends StatelessWidget { + /// Widget displayed on the leading (left) side. If null and the navigator + /// can pop, a back button is shown automatically. + final Widget? leading; + + /// Widget displayed in the center/title area. + final Widget? title; + + /// Widgets displayed on the trailing (right) side. + final List actions; + + const FrostedAppBar({ + super.key, + this.leading, + this.title, + this.actions = const [], + }); + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.paddingOf(context).top; + final canPop = Navigator.canPop(context); + + final effectiveLeading = + leading ?? + (canPop + ? SizedBox( + width: 48, + height: 48, + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(LucideIcons.chevronLeft), + tooltip: 'Back', + ), + ) + : null); + + return Positioned( + top: 0, + left: 0, + right: 0, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: EdgeInsets.only(top: topPadding), + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.5), + border: Border( + bottom: BorderSide( + color: context.colors.outlineVariant.withValues(alpha: 0.3), + ), + ), + ), + child: SizedBox( + height: _kBarContentHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), + child: Row( + children: [ + ?effectiveLeading, + if (title != null) + Expanded( + child: Padding( + padding: EdgeInsets.only( + left: effectiveLeading != null ? 0 : Grid.xs, + ), + child: DefaultTextStyle.merge( + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + child: title!, + ), + ), + ) + else + const Spacer(), + ...actions, + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart new file mode 100644 index 000000000..6b7e39fd7 --- /dev/null +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'frosted_app_bar.dart'; + +/// A convenience [Scaffold] that overlays a [FrostedAppBar] on top of its body. +/// +/// The body is rendered full-bleed inside a [Stack] with the frosted app bar +/// floating above it. The body is responsible for adding its own top spacing +/// using [frostedAppBarHeight] so content starts below the bar. +class FrostedScaffold extends StatelessWidget { + /// The frosted app bar displayed at the top of the screen. + final FrostedAppBar appBar; + + /// The primary content of the scaffold. Must handle its own top spacing + /// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding. + final Widget body; + + /// Optional floating action button, passed through to [Scaffold]. + final Widget? floatingActionButton; + + /// Whether the body should resize when the on-screen keyboard appears. + final bool? resizeToAvoidBottomInset; + + const FrostedScaffold({ + super.key, + required this.appBar, + required this.body, + this.floatingActionButton, + this.resizeToAvoidBottomInset, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + floatingActionButton: floatingActionButton, + body: Stack(children: [body, appBar]), + ); + } +} diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index c4aa7c311..bb4100cd2 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -17,10 +17,7 @@ void main() { Widget buildTestable({required List overrides}) { return ProviderScope( overrides: overrides, - child: MaterialApp( - theme: AppTheme.lightTheme, - home: const ActivityPage(), - ), + child: MaterialApp(theme: AppTheme.light(), home: const ActivityPage()), ); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index ce449ee0d..6834679fe 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -156,7 +156,7 @@ Widget _buildTestable({ ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), navigatorObservers: navigatorObservers, home: ChannelDetailPage(channel: resolvedChannel), ), @@ -949,7 +949,7 @@ void main() { ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: ChannelDetailPage(channel: _testChannel), ), ), diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 5c1955b2c..ce77e8a84 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -18,10 +18,7 @@ void main() { presenceProvider.overrideWith(() => _FakePresenceNotifier()), ...overrides, ], - child: MaterialApp( - theme: AppTheme.lightTheme, - home: const ChannelsPage(), - ), + child: MaterialApp(theme: AppTheme.light(), home: const ChannelsPage()), ); } @@ -79,7 +76,7 @@ void main() { expect(find.text('CHANNELS'), findsOneWidget); expect(find.text('FORUMS'), findsOneWidget); expect(find.text('DMS'), findsOneWidget); - expect(find.text('\u{1F331} Sprout'), findsOneWidget); + expect(find.text('\u{1F331}'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget); }); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index ca328dc9c..65d8e16cb 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -120,7 +120,7 @@ Widget _buildComposeBar({ ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold( body: SafeArea( child: ComposeBar(channelId: 'channel-1', onSend: onSend), diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 50658d88d..4f8a3a058 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -7,7 +7,7 @@ import 'package:sprout_mobile/shared/theme/theme.dart'; Widget _testable(Widget child) { return MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold(body: child), ); } diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 44948e121..464c2f713 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -74,7 +74,7 @@ Widget _buildPostCard({ userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold( body: ForumPostCard( post: post, @@ -103,7 +103,7 @@ Widget _buildPostsView({ ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold( body: ForumPostsView(channel: ch, currentPubkey: 'self'), ), @@ -132,7 +132,7 @@ Widget _buildThreadPage({ ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: ForumThreadPage( channelId: _channelId, postEventId: postEventId, diff --git a/mobile/test/helpers/widget_helpers.dart b/mobile/test/helpers/widget_helpers.dart index f51acf751..543f5e9dd 100644 --- a/mobile/test/helpers/widget_helpers.dart +++ b/mobile/test/helpers/widget_helpers.dart @@ -11,7 +11,7 @@ class WidgetHelpers { return ProviderScope( overrides: overrides, child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold(body: child), ), );