mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
+29
-6
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -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
-1
@@ -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).
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
-1
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user