mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): frosted glass navbar + channel detail polish (#404)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<FeedItem> _filteredItems(HomeFeedResponse feed, _Filter filter) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<Channel>? channels;
|
||||
final AsyncValue<List<Channel>> channelsAsync;
|
||||
final SessionStatus sessionStatus;
|
||||
final String? currentPubkey;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Future<void> 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<Channel> channels;
|
||||
final String? currentPubkey;
|
||||
final Future<void> 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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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<Widget> 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,7 @@ void main() {
|
||||
Widget buildTestable({required List<Override> overrides}) {
|
||||
return ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.lightTheme,
|
||||
home: const ActivityPage(),
|
||||
),
|
||||
child: MaterialApp(theme: AppTheme.light(), home: const ActivityPage()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,7 @@ class WidgetHelpers {
|
||||
return ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.lightTheme,
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user