Refine mobile settings and themes (#2844)

## Summary
- reorganize mobile settings around profile, appearance, and connection
cards
- add System/Light/Dark theme pairing, accent selection, and the Buzz
gradient theme
- align avatar badges, status editing, and supporting mobile chrome

## Test plan
- `just mobile-check`
- `just mobile-test`
This commit is contained in:
klopez4212
2026-07-26 08:57:31 +01:00
committed by GitHub
parent c2a4ee711e
commit dd222a509b
50 changed files with 3037 additions and 776 deletions
+29 -6
View File
@@ -10,6 +10,8 @@ import 'features/pairing/pairing_page.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
import 'features/channels/deep_link_dispatcher.dart';
import 'features/profile/user_status_cache_provider.dart';
import 'features/profile/settings_profile_header.dart';
import 'features/settings/settings_page.dart';
import 'shared/auth/auth.dart';
import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/relay/relay.dart';
@@ -25,13 +27,25 @@ class App extends HookConsumerWidget {
final schemeName = ref.watch(schemeProvider);
final authState = ref.watch(authProvider);
final resolved = resolveSchemes(schemeName);
final resolved = resolveSchemes(schemeName, themeMode);
final lightScheme = applyAccent(resolved.light, accentIndex);
final darkScheme = applyAccent(resolved.dark, accentIndex);
// Default and named schemes can force light or dark mode; otherwise
// respect the user's ThemeMode preference.
// Light/Dark modes pin the brightness; System leaves it null so Flutter
// follows the OS across the selected theme and its pair.
final effectiveMode = resolved.forcedMode ?? themeMode;
// Derive the gradient from the themes that produced each color scheme.
// This keeps fallbacks and pinned brightness changes aligned with the
// rendered palette rather than the raw persisted selection.
final buzzLightGradient = buzzTopSectionGradient(
resolved.lightTheme?.name ?? '',
lightScheme.brightness,
);
final buzzDarkGradient = buzzTopSectionGradient(
resolved.darkTheme?.name ?? '',
darkScheme.brightness,
);
// Eagerly initialize websocket session and lifecycle observer when
// authenticated. These providers connect and manage the websocket.
if (authState.value?.status == AuthStatus.authenticated) {
@@ -65,15 +79,21 @@ class App extends HookConsumerWidget {
return MaterialApp(
title: 'Buzz',
theme: AppTheme.light(colorScheme: lightScheme),
darkTheme: AppTheme.dark(colorScheme: darkScheme),
theme: AppTheme.light(
colorScheme: lightScheme,
topSectionGradient: buzzLightGradient,
),
darkTheme: AppTheme.dark(
colorScheme: darkScheme,
topSectionGradient: buzzDarkGradient,
),
themeMode: effectiveMode,
home: authState.when(
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => const DeepLinkDispatcher(
child: HomePage(),
child: HomePage(settingsPageBuilder: _buildSettingsPage),
),
_ => const DeepLinkDispatcher(
dispatchMessageLinks: false,
@@ -85,6 +105,9 @@ class App extends HookConsumerWidget {
}
}
Widget _buildSettingsPage(BuildContext context) =>
const SettingsPage(profileHeader: SettingsProfileHeader());
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@@ -5,10 +5,10 @@ import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/auth/auth.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/relay/relay.dart';
import '../profile/profile_provider.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import 'channel.dart';
import 'channels_provider.dart';
@@ -15,12 +15,11 @@ 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 '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../profile/profile_avatar.dart';
import '../profile/profile_provider.dart';
import '../settings/settings_page.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/user_cache_provider.dart';
import '../pairing/pairing_page.dart';
@@ -62,6 +61,19 @@ const double _kChannelLabelGap = Grid.xxs;
const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter;
const double _kChannelLabelInset =
_kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap;
/// DM avatars are circles, so they fill their box edge to edge where a channel
/// glyph leaves 4dp of slack inside the same 22dp leading column. Sizing them to
/// the glyph's ink width keeps the icon-to-label distance identical across both
/// sections while the labels stay on [_kChannelLabelInset].
const double _kDmAvatarSize = _kChannelIconSize;
/// 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).
const double _kTopSectionInset = Grid.twelve;
const Duration _kSectionExpandDuration = Duration(milliseconds: 220);
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
@@ -129,7 +141,9 @@ _UnreadChannelState _computeUnreadChannelState({
}
class ChannelsPage extends HookConsumerWidget {
const ChannelsPage({super.key});
const ChannelsPage({required this.settingsPageBuilder, super.key});
final WidgetBuilder settingsPageBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -208,7 +222,11 @@ class ChannelsPage extends HookConsumerWidget {
return FrostedScaffold(
appBar: FrostedAppBar(
horizontalInset: _kChannelSectionInset,
horizontalInset: _kTopSectionInset,
// Under a Buzz theme the community + account avatar strip carries the
// branded gradient, the way desktop paints it across the sidebar. Null
// under every other theme, leaving the default frosted fill.
gradient: context.appColors.topSectionGradient,
leading: _CommunityIndicator(
onTap: () => showModalBottomSheet<void>(
context: context,
@@ -219,9 +237,9 @@ class ChannelsPage extends HookConsumerWidget {
title: const SizedBox.shrink(),
actions: [
ProfileAvatar(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsPage()),
),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: settingsPageBuilder)),
),
],
),
@@ -41,20 +41,19 @@ class _ChannelTile extends ConsumerWidget {
),
child: Row(
children: [
if (channel.isDm)
_DmAvatar(channel: channel, currentPubkey: currentPubkey)
else
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
channelIcon(channel),
size: _kChannelIconSize,
color: context.colors.onSurface,
),
),
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: channel.isDm
? _DmAvatar(channel: channel, currentPubkey: currentPubkey)
: Icon(
channelIcon(channel),
size: _kChannelIconSize,
color: context.colors.onSurface,
),
),
),
const SizedBox(width: _kChannelLabelGap),
Expanded(
child: Column(
@@ -343,8 +342,8 @@ class _DmAvatar extends ConsumerWidget {
if (visiblePubkeys.length > 1) {
return Container(
width: 22,
height: 22,
width: _kDmAvatarSize,
height: _kDmAvatarSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
@@ -354,7 +353,7 @@ class _DmAvatar extends ConsumerWidget {
child: Text(
'${visiblePubkeys.length}',
style: context.textTheme.labelSmall?.copyWith(
fontSize: 10,
fontSize: 9,
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
height: 1,
@@ -385,14 +384,14 @@ class _DmAvatar extends ConsumerWidget {
: 'offline';
return SizedBox(
width: 22,
height: 22,
width: _kDmAvatarSize,
height: _kDmAvatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
AvatarImage(
imageUrl: avatarUrl,
radius: 10,
radius: _kDmAvatarSize / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
@@ -407,8 +406,8 @@ class _DmAvatar extends ConsumerWidget {
right: -1,
bottom: -1,
child: Container(
width: 9,
height: 9,
width: 8,
height: 8,
decoration: BoxDecoration(
color: _presenceColor(context, presence),
shape: BoxShape.circle,
@@ -17,8 +17,8 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import 'camera_capture_cleanup.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
@@ -3,9 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
/// Opens the full emoji picker as a modal bottom sheet.
void showEmojiPicker({
@@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import 'channel_management_provider.dart';
import 'emoji_picker.dart';
import 'thread_detail_page.dart';
@@ -15,9 +15,9 @@ import '../../shared/clipboard_utils.dart';
import '../../shared/relay/relay.dart';
import '../../shared/syntax_highlight.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import 'media_viewer_page.dart';
import 'message_media.dart';
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../custom_emoji/custom_emoji_render.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_management_provider.dart';
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../shared/relay/relay.dart';
import '../custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import 'channel_window.dart';
enum SystemEventType {
@@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import 'forum_models.dart';
/// Fetches forum posts (kind:45001) for a channel from the relay.
+8 -2
View File
@@ -13,7 +13,9 @@ import '../channels/channels_page.dart';
import '../search/search_page.dart';
class HomePage extends HookConsumerWidget {
const HomePage({super.key});
const HomePage({required this.settingsPageBuilder, super.key});
final WidgetBuilder settingsPageBuilder;
static const double _tabBarHeight = 56;
static const double _tabBarRadius = _tabBarHeight / 2;
@@ -54,7 +56,11 @@ class HomePage extends HookConsumerWidget {
_destinations.length,
);
const pages = [ChannelsPage(), ActivityPage(), SearchPage()];
final pages = [
ChannelsPage(settingsPageBuilder: settingsPageBuilder),
const ActivityPage(),
const SearchPage(),
];
return Scaffold(
extendBody: true,
+42 -29
View File
@@ -3,10 +3,23 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import 'profile_provider.dart';
import 'user_profile.dart';
/// Matches desktop's sidebar profile card, whose avatar is 32px.
const _avatarSize = 32.0;
/// The visible dot is smaller than the notch it sits in, so a ring of
/// background separates it from the avatar. Desktop's `h-2 w-2` dot inside a
/// `h-3.5 w-3.5` badge frame.
const _presenceDotRatio = 8 / 14;
/// User avatar with a presence dot indicator, for use in the app bar.
///
/// The dot sits in a notch masked out of the avatar rather than overlapping it,
/// the same treatment desktop's `SidebarProfileCard` uses — so it needs no
/// background-coloured ring, and reads correctly over the themed top section.
class ProfileAvatar extends ConsumerWidget {
final VoidCallback? onTap;
final bool showPresence;
@@ -32,7 +45,7 @@ class ProfileAvatar extends ConsumerWidget {
Widget _buildPlaceholder(BuildContext context) {
return CircleAvatar(
radius: 16,
radius: _avatarSize / 2,
backgroundColor: context.colors.primaryContainer,
);
}
@@ -44,37 +57,37 @@ class ProfileAvatar extends ConsumerWidget {
) {
return GestureDetector(
onTap: onTap,
child: Stack(
children: [
AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 16,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
if (showPresence)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _presenceColor(context, presence),
shape: BoxShape.circle,
border: Border.all(
color: context.theme.scaffoldBackgroundColor,
width: 1.5,
),
child: MaskedAvatarBadge(
size: _avatarSize,
geometry: AvatarBadgeMaskGeometry.presenceDot,
avatar: ClipOval(
child: ColoredBox(
color: context.colors.primaryContainer,
child: AvatarImageContent(
imageUrl: profile?.avatarUrl,
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
],
),
),
badge: showPresence
? Center(
child: FractionallySizedBox(
widthFactor: _presenceDotRatio,
heightFactor: _presenceDotRatio,
child: DecoratedBox(
decoration: BoxDecoration(
color: _presenceColor(context, presence),
shape: BoxShape.circle,
),
),
),
)
: null,
),
);
}
+104 -177
View File
@@ -1,33 +1,24 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/theme/theme.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import '../channels/emoji_picker.dart';
import 'user_status.dart';
import 'user_status_provider.dart';
const _emojiOptions = [
(emoji: '\u{1F5E3}\u{FE0F}', label: 'In a meeting'),
(emoji: '\u{1F68C}', label: 'Commuting'),
(emoji: '\u{1F912}', label: 'Out sick'),
(emoji: '\u{1F3D6}\u{FE0F}', label: 'Vacationing'),
(emoji: '\u{1F3E0}', label: 'Working remotely'),
(emoji: '\u{1F354}', label: 'Lunch'),
(emoji: '\u{1F3AF}', label: 'Focus'),
(emoji: '\u{1F4AA}', label: 'Exercising'),
];
const _presets = [
(text: 'In a meeting', emoji: '\u{1F5E3}\u{FE0F}'),
(text: 'Commuting', emoji: '\u{1F68C}'),
(text: 'Out sick', emoji: '\u{1F912}'),
(text: 'Vacationing', emoji: '\u{1F3D6}\u{FE0F}'),
(text: 'Working remotely', emoji: '\u{1F3E0}'),
];
/// The emoji well and the text field are sized to be the two things you reach
/// for, so they carry no borders — the sheet has no other controls to compete
/// with them.
const _emojiWellSize = 56.0;
const _emojiGlyphSize = 32.0;
const _saveButtonHeight = 52.0;
void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) {
showModalBottomSheet<void>(
@@ -51,7 +42,6 @@ class _SetStatusSheet extends HookConsumerWidget {
final emoji = useState(currentStatus?.emoji ?? '');
final text = useState(currentStatus?.text ?? '');
final isSaving = useState(false);
final customEmoji = ref.watch(customEmojiListProvider);
useEffect(() {
void listener() => text.value = textController.text;
@@ -91,7 +81,13 @@ class _SetStatusSheet extends HookConsumerWidget {
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom,
// The sheet ends in the Save button, so it owns its own breathing room
// above whichever is taller: the keyboard, or the home indicator.
Grid.gutter +
math.max(
MediaQuery.viewInsetsOf(context).bottom,
MediaQuery.viewPaddingOf(context).bottom,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -107,31 +103,77 @@ class _SetStatusSheet extends HookConsumerWidget {
),
const SizedBox(height: Grid.twelve),
// Text input with emoji preview
// The emoji well doubles as the picker's entry point, which is why
// there is no separate row of emoji suggestions below.
Row(
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.md),
),
child: _StatusEmojiPreview(emoji: emoji.value),
Stack(
clipBehavior: Clip.none,
children: [
Semantics(
button: true,
label: 'Choose a status emoji',
child: InkWell(
borderRadius: BorderRadius.circular(Radii.lg),
onTap: () => showEmojiPicker(
context: context,
onSelect: (value) => emoji.value = value,
),
child: SizedBox.square(
dimension: _emojiWellSize,
child: Center(
child: _StatusEmojiPreview(emoji: emoji.value),
),
),
),
),
if (emoji.value.isNotEmpty)
Positioned(
top: -Grid.quarter,
right: -Grid.quarter,
child: SizedBox.square(
dimension: Grid.sm,
child: IconButton(
onPressed: isSaving.value
? null
: () => emoji.value = '',
tooltip: 'Remove status emoji',
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: context.colors.surface,
minimumSize: const Size.square(Grid.sm),
maximumSize: const Size.square(Grid.sm),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
LucideIcons.x,
size: 14,
color: context.colors.onSurface,
),
),
),
),
],
),
const SizedBox(width: Grid.xxs),
const SizedBox(width: Grid.half),
Expanded(
child: TextField(
controller: textController,
autofocus: true,
decoration: const InputDecoration(
style: context.textTheme.titleMedium,
decoration: InputDecoration(
hintText: 'What\u2019s your status?',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xxs,
hintStyle: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
// The theme outlines inputs; this one is the sheet's whole
// content, so every border state is cleared explicitly.
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: false,
contentPadding: EdgeInsets.zero,
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
@@ -141,74 +183,28 @@ class _SetStatusSheet extends HookConsumerWidget {
),
],
),
const SizedBox(height: Grid.twelve),
const SizedBox(height: Grid.gutter),
Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final option in _emojiOptions)
_EmojiButton(
emoji: option.emoji,
label: option.label,
selected: emoji.value == option.emoji,
onTap: () {
emoji.value = emoji.value == option.emoji
? ''
: option.emoji;
},
),
if (customEmoji.isNotEmpty)
_PickCustomEmojiButton(
selected: emoji.value.startsWith(':'),
onTap: () => showEmojiPicker(
context: context,
onSelect: (value) => emoji.value = value,
),
),
],
SizedBox(
width: double.infinity,
height: _saveButtonHeight,
child: FilledButton(
onPressed: hasContent && !isSaving.value ? handleSave : null,
child: const Text('Save'),
),
),
const SizedBox(height: Grid.twelve),
// Presets
Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final preset in _presets)
ActionChip(
label: Text('${preset.emoji} ${preset.text}'),
labelStyle: context.textTheme.labelSmall,
onPressed: () {
textController.text = preset.text;
emoji.value = preset.emoji;
},
),
],
),
const SizedBox(height: Grid.xs),
Row(
children: [
if (hasExistingStatus)
TextButton(
// No Cancel \u2014 the sheet dismisses by swiping down.
if (hasExistingStatus)
Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: SizedBox(
width: double.infinity,
child: TextButton(
onPressed: isSaving.value ? null : handleClear,
child: const Text('Clear status'),
),
const Spacer(),
TextButton(
onPressed: isSaving.value
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: Grid.xxs),
FilledButton(
onPressed: hasContent && !isSaving.value ? handleSave : null,
child: const Text('Save'),
),
],
),
),
],
),
);
@@ -223,7 +219,11 @@ class _StatusEmojiPreview extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return const Text('\u{1F4AC}', style: TextStyle(fontSize: 18));
return Icon(
LucideIcons.smilePlus,
size: _emojiGlyphSize,
color: context.colors.onSurfaceVariant,
);
}
final palette = ref.watch(customEmojiListProvider);
final shortcode = normalizeShortcode(emoji);
@@ -233,84 +233,11 @@ class _StatusEmojiPreview extends ConsumerWidget {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: 22,
size: _emojiGlyphSize,
);
}
}
}
return Text(emoji, style: const TextStyle(fontSize: 18));
}
}
class _PickCustomEmojiButton extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
const _PickCustomEmojiButton({required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Custom emoji',
child: InkWell(
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? context.colors.secondaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
border: selected ? Border.all(color: context.colors.outline) : null,
),
child: Icon(
Icons.add_reaction_outlined,
size: 18,
color: context.colors.onSurfaceVariant,
),
),
),
);
}
}
class _EmojiButton extends StatelessWidget {
final String emoji;
final String label;
final bool selected;
final VoidCallback onTap;
const _EmojiButton({
required this.emoji,
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: InkWell(
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? context.colors.secondaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
border: selected ? Border.all(color: context.colors.outline) : null,
),
child: Text(emoji, style: const TextStyle(fontSize: 18)),
),
),
);
return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize));
}
}
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import 'profile_provider.dart';
import 'set_status_sheet.dart';
import 'user_status_provider.dart';
/// Desktop's settings-avatar treatment (`ProfileSettingsCard`): a large centred
/// avatar with a circular badge notched out of its bottom-right corner. Desktop
/// puts an edit-photo pencil in that badge; here it carries the status glyph and
/// opens the status sheet instead. The notch shape — including the fillets where
/// it meets the avatar's edge — comes from [AvatarBadgeMaskGeometry.badge].
class SettingsProfileHeader extends ConsumerWidget {
const SettingsProfileHeader({super.key});
static const _avatarSize = 128.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider).asData?.value;
final status = ref.watch(userStatusProvider).asData?.value;
final hasStatus = status != null && !status.isEmpty;
void openStatusSheet() =>
showSetStatusSheet(context, currentStatus: status);
return Padding(
padding: const EdgeInsets.only(top: Grid.xxs, bottom: Grid.sm),
child: Column(
children: [
MaskedAvatarBadge(
size: _avatarSize,
avatar: ColoredBox(
color: context.colors.primaryContainer,
child: AvatarImageContent(
imageUrl: profile?.avatarUrl,
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.displaySmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
),
badge: _StatusBadge(
emoji: status?.emoji ?? '',
onTap: openStatusSheet,
),
),
const SizedBox(height: Grid.twelve),
Text(
profile?.label ?? 'Your profile',
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
// No placeholder copy — the badge is the affordance, so this line
// appears only once there is an actual status to show.
if (hasStatus)
GestureDetector(
onTap: openStatusSheet,
child: Padding(
padding: const EdgeInsets.only(
top: Grid.quarter,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.half,
),
child: Text(
status.text.isNotEmpty ? status.text : status.emoji,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
);
}
}
/// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask
/// geometry rather than being set here.
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.emoji, required this.onTap});
final String emoji;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Set a status',
child: GestureDetector(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Center(child: _StatusGlyph(emoji: emoji)),
),
),
);
}
}
/// The status emoji, resolving `:shortcode:` values against the community's
/// custom emoji. Falls back to the add-status icon when no status is set.
class _StatusGlyph extends ConsumerWidget {
const _StatusGlyph({required this.emoji});
final String emoji;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return Icon(
LucideIcons.smilePlus,
size: 20,
color: context.colors.onSurfaceVariant,
);
}
final shortcode = emoji.startsWith(':') && emoji.endsWith(':')
? emoji.substring(1, emoji.length - 1).toLowerCase()
: null;
if (shortcode != null) {
for (final entry in ref.watch(customEmojiListProvider)) {
if (entry.shortcode == shortcode) {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: 22,
);
}
}
return Icon(
LucideIcons.smile,
size: 20,
color: context.colors.onSurfaceVariant,
);
}
return Text(emoji, style: const TextStyle(fontSize: 20));
}
}
+1 -1
View File
@@ -149,7 +149,7 @@ class NoteCard extends HookConsumerWidget {
_ActionButton(
icon: effectiveUpvoted
? Icons.favorite
: Icons.favorite_border,
: LucideIcons.heart,
label: effectiveCount > 0 ? '$effectiveCount' : null,
color: effectiveUpvoted ? Colors.redAccent : null,
onTap: () async {
+2 -2
View File
@@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../custom_emoji/custom_emoji.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import 'pulse_models.dart';
import 'pulse_provider.dart';
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
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';
/// Accent colors as a list of rows, matching the theme picker's shape. The
/// swatches used to live inline on the settings page as a wrap of tappable
/// circles; a row per color keeps the settings page to plain rows and gives each
/// accent a readable name.
class AccentPickerPage extends ConsumerWidget {
const AccentPickerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(accentProvider);
final colorScheme = context.colors;
return FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Accent Color')),
body: ListView(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
bottom: Grid.xs,
),
children: [
for (var i = 0; i < accentColors.length; i++)
_AccentRow(
color: accentColorForScheme(colorScheme, i),
label: accentColors[i].name,
selected: selected == i,
onTap: () => ref.read(accentProvider.notifier).setAccent(i),
),
],
),
);
}
}
class _AccentRow extends StatelessWidget {
const _AccentRow({
required this.color,
required this.label,
required this.selected,
required this.onTap,
});
final Color color;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
),
title: Text(label),
trailing: selected
? Icon(LucideIcons.check, size: 18, color: context.colors.primary)
: null,
onTap: onTap,
);
}
}
+40 -269
View File
@@ -10,23 +10,22 @@ import '../../shared/clipboard_utils.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../profile/set_status_sheet.dart';
import '../profile/user_status_provider.dart';
import '../custom_emoji/custom_emoji_provider.dart';
import '../custom_emoji/custom_emoji_render.dart';
import 'accent_picker_page.dart';
import 'theme_picker_page.dart';
part 'settings_page/appearance_section.dart';
part 'settings_page/connection_section.dart';
class SettingsPage extends HookConsumerWidget {
const SettingsPage({super.key});
const SettingsPage({super.key, required this.profileHeader});
final Widget profileHeader;
@override
Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(relayConfigProvider);
final selectedAccent = ref.watch(accentProvider);
final selectedScheme = ref.watch(schemeProvider);
final colorScheme = context.colors;
final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
final packageInfo = useFuture(packageInfoFuture);
@@ -41,283 +40,55 @@ class SettingsPage extends HookConsumerWidget {
bottom: Grid.xs,
),
children: [
const SizedBox(height: Grid.xxs),
// Status — flush header row, like Slack's profile/status block.
_StatusRow(),
// Appearance
AppListSection(
label: 'Appearance',
children: [
AppListRow(
icon: LucideIcons.palette,
title: 'Color Scheme',
subtitle: selectedScheme == null
? 'Default ($defaultSchemeDisplayName)'
: findTheme(selectedScheme)?.displayName ??
selectedScheme,
trailing: Icon(
LucideIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const ThemePickerPage(),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.twelve,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Accent Color',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.twelve),
Wrap(
spacing: Grid.xxs,
runSpacing: Grid.xxs,
children: [
for (var i = 0; i < accentColors.length; i++)
_AccentSwatch(
color: accentColorForScheme(colorScheme, i),
label: accentColors[i].name,
selected: selectedAccent == i,
onTap: () => ref
.read(accentProvider.notifier)
.setAccent(i),
),
],
),
],
),
),
],
),
// Connection
AppListSection(
label: 'Connection',
children: [
AppListRow(
icon: LucideIcons.server,
title: 'Connected to',
subtitle: config.baseUrl,
),
if (config.nsec != null && config.nsec!.isNotEmpty)
Builder(
builder: (context) {
final privHex = nostr.Nip19.decode(
payload: config.nsec!,
).data;
final pubkey = privHex.isNotEmpty
? nostr.Keys(privHex).public
: 'unknown';
return AppListRow(
icon: LucideIcons.key,
title: 'Identity (pubkey)',
subtitle: pubkey,
subtitleStyle: context.textTheme.bodySmall
?.copyWith(
color: context.colors.onSurfaceVariant,
fontFamily: 'GeistMono',
fontSize: 11,
),
subtitleMaxLines: 2,
trailing: IconButton(
icon: const Icon(LucideIcons.copy, size: 16),
onPressed: () async {
await copyToClipboard(
context,
pubkey,
message: 'Pubkey copied',
);
},
),
);
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Center(
child: TextButton.icon(
onPressed: () => _confirmSignOut(context, ref),
icon: const Icon(LucideIcons.logOut, size: 18),
label: const Text('Remove Community'),
style: TextButton.styleFrom(
foregroundColor: context.colors.error,
),
),
),
),
],
),
profileHeader,
const _AppearanceSection(),
const _ConnectionSection(),
const _RemoveCommunitySection(),
],
),
),
if (packageInfo.hasData)
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs),
child: Center(
child: Text(
'v${packageInfo.data!.version}',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
),
),
),
),
],
),
);
}
void _confirmSignOut(BuildContext context, WidgetRef ref) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove Community'),
content: const Text(
'This will disconnect this community. You will need '
'to scan a new pairing code to reconnect.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop(); // close dialog
// Pop all pushed routes back to root so MaterialApp.home
// rebuilds to PairingPage when auth state changes.
Navigator.of(context).popUntil((route) => route.isFirst);
ref.read(authProvider.notifier).signOut();
},
style: FilledButton.styleFrom(backgroundColor: ctx.colors.error),
child: const Text('Remove'),
),
_VersionFooter(version: packageInfo.data!.version),
],
),
);
}
}
class _StatusRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusAsync = ref.watch(userStatusProvider);
final status = statusAsync.asData?.value;
final hasStatus = status != null && !status.isEmpty;
class _VersionFooter extends StatelessWidget {
const _VersionFooter({required this.version});
return AppListRowRaw(
leading: _StatusEmojiIcon(emoji: status?.emoji ?? ''),
title: Text(
hasStatus
? (status.text.isNotEmpty ? status.text : status.emoji)
: 'Set a status',
style: hasStatus
? context.textTheme.bodyLarge
: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
subtitle: hasStatus
? Text(
'Tap to update',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
)
: null,
onTap: () => showSetStatusSheet(context, currentStatus: status),
);
}
}
class _StatusEmojiIcon extends ConsumerWidget {
final String emoji;
const _StatusEmojiIcon({required this.emoji});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return const Text('\u{1F4AC}', style: TextStyle(fontSize: 20));
}
final shortcode = emoji.startsWith(':') && emoji.endsWith(':')
? emoji.substring(1, emoji.length - 1).toLowerCase()
: null;
if (shortcode != null) {
for (final entry in ref.watch(customEmojiListProvider)) {
if (entry.shortcode == shortcode) {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: 24,
);
}
}
}
return Text(emoji, style: const TextStyle(fontSize: 20));
}
}
class _AccentSwatch extends StatelessWidget {
const _AccentSwatch({
required this.color,
required this.label,
required this.selected,
required this.onTap,
});
final Color color;
final String label;
final bool selected;
final VoidCallback onTap;
final String version;
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 36,
height: 36,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(Radii.md),
border: selected
? Border.all(color: context.colors.onSurface, width: 2.5)
: Border.all(color: color.withValues(alpha: 0.4), width: 1),
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs),
child: Center(
child: Text(
'v$version',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
),
),
child: selected
? Icon(
LucideIcons.check,
size: 16,
color: contrastForeground(color),
)
: null,
),
),
);
}
}
/// Trailing affordance shared by the rows that push a picker page.
class _RowChevron extends StatelessWidget {
const _RowChevron();
@override
Widget build(BuildContext context) {
return Icon(
LucideIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
);
}
}
@@ -0,0 +1,138 @@
part of '../settings_page.dart';
/// System / Light / Dark, mirroring desktop's appearance-mode selector.
const _modeOptions = <({ThemeMode mode, String label, IconData icon})>[
(mode: ThemeMode.system, label: 'System', icon: LucideIcons.sunMoon),
(mode: ThemeMode.light, label: 'Light', icon: LucideIcons.sun),
(mode: ThemeMode.dark, label: 'Dark', icon: LucideIcons.moon),
];
String _modeLabel(ThemeMode mode) =>
_modeOptions.firstWhere((option) => option.mode == mode).label;
class _AppearanceSection extends ConsumerWidget {
const _AppearanceSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode = ref.watch(themeProvider);
final schemeName = ref.watch(schemeProvider);
final accentIndex = ref.watch(accentProvider);
return AppListCard(
label: 'Style',
children: [
AppListRow(
icon: LucideIcons.sunMoon,
title: 'Appearance',
value: _modeLabel(mode),
trailing: const _RowChevron(),
onTap: () => _showAppearanceModeSheet(context),
),
AppListRow(
icon: LucideIcons.palette,
title: 'Theme',
value: themeSelectionLabel(schemeName, mode),
trailing: const _RowChevron(),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ThemePickerPage()),
),
),
AppListRow(
icon: LucideIcons.droplet,
title: 'Accent color',
// The swatch *is* the value — naming the color as well would say the
// same thing twice, so it takes the chevron's place.
trailing: _AccentSwatch(accentIndex: accentIndex),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const AccentPickerPage()),
),
),
],
);
}
}
void _showAppearanceModeSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _AppearanceModeSheet(),
);
}
/// Three choices is too few to warrant a page, so the appearance row opens a
/// sheet rather than pushing a route.
class _AppearanceModeSheet extends ConsumerWidget {
const _AppearanceModeSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode = ref.watch(themeProvider);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xxs,
),
child: Text('Appearance', style: context.textTheme.titleMedium),
),
for (final option in _modeOptions)
AppListRow(
icon: option.icon,
title: option.label,
trailing: option.mode == mode
? Icon(
LucideIcons.check,
size: 18,
color: context.colors.primary,
)
: null,
onTap: () {
final schemeName = ref.read(schemeProvider);
final compatibleScheme = schemeForAppearanceMode(
schemeName,
option.mode,
);
if (compatibleScheme != schemeName) {
ref.read(schemeProvider.notifier).setScheme(compatibleScheme);
}
ref.read(themeProvider.notifier).setMode(option.mode);
Navigator.of(context).pop();
},
),
const SizedBox(height: Grid.xxs),
],
),
);
}
}
/// The selected accent, shown where a row would otherwise carry a chevron.
class _AccentSwatch extends StatelessWidget {
const _AccentSwatch({required this.accentIndex});
static const _size = 22.0;
final int accentIndex;
@override
Widget build(BuildContext context) {
return Container(
width: _size,
height: _size,
decoration: BoxDecoration(
color: accentColorForScheme(context.colors, accentIndex),
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
);
}
}
@@ -0,0 +1,103 @@
part of '../settings_page.dart';
class _ConnectionSection extends ConsumerWidget {
const _ConnectionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(relayConfigProvider);
final nsec = config.nsec;
return AppListCard(
label: 'Connection',
children: [
AppListRow(
icon: LucideIcons.server,
title: 'Connected to',
subtitle: config.baseUrl,
),
if (nsec != null && nsec.isNotEmpty) _IdentityRow(nsec: nsec),
],
);
}
}
/// Destructive, so it gets a container of its own rather than sitting at the
/// bottom of the connection group.
class _RemoveCommunitySection extends ConsumerWidget {
const _RemoveCommunitySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppListCard(
children: [
AppListRow(
icon: LucideIcons.logOut,
title: 'Remove community',
titleColor: context.colors.error,
onTap: () => _confirmRemoveCommunity(context, ref),
),
],
);
}
}
class _IdentityRow extends StatelessWidget {
const _IdentityRow({required this.nsec});
final String nsec;
@override
Widget build(BuildContext context) {
final privHex = nostr.Nip19.decode(payload: nsec).data;
final pubkey = privHex.isNotEmpty ? nostr.Keys(privHex).public : 'unknown';
return AppListRow(
icon: LucideIcons.key,
title: 'Identity (pubkey)',
subtitle: pubkey,
subtitleStyle: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontFamily: 'GeistMono',
fontSize: 11,
),
subtitleMaxLines: 2,
trailing: IconButton(
icon: const Icon(LucideIcons.copy, size: 16),
onPressed: () async {
await copyToClipboard(context, pubkey, message: 'Pubkey copied');
},
),
);
}
}
void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove Community'),
content: const Text(
'This will disconnect this community. You will need '
'to scan a new pairing code to reconnect.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop(); // close dialog
// Pop all pushed routes back to root so MaterialApp.home rebuilds
// to PairingPage when auth state changes.
Navigator.of(context).popUntil((route) => route.isFirst);
ref.read(authProvider.notifier).signOut();
},
style: FilledButton.styleFrom(backgroundColor: ctx.colors.error),
child: const Text('Remove'),
),
],
),
);
}
@@ -7,6 +7,9 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
/// Themes for the current appearance mode, organised the way desktop does it:
/// System offers the light/dark pairs as single entries, while Light and Dark
/// each list every theme of that brightness.
class ThemePickerPage extends HookConsumerWidget {
const ThemePickerPage({super.key});
@@ -15,41 +18,47 @@ class ThemePickerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode = ref.watch(themeProvider);
final selectedScheme = ref.watch(schemeProvider);
final searchQuery = useState('');
final searchController = useTextEditingController();
final scrollController = useScrollController();
// Flat alphabetical list by display name
final sorted = List<ThemeColors>.from(themeCatalog)
..sort((a, b) => a.displayName.compareTo(b.displayName));
final groups = themeGroups();
final entries = switch (mode) {
ThemeMode.system => groups.paired,
ThemeMode.light => groups.light,
ThemeMode.dark => groups.dark,
};
final isSystem = mode == ThemeMode.system;
String labelFor(ThemeColors theme) =>
isSystem ? pairedThemeLabel(theme.name) : theme.displayName;
final query = searchQuery.value.toLowerCase();
final defaultLabel = 'Default ($defaultSchemeDisplayName)';
final filtered = query.isEmpty
? sorted
: sorted
.where((t) => t.displayName.toLowerCase().contains(query))
? entries
: entries
.where((t) => labelFor(t).toLowerCase().contains(query))
.toList();
final showDefault =
query.isEmpty ||
'default'.contains(query) ||
defaultLabel.toLowerCase().contains(query);
// Default entry colors.
final defaultTheme = findTheme(defaultSchemeName);
final defaultBg = defaultTheme?.bg ?? lightColorScheme.surface;
final defaultFg = defaultTheme?.fg ?? lightColorScheme.onSurface;
final defaultComment =
defaultTheme?.comment ?? lightColorScheme.onSurfaceVariant;
// In System mode either half of a pair counts as the selection; in the
// pinned modes the effective theme already accounts for coercion, so the
// checkmark always names what is actually rendered.
final active = effectiveTheme(selectedScheme, mode);
bool isSelected(ThemeColors theme) {
if (active == null) return false;
if (!isSystem) return active.name == theme.name;
return active.name == theme.name ||
themePairFor(theme.name) == active.name;
}
// Auto-scroll to the selected theme on first build (no search active)
// Auto-scroll to the selected theme on first build (no search active).
useEffect(() {
if (selectedScheme == null || query.isNotEmpty) return null;
final idx = sorted.indexWhere((t) => t.name == selectedScheme);
if (query.isNotEmpty) return null;
final idx = filtered.indexWhere(isSelected);
if (idx < 0) return null;
// +1 to account for the Default entry at the top
final offset = (idx + 1) * _itemHeight;
final offset = idx * _itemHeight;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.animateTo(
@@ -63,98 +72,22 @@ class ThemePickerPage extends HookConsumerWidget {
}, const []);
return FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Color Scheme')),
appBar: const FrostedAppBar(title: Text('Theme')),
body: Column(
children: [
SizedBox(height: frostedAppBarHeight(context)),
// Always-visible search bar
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
child: Row(
children: [
Icon(
LucideIcons.search,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: 'Search themes...',
hintStyle: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: context.textTheme.bodyMedium,
onChanged: (v) => searchQuery.value = v,
),
),
if (searchQuery.value.isNotEmpty)
GestureDetector(
onTap: () {
searchController.clear();
searchQuery.value = '';
},
child: Icon(
LucideIcons.x,
size: 16,
color: context.colors.onSurfaceVariant,
),
),
],
),
),
_SearchField(
controller: searchController,
query: searchQuery.value,
onChanged: (v) => searchQuery.value = v,
onClear: () {
searchController.clear();
searchQuery.value = '';
},
),
// Theme list
Expanded(
child: ListView(
controller: scrollController,
children: [
// Default entry
if (showDefault)
_ThemeRow(
bg: defaultBg,
fg: defaultFg,
comment: defaultComment,
label: defaultLabel,
selected: selectedScheme == null,
onTap: () =>
ref.read(schemeProvider.notifier).setScheme(null),
),
// All themes, flat alphabetical
for (final theme in filtered)
_ThemeRow(
bg: theme.bg,
fg: theme.fg,
comment: theme.comment,
label: theme.displayName,
selected: selectedScheme == theme.name,
onTap: () =>
ref.read(schemeProvider.notifier).setScheme(theme.name),
),
if (!showDefault && filtered.isEmpty)
Padding(
child: filtered.isEmpty
? Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Center(
child: Text(
@@ -164,9 +97,26 @@ class ThemePickerPage extends HookConsumerWidget {
),
),
),
)
: ListView.builder(
controller: scrollController,
itemCount: filtered.length,
itemBuilder: (_, i) {
final theme = filtered[i];
final pairName = isSystem
? themePairFor(theme.name)
: null;
return _ThemeRow(
theme: theme,
pair: pairName == null ? null : findTheme(pairName),
label: labelFor(theme),
selected: isSelected(theme),
onTap: () => ref
.read(schemeProvider.notifier)
.setScheme(theme.name),
);
},
),
],
),
),
],
),
@@ -174,50 +124,118 @@ class ThemePickerPage extends HookConsumerWidget {
}
}
/// A row showing a 3-stripe color bar (bg/fg/comment) + theme name + checkmark.
class _SearchField extends StatelessWidget {
const _SearchField({
required this.controller,
required this.query,
required this.onChanged,
required this.onClear,
});
final TextEditingController controller;
final String query;
final ValueChanged<String> onChanged;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
child: Row(
children: [
Icon(
LucideIcons.search,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Search themes...',
hintStyle: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: context.textTheme.bodyMedium,
onChanged: onChanged,
),
),
if (query.isNotEmpty)
GestureDetector(
onTap: onClear,
child: Icon(
LucideIcons.x,
size: 16,
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
}
}
/// A theme row with a colour bar + name + checkmark. When [pair] is supplied the
/// bar is split down the middle — light stripes on the left, dark on the right —
/// standing in for desktop's split light/dark preview tile.
class _ThemeRow extends StatelessWidget {
const _ThemeRow({
required this.bg,
required this.fg,
required this.comment,
required this.theme,
required this.pair,
required this.label,
required this.selected,
required this.onTap,
});
final Color bg;
final Color fg;
final Color comment;
final ThemeColors theme;
final ThemeColors? pair;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final swatch = pair == null ? [theme] : [theme, pair!];
return ListTile(
leading: Container(
width: 56,
height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(color: context.colors.outlineVariant, width: 1),
border: Border.all(color: context.colors.outlineVariant),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm - 1),
child: Row(
children: [
Expanded(
child: ColoredBox(color: bg, child: const SizedBox.expand()),
),
Expanded(
child: ColoredBox(color: fg, child: const SizedBox.expand()),
),
Expanded(
child: ColoredBox(
color: comment,
child: const SizedBox.expand(),
),
),
for (final t in swatch)
for (final color in [t.bg, t.fg, t.comment])
Expanded(
child: ColoredBox(
color: color,
child: const SizedBox.expand(),
),
),
],
),
),
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart';
import '../../shared/relay/nostr_models.dart';
import '../relay/nostr_models.dart';
/// A community custom emoji: a `:shortcode:` mapped to an image URL.
///
@@ -1,6 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../relay/relay.dart';
import 'custom_emoji.dart';
/// Community custom-emoji palette (NIP-30, per-user kind:30030 sets unioned).
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import '../../shared/relay/relay.dart';
import '../relay/relay.dart';
import 'custom_emoji.dart';
+23 -6
View File
@@ -6,19 +6,31 @@ class AppColors extends ThemeExtension<AppColors> {
final Color warning;
final Color accent;
/// Gradient for the app's top section, non-null only under the Buzz themes.
/// Carried on the theme rather than read from a provider so any surface can
/// opt in via `context.appColors.topSectionGradient` — see
/// `buzzTopSectionGradient`.
final Gradient? topSectionGradient;
const AppColors({
required this.success,
required this.warning,
required this.accent,
this.topSectionGradient,
});
@override
AppColors copyWith({Color? success, Color? warning, Color? accent}) =>
AppColors(
success: success ?? this.success,
warning: warning ?? this.warning,
accent: accent ?? this.accent,
);
AppColors copyWith({
Color? success,
Color? warning,
Color? accent,
Gradient? topSectionGradient,
}) => AppColors(
success: success ?? this.success,
warning: warning ?? this.warning,
accent: accent ?? this.accent,
topSectionGradient: topSectionGradient ?? this.topSectionGradient,
);
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
@@ -27,6 +39,11 @@ class AppColors extends ThemeExtension<AppColors> {
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
accent: Color.lerp(accent, other.accent, t)!,
topSectionGradient: Gradient.lerp(
topSectionGradient,
other.topSectionGradient,
t,
),
);
}
}
+11 -2
View File
@@ -13,16 +13,21 @@ class Radii {
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
}
class AppTheme {
static ThemeData light({ColorScheme? colorScheme}) {
static ThemeData light({
ColorScheme? colorScheme,
Gradient? topSectionGradient,
}) {
final scheme = colorScheme ?? lightColorScheme;
final appColors = AppColors(
success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal
warning: const Color(0xFFDF8E1D), // Latte Yellow
accent: scheme.tertiary,
topSectionGradient: topSectionGradient,
);
return _buildTheme(
@@ -34,7 +39,10 @@ class AppTheme {
);
}
static ThemeData dark({ColorScheme? colorScheme}) {
static ThemeData dark({
ColorScheme? colorScheme,
Gradient? topSectionGradient,
}) {
final scheme = colorScheme ?? darkColorScheme;
final appColors = AppColors(
success: const Color(
@@ -42,6 +50,7 @@ class AppTheme {
), // Catppuccin Macchiato Green — universal
warning: const Color(0xFFEED49F), // Macchiato Yellow
accent: scheme.tertiary,
topSectionGradient: topSectionGradient,
);
return _buildTheme(
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
/// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for
/// every base color; the one thing that sets it apart is a branded gradient
/// painted across the app's top section. Mirrors desktop, where the same
/// gradient fills the sidebar canvas — see `data-buzz-sidebar` in
/// `desktop/src/shared/styles/globals/theme.css`.
const buzzThemeName = 'buzz';
/// Name of the dark counterpart, which reuses the GitHub Dark palette and the
/// dark-tuned gradient stops. Paired with [buzzThemeName] in `themePairs`, so
/// the two behave as a single "Buzz" choice under System mode.
const buzzDarkThemeName = 'buzz-dark';
/// Whether [themeName] is either half of the Buzz pair. Both halves enable the
/// gradient so System mode keeps it on across an OS light/dark switch.
bool isBuzzTheme(String themeName) =>
themeName == buzzThemeName || themeName == buzzDarkThemeName;
/// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties.
const _lightTop = Color(0xFFE6E6B6);
const _lightBottom = Color(0xFFC4D0DA);
const _darkTop = Color(0xFF4A4616);
const _darkBottom = Color(0xFF0A1423);
/// The Buzz gradient for the app's top section, or null when [themeName] is not
/// a Buzz theme — in which case the section keeps its default frosted fill.
///
/// The stops are fully opaque: under Buzz the color replaces the frosted
/// treatment rather than tinting it, matching desktop's solid sidebar canvas.
///
/// [brightness] comes from the applied color scheme rather than the theme name,
/// so System mode picks the right stops as the OS switches.
LinearGradient? buzzTopSectionGradient(
String themeName,
Brightness brightness,
) {
if (!isBuzzTheme(themeName)) return null;
final isDark = brightness == Brightness.dark;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
isDark ? _darkTop : _lightTop,
isDark ? _darkBottom : _lightBottom,
],
);
}
+2
View File
@@ -2,8 +2,10 @@ export 'accent_colors.dart';
export 'adaptive_theme.dart';
export 'app_colors.dart';
export 'app_theme.dart';
export 'buzz_theme.dart';
export 'color_scheme.dart';
export 'grid.dart';
export 'theme_catalog.dart';
export 'theme_extensions.dart';
export 'theme_pairs.dart';
export 'theme_provider.dart';
@@ -33,6 +33,7 @@ class ThemeColors {
/// Known light theme names — used to show sun/moon icons before loading.
const lightThemeNames = <String>{
'buzz',
'catppuccin-latte',
'everforest-light',
'github-light',
@@ -79,6 +80,25 @@ const themeCatalog = <ThemeColors>[
added: Color(0xFF70BF56),
deleted: Color(0xFFF26D78),
),
// Buzz and Buzz Dark are first-party: they borrow the GitHub Light / GitHub
// Dark palettes wholesale and are distinguished only by the branded gradient
// painted across the app's top section (see buzz_theme.dart).
ThemeColors(
name: 'buzz',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF24292E),
comment: Color(0xFF6A737D),
added: Color(0xFF28A745),
deleted: Color(0xFFD73A49),
),
ThemeColors(
name: 'buzz-dark',
bg: Color(0xFF24292E),
fg: Color(0xFFE1E4E8),
comment: Color(0xFF6A737D),
added: Color(0xFF34D058),
deleted: Color(0xFFEA4A5A),
),
ThemeColors(
name: 'catppuccin-frappe',
bg: Color(0xFF303446),
+115
View File
@@ -0,0 +1,115 @@
import 'theme_catalog.dart';
/// Light → dark theme counterparts, ported from the desktop app's `THEME_PAIRS`
/// (`desktop/src/shared/theme/theme-loader.ts`) so both clients offer the same
/// System-mode pairings.
///
/// Buzz leads the map the way it leads desktop's, so the first-party pair sorts
/// ahead of the borrowed syntax themes wherever insertion order is preserved.
const themePairs = <String, String>{
'buzz': 'buzz-dark',
'catppuccin-latte': 'catppuccin-mocha',
'everforest-light': 'everforest-dark',
'github-light': 'github-dark',
'github-light-default': 'github-dark-default',
'github-light-high-contrast': 'github-dark-high-contrast',
'gruvbox-light-hard': 'gruvbox-dark-hard',
'gruvbox-light-medium': 'gruvbox-dark-medium',
'gruvbox-light-soft': 'gruvbox-dark-soft',
'kanagawa-lotus': 'kanagawa-wave',
'light-plus': 'dark-plus',
'material-theme-lighter': 'material-theme',
'min-light': 'min-dark',
'one-light': 'one-dark-pro',
'rose-pine-dawn': 'rose-pine',
'slack-ochin': 'slack-dark',
'solarized-light': 'solarized-dark',
'vitesse-light': 'vitesse-dark',
};
final Map<String, String> _darkToLight = {
for (final entry in themePairs.entries) entry.value: entry.key,
};
/// The counterpart of [name] in the opposite brightness, or null when the theme
/// has no pair. Resolves in both directions.
String? themePairFor(String name) => themePairs[name] ?? _darkToLight[name];
/// Whether [name] participates in a light/dark pair, and so can follow the OS.
bool isPairedTheme(String name) => themePairFor(name) != null;
/// [themeCatalog] partitioned the way the appearance picker offers it.
class ThemeGroups {
/// Light members of light/dark pairs — the System-mode options. Each stands
/// in for both halves of its pair.
final List<ThemeColors> paired;
/// Every light theme, paired or not — the Light-mode options.
final List<ThemeColors> light;
/// Every dark theme, paired or not — the Dark-mode options.
final List<ThemeColors> dark;
const ThemeGroups({
required this.paired,
required this.light,
required this.dark,
});
}
ThemeGroups? _groups;
/// [themeCatalog] grouped for the appearance picker, each list sorted by
/// display name. Computed once — the catalog is a compile-time constant.
ThemeGroups themeGroups() {
final cached = _groups;
if (cached != null) return cached;
final paired = <ThemeColors>[];
final light = <ThemeColors>[];
final dark = <ThemeColors>[];
for (final theme in themeCatalog) {
if (theme.isDark) {
dark.add(theme);
continue;
}
light.add(theme);
if (themePairs.containsKey(theme.name)) paired.add(theme);
}
int byDisplayName(ThemeColors a, ThemeColors b) =>
a.displayName.compareTo(b.displayName);
paired.sort(byDisplayName);
light.sort(byDisplayName);
dark.sort(byDisplayName);
return _groups = ThemeGroups(paired: paired, light: light, dark: dark);
}
/// Tokens that only describe a theme's brightness, stripped from paired labels.
const _modeTokens = <String>{
'light',
'latte',
'dawn',
'lotus',
'ochin',
'lighter',
'plus',
};
/// Display label for a pair, derived from its light member: strips the
/// mode-specific tokens so `github-light` reads as "Github" and stands for both
/// halves. Mirrors desktop's `pairedThemeLabel`.
String pairedThemeLabel(String lightName) {
final stripped = lightName
.split('-')
.where((token) => !_modeTokens.contains(token))
.toList();
// Stripping can remove everything (e.g. "light-plus") — fall back to the raw
// name so the row is never blank.
final parts = stripped.isEmpty ? lightName.split('-') : stripped;
return parts
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
.join(' ');
}
+175 -28
View File
@@ -4,21 +4,26 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'accent_colors.dart';
import 'adaptive_theme.dart';
import 'buzz_theme.dart';
import 'color_scheme.dart';
import 'theme_catalog.dart';
import 'theme_pairs.dart';
const _themeModeKey = 'buzz_theme_mode';
const _accentKey = 'buzz_accent_color';
const _schemeKey = 'buzz_color_scheme';
const defaultSchemeName = 'github-light';
const defaultSchemeDisplayName = 'GitHub Light';
/// Buzz ships as the default: the first-party pair, so a fresh install gets the
/// branded top-section gradient without picking a theme first.
const defaultSchemeName = buzzThemeName;
const defaultSchemeDisplayName = 'Buzz';
/// Pre-loaded SharedPreferences instance, overridden in main().
final savedPrefsProvider = Provider<SharedPreferences>(
(_) => throw UnimplementedError('Must be overridden'),
);
/// Tracks the appearance mode: follow the OS, or pin light/dark.
class ThemeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
@@ -30,6 +35,11 @@ class ThemeNotifier extends Notifier<ThemeMode> {
}
return ThemeMode.system;
}
void setMode(ThemeMode mode) {
state = mode;
ref.read(savedPrefsProvider).setString(_themeModeKey, mode.name);
}
}
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(
@@ -68,19 +78,21 @@ class SchemeNotifier extends Notifier<String?> {
@override
String? build() {
final prefs = ref.read(savedPrefsProvider);
final scheme = prefs.getString(_schemeKey);
final stored = prefs.getString(_schemeKey);
final compatible = schemeForAppearanceMode(
stored,
ref.watch(themeProvider),
);
// One-time migration: if no scheme has been chosen and the user had a
// non-system themeMode persisted (from the old Light/System/Dark toggle),
// reset it because default schemes now control their own brightness.
if (scheme == null) {
final storedMode = prefs.getString(_themeModeKey);
if (storedMode != null && storedMode != ThemeMode.system.name) {
prefs.setString(_themeModeKey, ThemeMode.system.name);
if (compatible != stored) {
if (compatible == null) {
prefs.remove(_schemeKey);
} else {
prefs.setString(_schemeKey, compatible);
}
}
return scheme;
return compatible;
}
void setScheme(String? name) {
@@ -98,25 +110,160 @@ final schemeProvider = NotifierProvider<SchemeNotifier, String?>(
SchemeNotifier.new,
);
/// Resolves the current scheme selection into light and dark [ColorScheme]s.
/// When a named scheme is selected, generates it via the adaptive engine.
/// When null (default), resolves to [defaultSchemeName].
({ColorScheme light, ColorScheme dark, ThemeMode? forcedMode}) resolveSchemes(
String? schemeName,
) {
final theme =
/// Returns a scheme that can honor [mode] without silently pinning brightness.
///
/// System mode only offers paired themes because they have both light and dark
/// variants. Switching to it from an unpaired or unknown selection therefore
/// falls back to the first paired theme, matching the picker ordering.
String? schemeForAppearanceMode(String? schemeName, ThemeMode mode) {
if (mode != ThemeMode.system) return schemeName;
final selected = findTheme(schemeName ?? defaultSchemeName);
if (selected != null && isPairedTheme(selected.name)) return schemeName;
return themeGroups().paired.firstOrNull?.name ?? schemeName;
}
/// Resolves the selected scheme and appearance [mode] into light and dark
/// [ColorScheme]s, mirroring desktop's System / Light / Dark behaviour.
///
/// In [ThemeMode.system] a paired theme is rendered with its pair
/// ([themePairFor]) so Flutter swaps them with the OS. An unpaired theme is
/// pinned to its own brightness. In [ThemeMode.light] and [ThemeMode.dark] the
/// theme is coerced to that brightness and the mode is forced, so the OS setting
/// is ignored.
({
ColorScheme light,
ColorScheme dark,
ThemeColors? lightTheme,
ThemeColors? darkTheme,
ThemeMode? forcedMode,
})
resolveSchemes(String? schemeName, ThemeMode mode) {
final selected =
findTheme(schemeName ?? defaultSchemeName) ??
findTheme(defaultSchemeName);
if (theme == null) {
return (light: lightColorScheme, dark: darkColorScheme, forcedMode: null);
if (selected == null) {
return (
light: lightColorScheme,
dark: darkColorScheme,
lightTheme: null,
darkTheme: null,
forcedMode: null,
);
}
final scheme = generateColorScheme(theme);
// A named scheme is inherently light or dark — force the mode.
return (
light: scheme,
dark: scheme,
forcedMode: theme.isDark ? ThemeMode.dark : ThemeMode.light,
);
switch (mode) {
case ThemeMode.system:
final pairName = themePairFor(selected.name);
if (pairName == null) {
final scheme = generateColorScheme(selected);
return (
light: scheme,
dark: scheme,
lightTheme: selected,
darkTheme: selected,
forcedMode: selected.isDark ? ThemeMode.dark : ThemeMode.light,
);
}
final counterpart = findTheme(pairName) ?? selected;
final lightTheme = selected.isDark ? counterpart : selected;
final darkTheme = selected.isDark ? selected : counterpart;
return (
light: generateColorScheme(lightTheme),
dark: generateColorScheme(darkTheme),
lightTheme: lightTheme,
darkTheme: darkTheme,
forcedMode: null,
);
case ThemeMode.light:
final theme = _themeForBrightness(selected, wantDark: false);
final scheme = generateColorScheme(theme);
return (
light: scheme,
dark: scheme,
lightTheme: theme,
darkTheme: theme,
forcedMode: ThemeMode.light,
);
case ThemeMode.dark:
final theme = _themeForBrightness(selected, wantDark: true);
final scheme = generateColorScheme(theme);
return (
light: scheme,
dark: scheme,
lightTheme: theme,
darkTheme: theme,
forcedMode: ThemeMode.dark,
);
}
}
/// The theme whose colors are actually applied for [schemeName] under [mode].
///
/// In [ThemeMode.system] that is the selected theme itself — its pair covers the
/// other brightness. In the pinned modes it is the theme coerced to the pinned
/// brightness, which is what the picker highlights so the checkmark always
/// tracks what is on screen.
ThemeColors? effectiveTheme(String? schemeName, ThemeMode mode) {
final selected =
findTheme(schemeName ?? defaultSchemeName) ??
findTheme(defaultSchemeName);
if (selected == null) return null;
return switch (mode) {
ThemeMode.system => selected,
ThemeMode.light => _themeForBrightness(selected, wantDark: false),
ThemeMode.dark => _themeForBrightness(selected, wantDark: true),
};
}
/// Label for the current theme selection. System mode names the pair as a whole
/// ("Github" rather than "Github Light"), since both halves are in play.
String themeSelectionLabel(String? schemeName, ThemeMode mode) {
final theme = effectiveTheme(schemeName, mode);
if (theme == null) return defaultSchemeDisplayName;
if (mode != ThemeMode.system) return theme.displayName;
final lightMember = themePairs.containsKey(theme.name)
? theme.name
: themePairFor(theme.name);
return lightMember == null
? theme.displayName
: pairedThemeLabel(lightMember);
}
/// Coerces [selected] to the requested brightness: itself when it already
/// matches, otherwise its pair, otherwise the first catalog theme of that
/// brightness. Keeps a stored light theme usable after switching to Dark mode
/// without rewriting the user's saved selection.
ThemeColors _themeForBrightness(
ThemeColors selected, {
required bool wantDark,
}) {
if (selected.isDark == wantDark) return selected;
final pairName = themePairFor(selected.name);
final paired = pairName == null ? null : findTheme(pairName);
if (paired != null && paired.isDark == wantDark) return paired;
// Match desktop's paired-first picker ordering: an unpaired selection falls
// back through the first-party default pair, not whichever borrowed theme
// sorts first in the full brightness group.
final defaultTheme = findTheme(defaultSchemeName);
if (defaultTheme != null) {
if (defaultTheme.isDark == wantDark) return defaultTheme;
final defaultPairName = themePairFor(defaultTheme.name);
final defaultPair = defaultPairName == null
? null
: findTheme(defaultPairName);
if (defaultPair != null && defaultPair.isDark == wantDark) {
return defaultPair;
}
}
final groups = themeGroups();
final fallback = wantDark ? groups.dark : groups.light;
return fallback.isEmpty ? selected : fallback.first;
}
+48 -64
View File
@@ -1,10 +1,19 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import 'app_list_inset.dart';
/// Widest an inline [AppListRow.value] may grow before it ellipsises, leaving
/// room for a reasonable title beside it.
const double _maxValueWidth = 180.0;
/// Row height comes entirely from this padding — rows carry a single line of
/// text most of the time, so it sets how airy a card reads.
const double _rowVerticalPadding = Grid.xs;
/// A flush, borderless settings/list row: leading icon, title, optional
/// subtitle and trailing widget. No card, no background — groups are
/// separated by [AppListSection] dividers instead.
/// subtitle and trailing widget. Its own background comes from whatever
/// contains it — a grouped card, or the page itself.
class AppListRow extends StatelessWidget {
const AppListRow({
super.key,
@@ -13,6 +22,7 @@ class AppListRow extends StatelessWidget {
this.subtitle,
this.subtitleStyle,
this.subtitleMaxLines,
this.value,
this.trailing,
this.titleColor,
this.onTap,
@@ -23,6 +33,11 @@ class AppListRow extends StatelessWidget {
final String? subtitle;
final TextStyle? subtitleStyle;
final int? subtitleMaxLines;
/// The row's current setting, shown muted on the trailing side next to
/// [trailing] — for rows whose value is short enough to sit inline.
final String? value;
final Widget? trailing;
final Color? titleColor;
final VoidCallback? onTap;
@@ -30,21 +45,20 @@ class AppListRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final row = Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.twelve,
padding: EdgeInsets.symmetric(
horizontal: AppListInset.of(context),
vertical: _rowVerticalPadding,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
// Centred rather than baseline-aligned to the title: on a two-line row
// the icon and trailing control read as belonging to the row, not to
// its first line.
children: [
if (icon != null) ...[
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
icon,
size: 22,
color: titleColor ?? context.colors.onSurfaceVariant,
),
Icon(
icon,
size: 22,
color: titleColor ?? context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xs),
],
@@ -77,6 +91,24 @@ class AppListRow extends StatelessWidget {
],
),
),
if (value != null) ...[
const SizedBox(width: Grid.xxs),
// Inflexible, so the title's Expanded absorbs the slack and the
// value stays flush against the trailing edge; capped instead of
// flexed so a long value ellipsises rather than overflowing.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxValueWidth),
child: Text(
value!,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
),
],
if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!],
],
),
@@ -108,9 +140,9 @@ class AppListRowRaw extends StatelessWidget {
@override
Widget build(BuildContext context) {
final row = Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.twelve,
padding: EdgeInsets.symmetric(
horizontal: AppListInset.of(context),
vertical: _rowVerticalPadding,
),
child: Row(
children: [
@@ -138,51 +170,3 @@ class AppListRowRaw extends StatelessWidget {
return InkWell(onTap: onTap, child: row);
}
}
/// A group of list rows separated from the previous group by a hairline
/// divider with breathing room, Slack-style. An optional [label] renders a
/// small muted header above the rows.
class AppListSection extends StatelessWidget {
const AppListSection({
super.key,
this.label,
required this.children,
this.showDivider = true,
});
final String? label;
final List<Widget> children;
final bool showDivider;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDivider)
Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Divider(height: 1, color: context.colors.outlineVariant),
),
if (label != null)
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.quarter,
),
child: Text(
label!.toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
),
),
),
...children,
],
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import 'app_list_inset.dart';
/// A group of list rows in a rounded container, with an optional [label] above
/// it. Rows inside are hairline-separated and inset to the card rather than the
/// page, via [AppListInset].
class AppListCard extends StatelessWidget {
const AppListCard({super.key, this.label, required this.children});
/// Rendered above the card in sentence case, as written — no uppercasing.
final String? label;
final List<Widget> children;
static const _inset = Grid.xs;
/// Separators start at the label column, clearing the leading icon.
static const _dividerIndent = _inset + _iconColumnWidth;
static const _iconColumnWidth = 22.0 + Grid.xs;
@override
Widget build(BuildContext context) {
final separated = <Widget>[];
for (var index = 0; index < children.length; index++) {
if (index > 0) {
separated.add(
Divider(
height: 1,
thickness: 1,
indent: _dividerIndent,
endIndent: _inset,
// The scheme's own border tokens are derived from the page surface,
// which lands them within a few levels of the card fill — invisible.
// Tinting with the text color instead keeps the hairline readable on
// the card in both brightnesses.
color: context.colors.onSurface.withValues(alpha: 0.12),
),
);
}
separated.add(children[index]);
}
return Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null)
Padding(
padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs),
child: Text(
label!,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Material(
// The softest step on the elevation ramp — a rung below the home tab
// bar's active pill (primaryContainer, see PR #2810), since a
// full-width card at the pill's contrast reads heavier than the pill
// does. The dividers carry the group structure, so the fill only has
// to separate the card from the page.
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
// Keeps row ripples inside the rounded corners.
clipBehavior: Clip.antiAlias,
child: AppListInset(
horizontal: _inset,
child: Column(children: separated),
),
),
],
),
);
}
}
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// The horizontal padding list rows use, allowing grouped containers to replace
/// the page-level inset without changing each row.
class AppListInset extends InheritedWidget {
const AppListInset({
super.key,
required this.horizontal,
required super.child,
});
final double horizontal;
static double of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AppListInset>()?.horizontal ??
Grid.gutter;
@override
bool updateShouldNotify(AppListInset oldWidget) =>
oldWidget.horizontal != horizontal;
}
+12 -1
View File
@@ -36,6 +36,11 @@ class FrostedAppBar extends StatelessWidget {
/// Color applied to icons in the app bar.
final Color? iconColor;
/// Paints over the frosted fill instead of the default translucent surface.
/// Used by the Buzz themes to carry their branded gradient across the app's
/// top section — see [buzzTopSectionGradient].
final Gradient? gradient;
const FrostedAppBar({
super.key,
this.leading,
@@ -43,6 +48,7 @@ class FrostedAppBar extends StatelessWidget {
this.actions = const [],
this.horizontalInset = Grid.quarter,
this.iconColor,
this.gradient,
});
@override
@@ -75,7 +81,12 @@ class FrostedAppBar extends StatelessWidget {
child: Container(
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
color: context.colors.surface.withValues(alpha: 0.5),
// A gradient and a color cannot both paint, so the gradient
// replaces the frosted surface fill when one is supplied.
color: gradient == null
? context.colors.surface.withValues(alpha: 0.5)
: null,
gradient: gradient,
border: Border(
bottom: BorderSide(
color: context.colors.outlineVariant.withValues(alpha: 0.3),
@@ -0,0 +1,356 @@
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
/// Avatar-with-badge geometry ported from desktop's `MaskedAvatarBadgeFrame`
/// (`desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx`).
///
/// A badge (presence dot, status glyph) sits in a notch cut out of the avatar
/// rather than on top of it. The notch is not a plain circular subtraction: the
/// two edges are joined by cubic fillets so the avatar's outline flows into the
/// cutout instead of meeting it at a cusp. That fillet is the whole visual
/// signature, which is why the curve constants are ported verbatim.
@immutable
class AvatarBadgeCurve {
const AvatarBadgeCurve({
this.avatarRoundingAngle = 0.075,
this.cutoutRoundingLength = 5.5,
this.cutoutRoundingMinAngle = 0.22,
this.cutoutRoundingMaxAngle = 0.38,
this.handleDistanceRatio = 0.42,
this.handleLengthRatio = 0.14,
});
/// Radians the avatar's edge pulls back from the intersection before the
/// fillet starts.
final double avatarRoundingAngle;
/// Fillet length along the cutout, converted to an angle by dividing by the
/// cutout radius and clamped between the two angle bounds below.
final double cutoutRoundingLength;
final double cutoutRoundingMinAngle;
final double cutoutRoundingMaxAngle;
/// Bezier handle length, as a fraction of the gap between the two fillet
/// endpoints and of the cutout radius — whichever is shorter wins.
final double handleDistanceRatio;
final double handleLengthRatio;
/// Desktop's `DEFAULT_AVATAR_BADGE_CURVE`, used for large badges.
static const standard = AvatarBadgeCurve();
/// Desktop's `STATUS_DOT_MASK_CURVE` — a rounder, softer notch that reads
/// correctly at presence-dot sizes.
static const statusDot = AvatarBadgeCurve(
avatarRoundingAngle: 0.11,
cutoutRoundingLength: 6.5,
cutoutRoundingMinAngle: 0.28,
cutoutRoundingMaxAngle: 0.44,
handleDistanceRatio: 0.5,
handleLengthRatio: 0.2,
);
@override
bool operator ==(Object other) =>
other is AvatarBadgeCurve &&
other.avatarRoundingAngle == avatarRoundingAngle &&
other.cutoutRoundingLength == cutoutRoundingLength &&
other.cutoutRoundingMinAngle == cutoutRoundingMinAngle &&
other.cutoutRoundingMaxAngle == cutoutRoundingMaxAngle &&
other.handleDistanceRatio == handleDistanceRatio &&
other.handleLengthRatio == handleLengthRatio;
@override
int get hashCode => Object.hash(
avatarRoundingAngle,
cutoutRoundingLength,
cutoutRoundingMinAngle,
cutoutRoundingMaxAngle,
handleDistanceRatio,
handleLengthRatio,
);
}
/// Where the notch sits and how big the badge in it is, as fractions of the
/// avatar box so one constant covers every avatar size.
@immutable
class AvatarBadgeMaskGeometry {
const AvatarBadgeMaskGeometry({
required this.cutoutCenterRatio,
required this.cutoutRadiusRatio,
required this.badgeSizeRatio,
required this.curve,
});
/// Distance of the notch centre from the top-left, on both axes.
final double cutoutCenterRatio;
final double cutoutRadiusRatio;
/// The badge is centred on the notch and is deliberately smaller than it, so
/// a ring of background shows between badge and avatar.
final double badgeSizeRatio;
final AvatarBadgeCurve curve;
/// Desktop's settings-card treatment: a 192px avatar with a 54px badge notched
/// in at (165, 165) with radius 30.
static const badge = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 165 / 192,
cutoutRadiusRatio: 30 / 192,
badgeSizeRatio: 54 / 192,
curve: AvatarBadgeCurve.standard,
);
/// Desktop's sidebar presence treatment: a 32px avatar with a 14px dot frame
/// notched in at (28, 28) with radius 7.5.
static const presenceDot = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 28 / 32,
cutoutRadiusRatio: 7.5 / 32,
badgeSizeRatio: 14 / 32,
curve: AvatarBadgeCurve.statusDot,
);
Offset cutoutCenter(double size) =>
Offset(size * cutoutCenterRatio, size * cutoutCenterRatio);
double cutoutRadius(double size) => size * cutoutRadiusRatio;
double badgeSize(double size) => size * badgeSizeRatio;
@override
bool operator ==(Object other) =>
other is AvatarBadgeMaskGeometry &&
other.cutoutCenterRatio == cutoutCenterRatio &&
other.cutoutRadiusRatio == cutoutRadiusRatio &&
other.badgeSizeRatio == badgeSizeRatio &&
other.curve == curve;
@override
int get hashCode =>
Object.hash(cutoutCenterRatio, cutoutRadiusRatio, badgeSizeRatio, curve);
}
double _angleTo(Offset center, Offset point) =>
math.atan2(point.dy - center.dy, point.dx - center.dx);
Offset _pointOn(Offset center, double radius, double angle) =>
center + Offset(math.cos(angle), math.sin(angle)) * radius;
/// Unit tangent at [angle]; [clockwise] picks which way around the circle.
Offset _tangent(double angle, {required bool clockwise}) => clockwise
? Offset(-math.sin(angle), math.cos(angle))
: Offset(math.sin(angle), -math.cos(angle));
/// Signed sweep from [startAngle] to [endAngle], ported from desktop's
/// `getArcSweep`. Flutter shares SVG's angle convention (y grows downward, so a
/// positive sweep runs clockwise on screen), which is why the port is direct.
double _arcSweep(
double startAngle,
double endAngle, {
required bool clockwise,
required bool largeArc,
}) {
const fullTurn = math.pi * 2;
var sweep = clockwise ? endAngle - startAngle : startAngle - endAngle;
while (sweep < 0) {
sweep += fullTurn;
}
if (largeArc && sweep < math.pi) sweep += fullTurn;
if (!largeArc && sweep > math.pi) sweep -= fullTurn;
return clockwise ? sweep : -sweep;
}
/// The avatar outline with [geometry]'s notch cut out of its bottom-right.
///
/// Falls back to a plain circle when the notch does not actually cross the
/// avatar's edge — there is nothing to fillet in that case.
Path avatarBadgeMaskPath({
required double size,
required AvatarBadgeMaskGeometry geometry,
}) {
final avatarRadius = size / 2;
final avatarCenter = Offset(avatarRadius, avatarRadius);
final avatarRect = Rect.fromCircle(
center: avatarCenter,
radius: avatarRadius,
);
final cutoutCenter = geometry.cutoutCenter(size);
final cutoutRadius = geometry.cutoutRadius(size);
final curve = geometry.curve;
final span = cutoutCenter - avatarCenter;
final distance = span.distance;
if (distance >= avatarRadius + cutoutRadius ||
distance <= (avatarRadius - cutoutRadius).abs()) {
return Path()..addOval(avatarRect);
}
// Chord where the two circles cross, split into the upper and lower crossing.
final distanceToMidpoint =
(avatarRadius * avatarRadius -
cutoutRadius * cutoutRadius +
distance * distance) /
(2 * distance);
final halfChord = math.sqrt(
math.max(
0,
avatarRadius * avatarRadius - distanceToMidpoint * distanceToMidpoint,
),
);
final unit = span / distance;
final midpoint = avatarCenter + unit * distanceToMidpoint;
final perpendicular = Offset(-unit.dy, unit.dx);
final first = midpoint + perpendicular * halfChord;
final second = midpoint - perpendicular * halfChord;
final upperCrossing = first.dy < second.dy ? first : second;
final lowerCrossing = first.dy < second.dy ? second : first;
final cutoutRoundingAngle = math.min(
curve.cutoutRoundingMaxAngle,
math.max(
curve.cutoutRoundingMinAngle,
curve.cutoutRoundingLength / cutoutRadius,
),
);
// Pull each edge back from its crossing; the gap left behind is the fillet.
final avatarUpperAngle =
_angleTo(avatarCenter, upperCrossing) - curve.avatarRoundingAngle;
final avatarLowerAngle =
_angleTo(avatarCenter, lowerCrossing) + curve.avatarRoundingAngle;
final cutoutUpperAngle =
_angleTo(cutoutCenter, upperCrossing) - cutoutRoundingAngle;
final cutoutLowerAngle =
_angleTo(cutoutCenter, lowerCrossing) + cutoutRoundingAngle;
final avatarUpper = _pointOn(avatarCenter, avatarRadius, avatarUpperAngle);
final avatarLower = _pointOn(avatarCenter, avatarRadius, avatarLowerAngle);
final cutoutUpper = _pointOn(cutoutCenter, cutoutRadius, cutoutUpperAngle);
final cutoutLower = _pointOn(cutoutCenter, cutoutRadius, cutoutLowerAngle);
final upperHandle = math.min(
cutoutRadius * curve.handleLengthRatio,
(cutoutUpper - avatarUpper).distance * curve.handleDistanceRatio,
);
final lowerHandle = math.min(
cutoutRadius * curve.handleLengthRatio,
(avatarLower - cutoutLower).distance * curve.handleDistanceRatio,
);
final upperFromCutout =
cutoutUpper + _tangent(cutoutUpperAngle, clockwise: true) * upperHandle;
final upperIntoAvatar =
avatarUpper - _tangent(avatarUpperAngle, clockwise: false) * upperHandle;
final lowerFromAvatar =
avatarLower + _tangent(avatarLowerAngle, clockwise: false) * lowerHandle;
final lowerIntoCutout =
cutoutLower - _tangent(cutoutLowerAngle, clockwise: true) * lowerHandle;
return Path()
..moveTo(cutoutUpper.dx, cutoutUpper.dy)
..cubicTo(
upperFromCutout.dx,
upperFromCutout.dy,
upperIntoAvatar.dx,
upperIntoAvatar.dy,
avatarUpper.dx,
avatarUpper.dy,
)
// The long way round the avatar, back to the other side of the notch.
..arcTo(
avatarRect,
avatarUpperAngle,
_arcSweep(
avatarUpperAngle,
avatarLowerAngle,
clockwise: false,
largeArc: true,
),
false,
)
..cubicTo(
lowerFromAvatar.dx,
lowerFromAvatar.dy,
lowerIntoCutout.dx,
lowerIntoCutout.dy,
cutoutLower.dx,
cutoutLower.dy,
)
// Back across the notch itself, which bites into the avatar.
..arcTo(
Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius),
cutoutLowerAngle,
_arcSweep(
cutoutLowerAngle,
cutoutUpperAngle,
clockwise: true,
largeArc: false,
),
false,
)
..close();
}
/// Clips an avatar to the rounded badge-notch path described by [geometry].
class AvatarBadgeMaskClipper extends CustomClipper<Path> {
const AvatarBadgeMaskClipper({required this.geometry});
final AvatarBadgeMaskGeometry geometry;
@override
Path getClip(Size size) =>
avatarBadgeMaskPath(size: size.shortestSide, geometry: geometry);
@override
bool shouldReclip(covariant AvatarBadgeMaskClipper oldClipper) =>
oldClipper.geometry != geometry;
}
/// A square [avatar] with [badge] seated in a notch cut out of its bottom-right
/// corner. With no [badge] the avatar is left whole — there is nothing to make
/// room for.
class MaskedAvatarBadge extends StatelessWidget {
const MaskedAvatarBadge({
super.key,
required this.size,
required this.avatar,
this.geometry = AvatarBadgeMaskGeometry.badge,
this.badge,
});
final double size;
final Widget avatar;
final AvatarBadgeMaskGeometry geometry;
final Widget? badge;
@override
Widget build(BuildContext context) {
if (badge == null) {
return SizedBox.square(dimension: size, child: avatar);
}
final badgeSize = geometry.badgeSize(size);
final center = geometry.cutoutCenter(size);
return SizedBox.square(
dimension: size,
child: Stack(
// The notch reaches past the avatar box, so the badge does too.
clipBehavior: Clip.none,
children: [
ClipPath(
clipper: AvatarBadgeMaskClipper(geometry: geometry),
child: SizedBox.square(dimension: size, child: avatar),
),
Positioned(
left: center.dx - badgeSize / 2,
top: center.dy - badgeSize / 2,
width: badgeSize,
height: badgeSize,
child: badge!,
),
],
),
);
}
}
@@ -11,6 +11,7 @@ import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
@@ -42,7 +43,7 @@ void main() {
),
home: const Stack(
children: [
ChannelsPage(),
ChannelsPage(settingsPageBuilder: _buildSettingsPage),
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: true,
@@ -117,6 +118,24 @@ void main() {
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
});
testWidgets('opens the settings page supplied by the app layer', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pumpAndSettle();
expect(find.text('Injected settings'), findsOneWidget);
});
testWidgets('quick actions slide behind navigation when leaving home', (
tester,
) async {
@@ -885,6 +904,9 @@ void main() {
});
}
Widget _buildSettingsPage(BuildContext context) =>
const Scaffold(body: Text('Injected settings'));
class _FakeNotifier extends ChannelsNotifier {
final List<Channel> _channels;
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
@@ -18,8 +18,8 @@ import 'package:buzz/features/channels/compose_bar.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart';
import 'package:buzz/features/custom_emoji/custom_emoji.dart';
import 'package:buzz/features/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
+14 -3
View File
@@ -12,7 +12,10 @@ void main() {
) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
child: MaterialApp(
theme: AppTheme.light(),
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
),
),
);
await tester.pump();
@@ -67,7 +70,10 @@ void main() {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
child: MaterialApp(
theme: AppTheme.light(),
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
),
),
);
await tester.pump();
@@ -95,7 +101,10 @@ void main() {
) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
child: MaterialApp(
theme: AppTheme.light(),
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
),
),
);
await tester.pump();
@@ -135,3 +144,5 @@ void main() {
expect(opacity(), closeTo(1, 0.001));
});
}
Widget _buildSettingsPage(BuildContext context) => const SizedBox.shrink();
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
void main() {
Widget harness({bool showPresence = true, String presence = 'online'}) {
return ProviderScope(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => _FakePresenceNotifier(presence)),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Center(child: ProfileAvatar(showPresence: showPresence)),
),
);
}
testWidgets('seats the presence dot in a notch instead of over the avatar', (
tester,
) async {
await tester.pumpWidget(harness());
await tester.pumpAndSettle();
final badge = tester.widget<MaskedAvatarBadge>(
find.byType(MaskedAvatarBadge),
);
expect(badge.geometry, AvatarBadgeMaskGeometry.presenceDot);
expect(
tester.widget<ClipPath>(find.byType(ClipPath).last).clipper,
isA<AvatarBadgeMaskClipper>(),
);
// The notch supplies the separation, so the dot carries no border ring.
final dot = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((box) => box.decoration)
.whereType<BoxDecoration>()
.firstWhere((decoration) => decoration.shape == BoxShape.circle);
expect(dot.border, isNull);
});
Color dotColor(WidgetTester tester) => tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((box) => box.decoration)
.whereType<BoxDecoration>()
.firstWhere((decoration) => decoration.shape == BoxShape.circle)
.color!;
final theme = AppTheme.light();
testWidgets('paints the dot with the presence color', (tester) async {
await tester.pumpWidget(harness());
await tester.pumpAndSettle();
expect(dotColor(tester), theme.extension<AppColors>()!.success);
});
testWidgets('mutes the dot when offline', (tester) async {
await tester.pumpWidget(harness(presence: 'offline'));
await tester.pumpAndSettle();
expect(dotColor(tester), theme.colorScheme.outline);
});
testWidgets('leaves the avatar unmasked when presence is hidden', (
tester,
) async {
await tester.pumpWidget(harness(showPresence: false));
await tester.pumpAndSettle();
final badge = tester.widget<MaskedAvatarBadge>(
find.byType(MaskedAvatarBadge),
);
expect(badge.badge, isNull);
expect(
find.descendant(
of: find.byType(MaskedAvatarBadge),
matching: find.byType(ClipPath),
),
findsNothing,
);
});
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _FakePresenceNotifier extends PresenceNotifier {
_FakePresenceNotifier(this._presence);
final String _presence;
@override
Future<String> build() async => _presence;
}
@@ -0,0 +1,72 @@
import 'package:buzz/features/profile/set_status_sheet.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('removes only the emoji from an existing status', (tester) async {
final statusNotifier = _RecordingUserStatusNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
customEmojiListProvider.overrideWithValue(const []),
userStatusProvider.overrideWith(() => statusNotifier),
],
child: Builder(
builder: (context) => FilledButton(
onPressed: () => showSetStatusSheet(
context,
currentStatus: const UserStatus(
text: 'Focusing',
emoji: '\u{1F3AF}',
updatedAt: 1,
),
),
child: const Text('Open status editor'),
),
),
),
);
await tester.tap(find.text('Open status editor'));
await tester.pumpAndSettle();
expect(find.text('\u{1F3AF}'), findsOneWidget);
expect(find.byTooltip('Remove status emoji'), findsOneWidget);
await tester.tap(find.byTooltip('Remove status emoji'));
await tester.pump();
expect(find.text('\u{1F3AF}'), findsNothing);
expect(find.byIcon(LucideIcons.smilePlus), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
'Focusing',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(statusNotifier.savedText, 'Focusing');
expect(statusNotifier.savedEmoji, isEmpty);
});
}
class _RecordingUserStatusNotifier extends UserStatusNotifier {
String? savedText;
String? savedEmoji;
@override
Future<UserStatus?> build() async => null;
@override
Future<void> setStatus(String text, String emoji) async {
savedText = text;
savedEmoji = emoji;
}
}
@@ -0,0 +1,63 @@
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/settings_profile_header.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('uses a bounded icon for an unresolved status shortcode', (
tester,
) async {
const missingShortcode = ':very_long_missing_custom_emoji:';
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
userStatusProvider.overrideWith(
() => _FakeUserStatusNotifier(
const UserStatus(
text: 'Focusing',
emoji: missingShortcode,
updatedAt: 1,
),
),
),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
final badge = find.byType(MaskedAvatarBadge);
expect(
find.descendant(of: badge, matching: find.text(missingShortcode)),
findsNothing,
);
expect(
find.descendant(of: badge, matching: find.byIcon(LucideIcons.smile)),
findsOneWidget,
);
});
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _FakeUserStatusNotifier extends UserStatusNotifier {
_FakeUserStatusNotifier(this._status);
final UserStatus _status;
@override
Future<UserStatus?> build() async => _status;
}
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:buzz/features/settings/accent_picker_page.dart';
import 'package:buzz/features/settings/theme_picker_page.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/widget_helpers.dart';
Future<SharedPreferences> _prefs(Map<String, Object> initial) async {
SharedPreferences.setMockInitialValues(initial);
return SharedPreferences.getInstance();
}
Future<void> _pumpPicker(
WidgetTester tester,
Widget page, {
Map<String, Object> prefs = const {},
}) async {
final instance = await _prefs(prefs);
await tester.pumpWidget(
WidgetHelpers.testable(
child: page,
overrides: [savedPrefsProvider.overrideWithValue(instance)],
),
);
await tester.pumpAndSettle();
}
Future<void> _search(WidgetTester tester, String query) async {
await tester.enterText(find.byType(TextField), query);
await tester.pumpAndSettle();
}
void main() {
group('ThemePickerPage', () {
testWidgets('system mode offers pairs under their stripped label', (
tester,
) async {
await _pumpPicker(tester, const ThemePickerPage());
await _search(tester, 'github');
// Paired labels drop the brightness token — one row stands for both halves.
expect(find.text('Github'), findsOneWidget);
expect(find.text('Github Default'), findsOneWidget);
expect(find.text('Github Light'), findsNothing);
expect(find.text('Github Dark'), findsNothing);
});
testWidgets('system mode hides themes with no counterpart', (tester) async {
await _pumpPicker(tester, const ThemePickerPage());
// 'snazzy-light' is light but unpaired, so it cannot follow the OS.
await _search(tester, 'snazzy');
expect(find.text('No themes found'), findsOneWidget);
});
testWidgets('system mode normalizes a stored unpaired theme', (
tester,
) async {
final instance = await _prefs({'buzz_color_scheme': 'snazzy-light'});
await tester.pumpWidget(
WidgetHelpers.testable(
child: const ThemePickerPage(),
overrides: [savedPrefsProvider.overrideWithValue(instance)],
),
);
await tester.pumpAndSettle();
expect(
instance.getString('buzz_color_scheme'),
themeGroups().paired.first.name,
);
});
testWidgets('light mode lists light themes by their full name', (
tester,
) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_theme_mode': 'light'},
);
await _search(tester, 'github');
expect(find.text('Github Light'), findsOneWidget);
expect(find.text('Github Light Default'), findsOneWidget);
expect(find.text('Github Dark'), findsNothing);
});
testWidgets('light mode includes unpaired light themes', (tester) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_theme_mode': 'light'},
);
await _search(tester, 'snazzy');
expect(find.text('Snazzy Light'), findsOneWidget);
});
testWidgets('dark mode lists dark themes only', (tester) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_theme_mode': 'dark'},
);
await _search(tester, 'github');
expect(find.text('Github Dark'), findsOneWidget);
expect(find.text('Github Light'), findsNothing);
});
testWidgets('checks the row matching the stored selection', (tester) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_theme_mode': 'light', 'buzz_color_scheme': 'nord'},
);
await _search(tester, 'nord');
// 'nord' is dark, so Light mode renders its light fallback instead — the
// checkmark must follow what is applied, not the raw stored value.
expect(find.byIcon(LucideIcons.check), findsNothing);
});
testWidgets('either half of a pair checks the same system row', (
tester,
) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_color_scheme': 'github-dark'},
);
await _search(tester, 'github');
final checked = tester
.widgetList<ListTile>(find.byType(ListTile))
.where((tile) => tile.trailing != null);
expect(checked, hasLength(1));
expect(find.text('Github'), findsOneWidget);
});
testWidgets('tapping a theme persists the selection', (tester) async {
final instance = await _prefs({'buzz_theme_mode': 'dark'});
await tester.pumpWidget(
WidgetHelpers.testable(
child: const ThemePickerPage(),
overrides: [savedPrefsProvider.overrideWithValue(instance)],
),
);
await tester.pumpAndSettle();
await _search(tester, 'nord');
await tester.tap(find.text('Nord'));
await tester.pumpAndSettle();
expect(instance.getString('buzz_color_scheme'), 'nord');
});
});
group('AccentPickerPage', () {
testWidgets('lists every accent and checks the stored one', (tester) async {
await _pumpPicker(
tester,
const AccentPickerPage(),
prefs: {'buzz_accent_color': 2},
);
for (final accent in accentColors) {
expect(find.text(accent.name), findsOneWidget);
}
expect(find.byIcon(LucideIcons.check), findsOneWidget);
});
testWidgets('tapping an accent persists the index', (tester) async {
final instance = await _prefs(const <String, Object>{});
await tester.pumpWidget(
WidgetHelpers.testable(
child: const AccentPickerPage(),
overrides: [savedPrefsProvider.overrideWithValue(instance)],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Green'));
await tester.pumpAndSettle();
expect(
instance.getInt('buzz_accent_color'),
accentColors.indexWhere((a) => a.name == 'Green'),
);
});
});
}
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
NostrEvent _event(
@@ -5,7 +5,7 @@ import 'package:buzz/shared/theme/theme.dart';
void main() {
group('default accent', () {
test('uses black on the default light scheme', () {
final resolved = resolveSchemes(null);
final resolved = resolveSchemes(null, ThemeMode.light);
final accented = applyAccent(resolved.light, defaultAccentIndex);
@@ -13,7 +13,7 @@ void main() {
});
test('uses the theme foreground on forced dark schemes', () {
final resolved = resolveSchemes('github-dark');
final resolved = resolveSchemes('github-dark', ThemeMode.dark);
final base = resolved.dark;
final accented = applyAccent(base, defaultAccentIndex);
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
void main() {
group('Buzz theme catalog entries', () {
test('both halves are in the catalog', () {
expect(findTheme(buzzThemeName), isNotNull);
expect(findTheme(buzzDarkThemeName), isNotNull);
});
test('borrow the GitHub palettes', () {
final buzz = findTheme(buzzThemeName)!;
final github = findTheme('github-light')!;
expect(buzz.bg, github.bg);
expect(buzz.fg, github.fg);
expect(buzz.comment, github.comment);
final buzzDark = findTheme(buzzDarkThemeName)!;
final githubDark = findTheme('github-dark')!;
expect(buzzDark.bg, githubDark.bg);
expect(buzzDark.fg, githubDark.fg);
expect(buzzDark.comment, githubDark.comment);
});
test('are a light/dark pair', () {
expect(findTheme(buzzThemeName)!.isDark, isFalse);
expect(findTheme(buzzDarkThemeName)!.isDark, isTrue);
expect(themePairFor(buzzThemeName), buzzDarkThemeName);
expect(themePairFor(buzzDarkThemeName), buzzThemeName);
});
test('appear as a single System-mode option labelled "Buzz"', () {
final paired = themeGroups().paired.map((t) => t.name);
expect(paired, contains(buzzThemeName));
expect(paired, isNot(contains(buzzDarkThemeName)));
expect(pairedThemeLabel(buzzThemeName), 'Buzz');
expect(themeSelectionLabel(buzzThemeName, ThemeMode.system), 'Buzz');
expect(themeSelectionLabel(buzzDarkThemeName, ThemeMode.system), 'Buzz');
});
test('resolve across brightnesses like any other pair', () {
final resolved = resolveSchemes(buzzThemeName, ThemeMode.system);
expect(resolved.forcedMode, isNull);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.dark.brightness, Brightness.dark);
expect(resolved.lightTheme?.name, buzzThemeName);
expect(resolved.darkTheme?.name, buzzDarkThemeName);
expect(
effectiveTheme(buzzThemeName, ThemeMode.dark)?.name,
buzzDarkThemeName,
);
expect(
effectiveTheme(buzzDarkThemeName, ThemeMode.light)?.name,
buzzThemeName,
);
});
test(
'fallbacks expose the effective Buzz theme for gradient selection',
() {
final coerced = resolveSchemes('nord', ThemeMode.light);
expect(coerced.lightTheme?.name, buzzThemeName);
expect(
buzzTopSectionGradient(
coerced.lightTheme!.name,
coerced.light.brightness,
),
isNotNull,
);
final unknown = resolveSchemes('not-a-theme', ThemeMode.light);
expect(unknown.lightTheme?.name, buzzThemeName);
expect(
buzzTopSectionGradient(
unknown.lightTheme!.name,
unknown.light.brightness,
),
isNotNull,
);
},
);
});
group('buzzTopSectionGradient', () {
test('is null for non-Buzz themes', () {
expect(buzzTopSectionGradient('github-light', Brightness.light), isNull);
expect(buzzTopSectionGradient('nord', Brightness.dark), isNull);
});
test('paints top to bottom for both halves of the pair', () {
for (final name in [buzzThemeName, buzzDarkThemeName]) {
final gradient = buzzTopSectionGradient(name, Brightness.light);
expect(gradient, isNotNull, reason: '$name should be gradient-backed');
expect(gradient!.begin, Alignment.topCenter);
expect(gradient.end, Alignment.bottomCenter);
expect(gradient.colors, hasLength(2));
}
});
test('brightness selects the stops, not the theme name', () {
// Both halves enable the gradient, so System mode keeps it on across an
// OS switch — the applied brightness alone decides which stops are used.
final light = buzzTopSectionGradient(buzzThemeName, Brightness.light)!;
final dark = buzzTopSectionGradient(buzzThemeName, Brightness.dark)!;
expect(light.colors, isNot(dark.colors));
expect(
buzzTopSectionGradient(buzzDarkThemeName, Brightness.dark)!.colors,
dark.colors,
);
expect(
buzzTopSectionGradient(buzzDarkThemeName, Brightness.light)!.colors,
light.colors,
);
});
test('is opaque so the color replaces the frosted fill', () {
for (final brightness in Brightness.values) {
final gradient = buzzTopSectionGradient(buzzThemeName, brightness)!;
for (final color in gradient.colors) {
expect(color.a, 1.0);
}
}
});
});
group('theme threading', () {
BoxDecoration barDecoration(WidgetTester tester) {
final container = tester
.widgetList<Container>(
find.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(Container),
),
)
.first;
return container.decoration! as BoxDecoration;
}
Widget harness(ThemeData theme) => MaterialApp(
theme: theme,
home: Builder(
builder: (context) => Stack(
children: [
FrostedAppBar(
gradient: context.appColors.topSectionGradient,
title: const Text('Home'),
),
],
),
),
);
testWidgets('AppTheme carries the gradient to the top section', (
tester,
) async {
await tester.pumpWidget(
harness(
AppTheme.light(
topSectionGradient: buzzTopSectionGradient(
buzzThemeName,
Brightness.light,
),
),
),
);
final decoration = barDecoration(tester);
expect(decoration.gradient, isNotNull);
// A BoxDecoration cannot paint a color and a gradient at once.
expect(decoration.color, isNull);
});
testWidgets('non-Buzz themes keep the frosted surface fill', (
tester,
) async {
await tester.pumpWidget(harness(AppTheme.light()));
final decoration = barDecoration(tester);
expect(decoration.gradient, isNull);
expect(decoration.color, isNotNull);
});
});
group('isBuzzTheme', () {
test('matches only the Buzz pair', () {
expect(isBuzzTheme(buzzThemeName), isTrue);
expect(isBuzzTheme(buzzDarkThemeName), isTrue);
expect(isBuzzTheme('github-light'), isFalse);
expect(isBuzzTheme(''), isFalse);
});
});
}
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
void main() {
group('themePairs', () {
test('every pair member exists in the catalog', () {
for (final entry in themePairs.entries) {
expect(
findTheme(entry.key),
isNotNull,
reason: '${entry.key} is not in themeCatalog',
);
expect(
findTheme(entry.value),
isNotNull,
reason: '${entry.value} is not in themeCatalog',
);
}
});
test('pairs map a light theme to a dark one', () {
for (final entry in themePairs.entries) {
expect(
findTheme(entry.key)!.isDark,
isFalse,
reason: '${entry.key} should be light',
);
expect(
findTheme(entry.value)!.isDark,
isTrue,
reason: '${entry.value} should be dark',
);
}
});
test('resolves in both directions', () {
expect(themePairFor('github-light'), 'github-dark');
expect(themePairFor('github-dark'), 'github-light');
expect(themePairFor('andromeeda'), isNull);
expect(isPairedTheme('catppuccin-latte'), isTrue);
expect(isPairedTheme('andromeeda'), isFalse);
});
});
group('themeGroups', () {
test('light and dark partition the whole catalog', () {
final groups = themeGroups();
expect(groups.light.length + groups.dark.length, themeCatalog.length);
expect(groups.light.every((t) => !t.isDark), isTrue);
expect(groups.dark.every((t) => t.isDark), isTrue);
});
test('paired holds only light members of pairs', () {
final groups = themeGroups();
expect(groups.paired.length, themePairs.length);
for (final theme in groups.paired) {
expect(theme.isDark, isFalse);
expect(themePairs.containsKey(theme.name), isTrue);
}
});
test('groups are sorted by display name', () {
final groups = themeGroups();
for (final list in [groups.paired, groups.light, groups.dark]) {
final names = list.map((t) => t.displayName).toList();
expect(names, orderedEquals(List.of(names)..sort()));
}
});
});
group('pairedThemeLabel', () {
test('strips brightness tokens', () {
expect(pairedThemeLabel('github-light'), 'Github');
expect(pairedThemeLabel('github-light-default'), 'Github Default');
expect(pairedThemeLabel('gruvbox-light-soft'), 'Gruvbox Soft');
expect(pairedThemeLabel('material-theme-lighter'), 'Material Theme');
expect(pairedThemeLabel('catppuccin-latte'), 'Catppuccin');
});
test('falls back to the raw name when stripping empties it', () {
expect(pairedThemeLabel('light-plus'), 'Light Plus');
});
test('never produces a blank label for a real pair', () {
for (final lightName in themePairs.keys) {
expect(pairedThemeLabel(lightName), isNotEmpty);
}
});
});
group('resolveSchemes', () {
test('system mode pairs the selection across brightnesses', () {
final resolved = resolveSchemes('github-light', ThemeMode.system);
// Null forcedMode leaves MaterialApp following the OS.
expect(resolved.forcedMode, isNull);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.dark.brightness, Brightness.dark);
});
test('system mode works from the dark half of a pair too', () {
final resolved = resolveSchemes('github-dark', ThemeMode.system);
expect(resolved.forcedMode, isNull);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.dark.brightness, Brightness.dark);
});
test('system mode pins an unpaired theme to its own brightness', () {
final resolved = resolveSchemes('andromeeda', ThemeMode.system);
// No counterpart exists, so both sides render the selected theme rather
// than jumping to an unrelated one.
expect(resolved.forcedMode, ThemeMode.dark);
expect(resolved.light, resolved.dark);
expect(resolved.dark.brightness, Brightness.dark);
});
test('system mode pins an unpaired light theme to light mode', () {
final resolved = resolveSchemes('snazzy-light', ThemeMode.system);
expect(resolved.forcedMode, ThemeMode.light);
expect(resolved.light, resolved.dark);
expect(resolved.light.brightness, Brightness.light);
});
test('light mode forces light and swaps a dark selection for its pair', () {
final resolved = resolveSchemes('github-dark', ThemeMode.light);
expect(resolved.forcedMode, ThemeMode.light);
expect(resolved.light, resolved.dark);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.light, generateColorScheme(findTheme('github-light')!));
});
test('dark mode forces dark and swaps a light selection for its pair', () {
final resolved = resolveSchemes('github-light', ThemeMode.dark);
expect(resolved.forcedMode, ThemeMode.dark);
expect(resolved.dark.brightness, Brightness.dark);
expect(resolved.dark, generateColorScheme(findTheme('github-dark')!));
});
test('dark mode falls back to the default pair when pick is unpaired', () {
// 'snazzy-light' is light and has no dark counterpart.
final resolved = resolveSchemes('snazzy-light', ThemeMode.dark);
expect(resolved.forcedMode, ThemeMode.dark);
expect(resolved.dark.brightness, Brightness.dark);
expect(resolved.darkTheme?.name, buzzDarkThemeName);
expect(resolved.dark, generateColorScheme(findTheme(buzzDarkThemeName)!));
});
test('light mode falls back to the default pair when pick is unpaired', () {
final resolved = resolveSchemes('andromeeda', ThemeMode.light);
expect(resolved.forcedMode, ThemeMode.light);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.lightTheme?.name, buzzThemeName);
expect(resolved.light, generateColorScheme(findTheme(buzzThemeName)!));
});
test('an unknown scheme name falls back to the default theme', () {
final resolved = resolveSchemes('not-a-theme', ThemeMode.light);
expect(
resolved.light,
generateColorScheme(findTheme(defaultSchemeName)!),
);
});
});
group('schemeForAppearanceMode', () {
test('system mode replaces an unpaired selection with a paired theme', () {
expect(
schemeForAppearanceMode('snazzy-light', ThemeMode.system),
themeGroups().paired.first.name,
);
expect(
schemeForAppearanceMode('nord', ThemeMode.system),
themeGroups().paired.first.name,
);
});
test('system mode preserves either half of a paired selection', () {
expect(
schemeForAppearanceMode('github-light', ThemeMode.system),
'github-light',
);
expect(
schemeForAppearanceMode('github-dark', ThemeMode.system),
'github-dark',
);
});
test('pinned modes preserve an unpaired selection', () {
expect(
schemeForAppearanceMode('snazzy-light', ThemeMode.light),
'snazzy-light',
);
expect(schemeForAppearanceMode('nord', ThemeMode.dark), 'nord');
});
});
group('effectiveTheme', () {
test('system mode keeps the stored selection', () {
expect(
effectiveTheme('github-dark', ThemeMode.system)?.name,
'github-dark',
);
});
test('pinned modes report the coerced theme', () {
expect(
effectiveTheme('github-dark', ThemeMode.light)?.name,
'github-light',
);
expect(
effectiveTheme('github-light', ThemeMode.dark)?.name,
'github-dark',
);
});
test('a null selection resolves to the default theme', () {
expect(effectiveTheme(null, ThemeMode.light)?.name, defaultSchemeName);
});
});
group('themeSelectionLabel', () {
test('names the pair as a whole in system mode', () {
expect(themeSelectionLabel('github-light', ThemeMode.system), 'Github');
expect(themeSelectionLabel('github-dark', ThemeMode.system), 'Github');
});
test('names the concrete theme in pinned modes', () {
expect(
themeSelectionLabel('github-light', ThemeMode.light),
'Github Light',
);
expect(
themeSelectionLabel('github-light', ThemeMode.dark),
'Github Dark',
);
});
test('falls back to the theme display name when unpaired', () {
expect(themeSelectionLabel('andromeeda', ThemeMode.system), 'Andromeeda');
});
});
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/app_list.dart';
import 'package:buzz/shared/widgets/app_list_card.dart';
Widget _host(Widget child) => MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: child),
);
void main() {
group('AppListRow', () {
testWidgets('centres the leading icon against a two-line row', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.dns,
title: 'Connected to',
subtitle: 'https://relay.example.com',
),
),
);
final row = tester.getRect(find.byType(AppListRow));
final icon = tester.getRect(find.byType(Icon));
expect(icon.center.dy, closeTo(row.center.dy, 0.5));
});
testWidgets('centres the trailing control against a two-line row', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.key,
title: 'Identity',
subtitle: 'deadbeef',
trailing: Icon(Icons.copy, key: Key('trailing')),
),
),
);
final row = tester.getRect(find.byType(AppListRow));
final trailing = tester.getRect(find.byKey(const Key('trailing')));
expect(trailing.center.dy, closeTo(row.center.dy, 0.5));
});
testWidgets('keeps the value flush against the trailing edge', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.palette,
title: 'Theme',
value: 'Buzz',
trailing: Icon(Icons.chevron_right, key: Key('chevron')),
),
),
);
final value = tester.getRect(find.text('Buzz'));
final chevron = tester.getRect(find.byKey(const Key('chevron')));
// Only the row's own inter-widget gap sits between them — no flex slack.
expect(chevron.left - value.right, closeTo(Grid.xxs, 0.5));
});
});
group('AppListCard', () {
testWidgets('separates rows with dividers that clear the icon column', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListCard(
label: 'Style',
children: [
AppListRow(icon: Icons.sunny, title: 'Appearance'),
AppListRow(icon: Icons.palette, title: 'Theme'),
AppListRow(icon: Icons.water_drop, title: 'Accent color'),
],
),
),
);
final dividers = find.byType(Divider);
expect(dividers, findsNWidgets(2));
final divider = tester.widget<Divider>(dividers.first);
final scheme = AppTheme.light().colorScheme;
// Derived from the text color, not the scheme's border tokens — those sit
// within a few levels of the card fill and vanish.
expect(divider.color, scheme.onSurface.withValues(alpha: 0.12));
// The indent is painted inside the divider's box, so the line starts at
// the box's left edge plus the indent — level with the row titles.
final titleLeft = tester.getRect(find.text('Appearance')).left;
final lineLeft = tester.getRect(dividers.first).left + divider.indent!;
expect(lineLeft, closeTo(titleLeft, 0.5));
});
testWidgets('fills with the softest container step', (tester) async {
await tester.pumpWidget(
_host(
const AppListCard(children: [AppListRow(title: 'Remove community')]),
),
);
final material = tester.widget<Material>(
find.descendant(
of: find.byType(AppListCard),
matching: find.byType(Material),
),
);
expect(
material.color,
AppTheme.light().colorScheme.surfaceContainerHighest,
);
});
});
}
@@ -0,0 +1,147 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
void main() {
group('avatarBadgeMaskPath', () {
const size = 128.0;
const geometry = AvatarBadgeMaskGeometry.badge;
test('keeps the avatar but cuts the notch out of it', () {
final path = avatarBadgeMaskPath(size: size, geometry: geometry);
expect(path.contains(const Offset(size / 2, size / 2)), isTrue);
expect(path.contains(geometry.cutoutCenter(size)), isFalse);
// The far edge is untouched — the notch only bites the bottom-right.
expect(path.contains(const Offset(4, size / 2)), isTrue);
});
test('stays inside the avatar circle on the unnotched sides', () {
// Checked with contains() rather than getBounds(), which reports
// control-point bounds and so overshoots the near-full-circle arc.
final path = avatarBadgeMaskPath(size: size, geometry: geometry);
expect(path.contains(const Offset(-1, size / 2)), isFalse);
expect(path.contains(const Offset(size / 2, -1)), isFalse);
expect(path.contains(const Offset(2, 2)), isFalse);
});
test('fillets the notch rather than subtracting a bare circle', () {
// A plain difference meets the cutout at two cusps. The fillets round
// those off, which necessarily removes extra material just outside the
// cutout circle — so the masked path must be a strict subset there.
final masked = avatarBadgeMaskPath(size: size, geometry: geometry);
final cutoutCenter = geometry.cutoutCenter(size);
final cutoutRadius = geometry.cutoutRadius(size);
final bare = Path.combine(
PathOperation.difference,
Path()..addOval(
Rect.fromCircle(
center: const Offset(size / 2, size / 2),
radius: size / 2,
),
),
Path()..addOval(
Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius),
),
);
// Sample the ring just outside the cutout, where the fillets live.
var filleted = 0;
for (var step = 0; step < 360; step++) {
final angle = step * math.pi / 180;
final point =
cutoutCenter +
Offset(math.cos(angle), math.sin(angle)) * (cutoutRadius + 2);
if (bare.contains(point) && !masked.contains(point)) filleted++;
// The fillet only adds material to the cut, never puts it back.
expect(
masked.contains(point) && !bare.contains(point),
isFalse,
reason: 'mask should not extend beyond the plain difference',
);
}
expect(filleted, greaterThan(0));
});
test('falls back to a plain circle when the notch misses the avatar', () {
// A notch tucked at the centre never crosses the avatar's edge, so there
// is no crossing to fillet and nothing should be cut.
const detached = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 0.5,
cutoutRadiusRatio: 0.05,
badgeSizeRatio: 0.08,
curve: AvatarBadgeCurve.standard,
);
final path = avatarBadgeMaskPath(size: size, geometry: detached);
expect(path.contains(detached.cutoutCenter(size)), isTrue);
expect(path.getBounds().size, const Size(size, size));
});
test(
'scales geometry with the avatar, matching desktop at its own size',
() {
// Desktop's sidebar avatar is 32px with the cutout at (28, 28) r 7.5.
const presence = AvatarBadgeMaskGeometry.presenceDot;
expect(presence.cutoutCenter(32), const Offset(28, 28));
expect(presence.cutoutRadius(32), 7.5);
expect(presence.badgeSize(32), 14);
// Twice the avatar, twice the notch.
expect(presence.cutoutCenter(64), const Offset(56, 56));
expect(presence.cutoutRadius(64), 15);
},
);
});
group('MaskedAvatarBadge', () {
Widget harness({Widget? badge}) => MaterialApp(
theme: AppTheme.light(),
home: Center(
child: MaskedAvatarBadge(
size: 128,
geometry: AvatarBadgeMaskGeometry.badge,
avatar: const ColoredBox(color: Color(0xFF123456)),
badge: badge,
),
),
);
testWidgets('masks the avatar and centres the badge in the notch', (
tester,
) async {
await tester.pumpWidget(
harness(badge: const ColoredBox(color: Color(0xFFABCDEF))),
);
final clip = tester.widget<ClipPath>(find.byType(ClipPath));
expect(clip.clipper, isA<AvatarBadgeMaskClipper>());
const geometry = AvatarBadgeMaskGeometry.badge;
final frame = tester.getRect(find.byType(MaskedAvatarBadge));
final badgeRect = tester.getRect(find.byType(ColoredBox).last);
expect(badgeRect.width, closeTo(geometry.badgeSize(128), 0.01));
expect(
badgeRect.center - frame.topLeft,
within(distance: 0.01, from: geometry.cutoutCenter(128)),
);
});
testWidgets('leaves the avatar whole when there is no badge', (
tester,
) async {
await tester.pumpWidget(harness());
expect(find.byType(ClipPath), findsNothing);
expect(
tester.getSize(find.byType(MaskedAvatarBadge)),
const Size(128, 128),
);
});
});
}