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/agent_activity/observer_subscription.dart';
|
||||||
import 'features/channels/deep_link_dispatcher.dart';
|
import 'features/channels/deep_link_dispatcher.dart';
|
||||||
import 'features/profile/user_status_cache_provider.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/auth/auth.dart';
|
||||||
import 'shared/deeplink/pending_deep_link_provider.dart';
|
import 'shared/deeplink/pending_deep_link_provider.dart';
|
||||||
import 'shared/relay/relay.dart';
|
import 'shared/relay/relay.dart';
|
||||||
@@ -25,13 +27,25 @@ class App extends HookConsumerWidget {
|
|||||||
final schemeName = ref.watch(schemeProvider);
|
final schemeName = ref.watch(schemeProvider);
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
final resolved = resolveSchemes(schemeName);
|
final resolved = resolveSchemes(schemeName, themeMode);
|
||||||
final lightScheme = applyAccent(resolved.light, accentIndex);
|
final lightScheme = applyAccent(resolved.light, accentIndex);
|
||||||
final darkScheme = applyAccent(resolved.dark, accentIndex);
|
final darkScheme = applyAccent(resolved.dark, accentIndex);
|
||||||
// Default and named schemes can force light or dark mode; otherwise
|
// Light/Dark modes pin the brightness; System leaves it null so Flutter
|
||||||
// respect the user's ThemeMode preference.
|
// follows the OS across the selected theme and its pair.
|
||||||
final effectiveMode = resolved.forcedMode ?? themeMode;
|
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
|
// Eagerly initialize websocket session and lifecycle observer when
|
||||||
// authenticated. These providers connect and manage the websocket.
|
// authenticated. These providers connect and manage the websocket.
|
||||||
if (authState.value?.status == AuthStatus.authenticated) {
|
if (authState.value?.status == AuthStatus.authenticated) {
|
||||||
@@ -65,15 +79,21 @@ class App extends HookConsumerWidget {
|
|||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Buzz',
|
title: 'Buzz',
|
||||||
theme: AppTheme.light(colorScheme: lightScheme),
|
theme: AppTheme.light(
|
||||||
darkTheme: AppTheme.dark(colorScheme: darkScheme),
|
colorScheme: lightScheme,
|
||||||
|
topSectionGradient: buzzLightGradient,
|
||||||
|
),
|
||||||
|
darkTheme: AppTheme.dark(
|
||||||
|
colorScheme: darkScheme,
|
||||||
|
topSectionGradient: buzzDarkGradient,
|
||||||
|
),
|
||||||
themeMode: effectiveMode,
|
themeMode: effectiveMode,
|
||||||
home: authState.when(
|
home: authState.when(
|
||||||
loading: () => const _SplashScreen(),
|
loading: () => const _SplashScreen(),
|
||||||
error: (_, _) => const PairingPage(),
|
error: (_, _) => const PairingPage(),
|
||||||
data: (state) => switch (state.status) {
|
data: (state) => switch (state.status) {
|
||||||
AuthStatus.authenticated => const DeepLinkDispatcher(
|
AuthStatus.authenticated => const DeepLinkDispatcher(
|
||||||
child: HomePage(),
|
child: HomePage(settingsPageBuilder: _buildSettingsPage),
|
||||||
),
|
),
|
||||||
_ => const DeepLinkDispatcher(
|
_ => const DeepLinkDispatcher(
|
||||||
dispatchMessageLinks: false,
|
dispatchMessageLinks: false,
|
||||||
@@ -85,6 +105,9 @@ class App extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSettingsPage(BuildContext context) =>
|
||||||
|
const SettingsPage(profileHeader: SettingsProfileHeader());
|
||||||
|
|
||||||
class _SplashScreen extends StatelessWidget {
|
class _SplashScreen extends StatelessWidget {
|
||||||
const _SplashScreen();
|
const _SplashScreen();
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../shared/auth/auth.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 '../../shared/relay/relay.dart';
|
||||||
import '../profile/profile_provider.dart';
|
import '../profile/profile_provider.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
|
||||||
import 'channel.dart';
|
import 'channel.dart';
|
||||||
import 'channels_provider.dart';
|
import 'channels_provider.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ import '../../shared/theme/theme.dart';
|
|||||||
import '../../shared/widgets/avatar_image.dart';
|
import '../../shared/widgets/avatar_image.dart';
|
||||||
import '../../shared/widgets/frosted_app_bar.dart';
|
import '../../shared/widgets/frosted_app_bar.dart';
|
||||||
import '../../shared/widgets/frosted_scaffold.dart';
|
import '../../shared/widgets/frosted_scaffold.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import '../custom_emoji/custom_emoji_render.dart';
|
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||||
import '../profile/profile_avatar.dart';
|
import '../profile/profile_avatar.dart';
|
||||||
import '../profile/profile_provider.dart';
|
import '../profile/profile_provider.dart';
|
||||||
import '../settings/settings_page.dart';
|
|
||||||
import '../profile/presence_cache_provider.dart';
|
import '../profile/presence_cache_provider.dart';
|
||||||
import '../profile/user_cache_provider.dart';
|
import '../profile/user_cache_provider.dart';
|
||||||
import '../pairing/pairing_page.dart';
|
import '../pairing/pairing_page.dart';
|
||||||
@@ -62,6 +61,19 @@ const double _kChannelLabelGap = Grid.xxs;
|
|||||||
const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter;
|
const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter;
|
||||||
const double _kChannelLabelInset =
|
const double _kChannelLabelInset =
|
||||||
_kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap;
|
_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 _kSectionExpandDuration = Duration(milliseconds: 220);
|
||||||
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
|
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
|
||||||
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
|
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
|
||||||
@@ -129,7 +141,9 @@ _UnreadChannelState _computeUnreadChannelState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ChannelsPage extends HookConsumerWidget {
|
class ChannelsPage extends HookConsumerWidget {
|
||||||
const ChannelsPage({super.key});
|
const ChannelsPage({required this.settingsPageBuilder, super.key});
|
||||||
|
|
||||||
|
final WidgetBuilder settingsPageBuilder;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -208,7 +222,11 @@ class ChannelsPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
return FrostedScaffold(
|
return FrostedScaffold(
|
||||||
appBar: FrostedAppBar(
|
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(
|
leading: _CommunityIndicator(
|
||||||
onTap: () => showModalBottomSheet<void>(
|
onTap: () => showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -219,9 +237,9 @@ class ChannelsPage extends HookConsumerWidget {
|
|||||||
title: const SizedBox.shrink(),
|
title: const SizedBox.shrink(),
|
||||||
actions: [
|
actions: [
|
||||||
ProfileAvatar(
|
ProfileAvatar(
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(
|
||||||
MaterialPageRoute<void>(builder: (_) => const SettingsPage()),
|
context,
|
||||||
),
|
).push(MaterialPageRoute<void>(builder: settingsPageBuilder)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,20 +41,19 @@ class _ChannelTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (channel.isDm)
|
SizedBox(
|
||||||
_DmAvatar(channel: channel, currentPubkey: currentPubkey)
|
width: _kChannelLeadingWidth,
|
||||||
else
|
child: Align(
|
||||||
SizedBox(
|
alignment: Alignment.centerLeft,
|
||||||
width: _kChannelLeadingWidth,
|
child: channel.isDm
|
||||||
child: Align(
|
? _DmAvatar(channel: channel, currentPubkey: currentPubkey)
|
||||||
alignment: Alignment.centerLeft,
|
: Icon(
|
||||||
child: Icon(
|
channelIcon(channel),
|
||||||
channelIcon(channel),
|
size: _kChannelIconSize,
|
||||||
size: _kChannelIconSize,
|
color: context.colors.onSurface,
|
||||||
color: context.colors.onSurface,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: _kChannelLabelGap),
|
const SizedBox(width: _kChannelLabelGap),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -343,8 +342,8 @@ class _DmAvatar extends ConsumerWidget {
|
|||||||
|
|
||||||
if (visiblePubkeys.length > 1) {
|
if (visiblePubkeys.length > 1) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 22,
|
width: _kDmAvatarSize,
|
||||||
height: 22,
|
height: _kDmAvatarSize,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.colors.surfaceContainerHighest,
|
color: context.colors.surfaceContainerHighest,
|
||||||
@@ -354,7 +353,7 @@ class _DmAvatar extends ConsumerWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'${visiblePubkeys.length}',
|
'${visiblePubkeys.length}',
|
||||||
style: context.textTheme.labelSmall?.copyWith(
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
fontSize: 10,
|
fontSize: 9,
|
||||||
color: context.colors.onSurface,
|
color: context.colors.onSurface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
height: 1,
|
height: 1,
|
||||||
@@ -385,14 +384,14 @@ class _DmAvatar extends ConsumerWidget {
|
|||||||
: 'offline';
|
: 'offline';
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 22,
|
width: _kDmAvatarSize,
|
||||||
height: 22,
|
height: _kDmAvatarSize,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
AvatarImage(
|
AvatarImage(
|
||||||
imageUrl: avatarUrl,
|
imageUrl: avatarUrl,
|
||||||
radius: 10,
|
radius: _kDmAvatarSize / 2,
|
||||||
backgroundColor: context.colors.primaryContainer,
|
backgroundColor: context.colors.primaryContainer,
|
||||||
fallback: Text(
|
fallback: Text(
|
||||||
initial,
|
initial,
|
||||||
@@ -407,8 +406,8 @@ class _DmAvatar extends ConsumerWidget {
|
|||||||
right: -1,
|
right: -1,
|
||||||
bottom: -1,
|
bottom: -1,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 9,
|
width: 8,
|
||||||
height: 9,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _presenceColor(context, presence),
|
color: _presenceColor(context, presence),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import '../../shared/theme/theme.dart';
|
|||||||
import '../../shared/widgets/avatar_image.dart';
|
import '../../shared/widgets/avatar_image.dart';
|
||||||
import '../profile/user_cache_provider.dart';
|
import '../profile/user_cache_provider.dart';
|
||||||
import '../profile/user_profile.dart';
|
import '../profile/user_profile.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import 'camera_capture_cleanup.dart';
|
import 'camera_capture_cleanup.dart';
|
||||||
import 'channel.dart';
|
import 'channel.dart';
|
||||||
import 'channel_management_provider.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 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
import '../../shared/theme/theme.dart';
|
import '../../shared/theme/theme.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import '../custom_emoji/custom_emoji_render.dart';
|
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||||
|
|
||||||
/// Opens the full emoji picker as a modal bottom sheet.
|
/// Opens the full emoji picker as a modal bottom sheet.
|
||||||
void showEmojiPicker({
|
void showEmojiPicker({
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
import '../../shared/theme/theme.dart';
|
import '../../shared/theme/theme.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import 'channel_management_provider.dart';
|
import 'channel_management_provider.dart';
|
||||||
import 'emoji_picker.dart';
|
import 'emoji_picker.dart';
|
||||||
import 'thread_detail_page.dart';
|
import 'thread_detail_page.dart';
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import '../../shared/clipboard_utils.dart';
|
|||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
import '../../shared/syntax_highlight.dart';
|
import '../../shared/syntax_highlight.dart';
|
||||||
import '../../shared/theme/theme.dart';
|
import '../../shared/theme/theme.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import '../custom_emoji/custom_emoji_render.dart';
|
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||||
import 'media_viewer_page.dart';
|
import 'media_viewer_page.dart';
|
||||||
import 'message_media.dart';
|
import 'message_media.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
import '../../shared/theme/theme.dart';
|
import '../../shared/theme/theme.dart';
|
||||||
import '../../shared/widgets/avatar_image.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_cache_provider.dart';
|
||||||
import '../profile/user_profile.dart';
|
import '../profile/user_profile.dart';
|
||||||
import 'channel_management_provider.dart';
|
import 'channel_management_provider.dart';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import 'channel_window.dart';
|
import 'channel_window.dart';
|
||||||
|
|
||||||
enum SystemEventType {
|
enum SystemEventType {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
import '../channels/channel_management_provider.dart';
|
import '../channels/channel_management_provider.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import 'forum_models.dart';
|
import 'forum_models.dart';
|
||||||
|
|
||||||
/// Fetches forum posts (kind:45001) for a channel from the relay.
|
/// 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';
|
import '../search/search_page.dart';
|
||||||
|
|
||||||
class HomePage extends HookConsumerWidget {
|
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 _tabBarHeight = 56;
|
||||||
static const double _tabBarRadius = _tabBarHeight / 2;
|
static const double _tabBarRadius = _tabBarHeight / 2;
|
||||||
@@ -54,7 +56,11 @@ class HomePage extends HookConsumerWidget {
|
|||||||
_destinations.length,
|
_destinations.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pages = [ChannelsPage(), ActivityPage(), SearchPage()];
|
final pages = [
|
||||||
|
ChannelsPage(settingsPageBuilder: settingsPageBuilder),
|
||||||
|
const ActivityPage(),
|
||||||
|
const SearchPage(),
|
||||||
|
];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBody: true,
|
extendBody: true,
|
||||||
|
|||||||
@@ -3,10 +3,23 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
import '../../shared/theme/theme.dart';
|
import '../../shared/theme/theme.dart';
|
||||||
import '../../shared/widgets/avatar_image.dart';
|
import '../../shared/widgets/avatar_image.dart';
|
||||||
|
import '../../shared/widgets/masked_avatar_badge.dart';
|
||||||
import 'profile_provider.dart';
|
import 'profile_provider.dart';
|
||||||
import 'user_profile.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.
|
/// 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 {
|
class ProfileAvatar extends ConsumerWidget {
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final bool showPresence;
|
final bool showPresence;
|
||||||
@@ -32,7 +45,7 @@ class ProfileAvatar extends ConsumerWidget {
|
|||||||
|
|
||||||
Widget _buildPlaceholder(BuildContext context) {
|
Widget _buildPlaceholder(BuildContext context) {
|
||||||
return CircleAvatar(
|
return CircleAvatar(
|
||||||
radius: 16,
|
radius: _avatarSize / 2,
|
||||||
backgroundColor: context.colors.primaryContainer,
|
backgroundColor: context.colors.primaryContainer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,37 +57,37 @@ class ProfileAvatar extends ConsumerWidget {
|
|||||||
) {
|
) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Stack(
|
child: MaskedAvatarBadge(
|
||||||
children: [
|
size: _avatarSize,
|
||||||
AvatarImage(
|
geometry: AvatarBadgeMaskGeometry.presenceDot,
|
||||||
imageUrl: profile?.avatarUrl,
|
avatar: ClipOval(
|
||||||
radius: 16,
|
child: ColoredBox(
|
||||||
backgroundColor: context.colors.primaryContainer,
|
color: context.colors.primaryContainer,
|
||||||
fallback: Text(
|
child: AvatarImageContent(
|
||||||
profile?.initial ?? '?',
|
imageUrl: profile?.avatarUrl,
|
||||||
style: context.textTheme.labelMedium?.copyWith(
|
fallback: Text(
|
||||||
color: context.colors.onPrimaryContainer,
|
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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 '../../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 '../channels/emoji_picker.dart';
|
||||||
import 'user_status.dart';
|
import 'user_status.dart';
|
||||||
import 'user_status_provider.dart';
|
import 'user_status_provider.dart';
|
||||||
|
|
||||||
const _emojiOptions = [
|
/// The emoji well and the text field are sized to be the two things you reach
|
||||||
(emoji: '\u{1F5E3}\u{FE0F}', label: 'In a meeting'),
|
/// for, so they carry no borders — the sheet has no other controls to compete
|
||||||
(emoji: '\u{1F68C}', label: 'Commuting'),
|
/// with them.
|
||||||
(emoji: '\u{1F912}', label: 'Out sick'),
|
const _emojiWellSize = 56.0;
|
||||||
(emoji: '\u{1F3D6}\u{FE0F}', label: 'Vacationing'),
|
const _emojiGlyphSize = 32.0;
|
||||||
(emoji: '\u{1F3E0}', label: 'Working remotely'),
|
const _saveButtonHeight = 52.0;
|
||||||
(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}'),
|
|
||||||
];
|
|
||||||
|
|
||||||
void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) {
|
void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) {
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
@@ -51,7 +42,6 @@ class _SetStatusSheet extends HookConsumerWidget {
|
|||||||
final emoji = useState(currentStatus?.emoji ?? '');
|
final emoji = useState(currentStatus?.emoji ?? '');
|
||||||
final text = useState(currentStatus?.text ?? '');
|
final text = useState(currentStatus?.text ?? '');
|
||||||
final isSaving = useState(false);
|
final isSaving = useState(false);
|
||||||
final customEmoji = ref.watch(customEmojiListProvider);
|
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
void listener() => text.value = textController.text;
|
void listener() => text.value = textController.text;
|
||||||
@@ -91,7 +81,13 @@ class _SetStatusSheet extends HookConsumerWidget {
|
|||||||
Grid.gutter,
|
Grid.gutter,
|
||||||
0,
|
0,
|
||||||
Grid.gutter,
|
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(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -107,31 +103,77 @@ class _SetStatusSheet extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: Grid.twelve),
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Stack(
|
||||||
width: 40,
|
clipBehavior: Clip.none,
|
||||||
height: 40,
|
children: [
|
||||||
alignment: Alignment.center,
|
Semantics(
|
||||||
decoration: BoxDecoration(
|
button: true,
|
||||||
border: Border.all(color: context.colors.outlineVariant),
|
label: 'Choose a status emoji',
|
||||||
borderRadius: BorderRadius.circular(Radii.md),
|
child: InkWell(
|
||||||
),
|
borderRadius: BorderRadius.circular(Radii.lg),
|
||||||
child: _StatusEmojiPreview(emoji: emoji.value),
|
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(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: textController,
|
controller: textController,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: const InputDecoration(
|
style: context.textTheme.titleMedium,
|
||||||
|
decoration: InputDecoration(
|
||||||
hintText: 'What\u2019s your status?',
|
hintText: 'What\u2019s your status?',
|
||||||
border: OutlineInputBorder(),
|
hintStyle: context.textTheme.titleMedium?.copyWith(
|
||||||
contentPadding: EdgeInsets.symmetric(
|
color: context.colors.onSurfaceVariant,
|
||||||
horizontal: Grid.twelve,
|
|
||||||
vertical: Grid.xxs,
|
|
||||||
),
|
),
|
||||||
|
// 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,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) {
|
onSubmitted: (_) {
|
||||||
@@ -141,74 +183,28 @@ class _SetStatusSheet extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: Grid.twelve),
|
const SizedBox(height: Grid.gutter),
|
||||||
|
|
||||||
Wrap(
|
SizedBox(
|
||||||
spacing: Grid.half,
|
width: double.infinity,
|
||||||
runSpacing: Grid.half,
|
height: _saveButtonHeight,
|
||||||
children: [
|
child: FilledButton(
|
||||||
for (final option in _emojiOptions)
|
onPressed: hasContent && !isSaving.value ? handleSave : null,
|
||||||
_EmojiButton(
|
child: const Text('Save'),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: Grid.twelve),
|
// No Cancel \u2014 the sheet dismisses by swiping down.
|
||||||
|
if (hasExistingStatus)
|
||||||
// Presets
|
Padding(
|
||||||
Wrap(
|
padding: const EdgeInsets.only(top: Grid.half),
|
||||||
spacing: Grid.half,
|
child: SizedBox(
|
||||||
runSpacing: Grid.half,
|
width: double.infinity,
|
||||||
children: [
|
child: TextButton(
|
||||||
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(
|
|
||||||
onPressed: isSaving.value ? null : handleClear,
|
onPressed: isSaving.value ? null : handleClear,
|
||||||
child: const Text('Clear status'),
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (emoji.isEmpty) {
|
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 palette = ref.watch(customEmojiListProvider);
|
||||||
final shortcode = normalizeShortcode(emoji);
|
final shortcode = normalizeShortcode(emoji);
|
||||||
@@ -233,84 +233,11 @@ class _StatusEmojiPreview extends ConsumerWidget {
|
|||||||
return CustomEmojiImage(
|
return CustomEmojiImage(
|
||||||
shortcode: shortcode,
|
shortcode: shortcode,
|
||||||
url: entry.url,
|
url: entry.url,
|
||||||
size: 22,
|
size: _emojiGlyphSize,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Text(emoji, style: const TextStyle(fontSize: 18));
|
return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize));
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
_ActionButton(
|
||||||
icon: effectiveUpvoted
|
icon: effectiveUpvoted
|
||||||
? Icons.favorite
|
? Icons.favorite
|
||||||
: Icons.favorite_border,
|
: LucideIcons.heart,
|
||||||
label: effectiveCount > 0 ? '$effectiveCount' : null,
|
label: effectiveCount > 0 ? '$effectiveCount' : null,
|
||||||
color: effectiveUpvoted ? Colors.redAccent : null,
|
color: effectiveUpvoted ? Colors.redAccent : null,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
import '../channels/channel_management_provider.dart';
|
import '../channels/channel_management_provider.dart';
|
||||||
import '../custom_emoji/custom_emoji.dart';
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import 'pulse_models.dart';
|
import 'pulse_models.dart';
|
||||||
import 'pulse_provider.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/relay/relay.dart';
|
||||||
import '../../shared/theme/theme.dart';
|
import '../../shared/theme/theme.dart';
|
||||||
import '../../shared/widgets/app_list.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_app_bar.dart';
|
||||||
import '../../shared/widgets/frosted_scaffold.dart';
|
import '../../shared/widgets/frosted_scaffold.dart';
|
||||||
import '../profile/set_status_sheet.dart';
|
import 'accent_picker_page.dart';
|
||||||
import '../profile/user_status_provider.dart';
|
|
||||||
import '../custom_emoji/custom_emoji_provider.dart';
|
|
||||||
import '../custom_emoji/custom_emoji_render.dart';
|
|
||||||
import 'theme_picker_page.dart';
|
import 'theme_picker_page.dart';
|
||||||
|
|
||||||
|
part 'settings_page/appearance_section.dart';
|
||||||
|
part 'settings_page/connection_section.dart';
|
||||||
|
|
||||||
class SettingsPage extends HookConsumerWidget {
|
class SettingsPage extends HookConsumerWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key, required this.profileHeader});
|
||||||
|
|
||||||
|
final Widget profileHeader;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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 packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
|
||||||
final packageInfo = useFuture(packageInfoFuture);
|
final packageInfo = useFuture(packageInfoFuture);
|
||||||
|
|
||||||
@@ -41,283 +40,55 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
bottom: Grid.xs,
|
bottom: Grid.xs,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: Grid.xxs),
|
profileHeader,
|
||||||
|
const _AppearanceSection(),
|
||||||
// Status — flush header row, like Slack's profile/status block.
|
const _ConnectionSection(),
|
||||||
_StatusRow(),
|
const _RemoveCommunitySection(),
|
||||||
|
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (packageInfo.hasData)
|
if (packageInfo.hasData)
|
||||||
SafeArea(
|
_VersionFooter(version: packageInfo.data!.version),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatusRow extends ConsumerWidget {
|
class _VersionFooter extends StatelessWidget {
|
||||||
@override
|
const _VersionFooter({required this.version});
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final statusAsync = ref.watch(userStatusProvider);
|
|
||||||
final status = statusAsync.asData?.value;
|
|
||||||
final hasStatus = status != null && !status.isEmpty;
|
|
||||||
|
|
||||||
return AppListRowRaw(
|
final String version;
|
||||||
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;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Tooltip(
|
return SafeArea(
|
||||||
message: label,
|
top: false,
|
||||||
child: GestureDetector(
|
child: Padding(
|
||||||
onTap: onTap,
|
padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs),
|
||||||
child: AnimatedContainer(
|
child: Center(
|
||||||
duration: const Duration(milliseconds: 150),
|
child: Text(
|
||||||
width: 36,
|
'v$version',
|
||||||
height: 36,
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
decoration: BoxDecoration(
|
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
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_app_bar.dart';
|
||||||
import '../../shared/widgets/frosted_scaffold.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 {
|
class ThemePickerPage extends HookConsumerWidget {
|
||||||
const ThemePickerPage({super.key});
|
const ThemePickerPage({super.key});
|
||||||
|
|
||||||
@@ -15,41 +18,47 @@ class ThemePickerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final mode = ref.watch(themeProvider);
|
||||||
final selectedScheme = ref.watch(schemeProvider);
|
final selectedScheme = ref.watch(schemeProvider);
|
||||||
final searchQuery = useState('');
|
final searchQuery = useState('');
|
||||||
final searchController = useTextEditingController();
|
final searchController = useTextEditingController();
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
// Flat alphabetical list by display name
|
final groups = themeGroups();
|
||||||
final sorted = List<ThemeColors>.from(themeCatalog)
|
final entries = switch (mode) {
|
||||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
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 query = searchQuery.value.toLowerCase();
|
||||||
final defaultLabel = 'Default ($defaultSchemeDisplayName)';
|
|
||||||
final filtered = query.isEmpty
|
final filtered = query.isEmpty
|
||||||
? sorted
|
? entries
|
||||||
: sorted
|
: entries
|
||||||
.where((t) => t.displayName.toLowerCase().contains(query))
|
.where((t) => labelFor(t).toLowerCase().contains(query))
|
||||||
.toList();
|
.toList();
|
||||||
final showDefault =
|
|
||||||
query.isEmpty ||
|
|
||||||
'default'.contains(query) ||
|
|
||||||
defaultLabel.toLowerCase().contains(query);
|
|
||||||
|
|
||||||
// Default entry colors.
|
// In System mode either half of a pair counts as the selection; in the
|
||||||
final defaultTheme = findTheme(defaultSchemeName);
|
// pinned modes the effective theme already accounts for coercion, so the
|
||||||
final defaultBg = defaultTheme?.bg ?? lightColorScheme.surface;
|
// checkmark always names what is actually rendered.
|
||||||
final defaultFg = defaultTheme?.fg ?? lightColorScheme.onSurface;
|
final active = effectiveTheme(selectedScheme, mode);
|
||||||
final defaultComment =
|
bool isSelected(ThemeColors theme) {
|
||||||
defaultTheme?.comment ?? lightColorScheme.onSurfaceVariant;
|
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(() {
|
useEffect(() {
|
||||||
if (selectedScheme == null || query.isNotEmpty) return null;
|
if (query.isNotEmpty) return null;
|
||||||
final idx = sorted.indexWhere((t) => t.name == selectedScheme);
|
final idx = filtered.indexWhere(isSelected);
|
||||||
if (idx < 0) return null;
|
if (idx < 0) return null;
|
||||||
// +1 to account for the Default entry at the top
|
final offset = idx * _itemHeight;
|
||||||
final offset = (idx + 1) * _itemHeight;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (scrollController.hasClients) {
|
if (scrollController.hasClients) {
|
||||||
scrollController.animateTo(
|
scrollController.animateTo(
|
||||||
@@ -63,98 +72,22 @@ class ThemePickerPage extends HookConsumerWidget {
|
|||||||
}, const []);
|
}, const []);
|
||||||
|
|
||||||
return FrostedScaffold(
|
return FrostedScaffold(
|
||||||
appBar: const FrostedAppBar(title: Text('Color Scheme')),
|
appBar: const FrostedAppBar(title: Text('Theme')),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: frostedAppBarHeight(context)),
|
SizedBox(height: frostedAppBarHeight(context)),
|
||||||
// Always-visible search bar
|
_SearchField(
|
||||||
Padding(
|
controller: searchController,
|
||||||
padding: const EdgeInsets.symmetric(
|
query: searchQuery.value,
|
||||||
horizontal: Grid.gutter,
|
onChanged: (v) => searchQuery.value = v,
|
||||||
vertical: Grid.xxs,
|
onClear: () {
|
||||||
),
|
searchController.clear();
|
||||||
child: Container(
|
searchQuery.value = '';
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Theme list
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: filtered.isEmpty
|
||||||
controller: scrollController,
|
? Padding(
|
||||||
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(
|
|
||||||
padding: const EdgeInsets.all(Grid.sm),
|
padding: const EdgeInsets.all(Grid.sm),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
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 {
|
class _ThemeRow extends StatelessWidget {
|
||||||
const _ThemeRow({
|
const _ThemeRow({
|
||||||
required this.bg,
|
required this.theme,
|
||||||
required this.fg,
|
required this.pair,
|
||||||
required this.comment,
|
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Color bg;
|
final ThemeColors theme;
|
||||||
final Color fg;
|
final ThemeColors? pair;
|
||||||
final Color comment;
|
|
||||||
final String label;
|
final String label;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final swatch = pair == null ? [theme] : [theme, pair!];
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 56,
|
width: 56,
|
||||||
height: 28,
|
height: 28,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(Radii.sm),
|
borderRadius: BorderRadius.circular(Radii.sm),
|
||||||
border: Border.all(color: context.colors.outlineVariant, width: 1),
|
border: Border.all(color: context.colors.outlineVariant),
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(Radii.sm - 1),
|
borderRadius: BorderRadius.circular(Radii.sm - 1),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
for (final t in swatch)
|
||||||
child: ColoredBox(color: bg, child: const SizedBox.expand()),
|
for (final color in [t.bg, t.fg, t.comment])
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: ColoredBox(
|
||||||
child: ColoredBox(color: fg, child: const SizedBox.expand()),
|
color: color,
|
||||||
),
|
child: const SizedBox.expand(),
|
||||||
Expanded(
|
),
|
||||||
child: ColoredBox(
|
),
|
||||||
color: comment,
|
|
||||||
child: const SizedBox.expand(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
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.
|
/// 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 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../relay/relay.dart';
|
||||||
import 'custom_emoji.dart';
|
import 'custom_emoji.dart';
|
||||||
|
|
||||||
/// Community custom-emoji palette (NIP-30, per-user kind:30030 sets unioned).
|
/// 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/gpt_markdown.dart';
|
||||||
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
|
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
|
||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../relay/relay.dart';
|
||||||
|
|
||||||
import 'custom_emoji.dart';
|
import 'custom_emoji.dart';
|
||||||
|
|
||||||
@@ -6,19 +6,31 @@ class AppColors extends ThemeExtension<AppColors> {
|
|||||||
final Color warning;
|
final Color warning;
|
||||||
final Color accent;
|
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({
|
const AppColors({
|
||||||
required this.success,
|
required this.success,
|
||||||
required this.warning,
|
required this.warning,
|
||||||
required this.accent,
|
required this.accent,
|
||||||
|
this.topSectionGradient,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppColors copyWith({Color? success, Color? warning, Color? accent}) =>
|
AppColors copyWith({
|
||||||
AppColors(
|
Color? success,
|
||||||
success: success ?? this.success,
|
Color? warning,
|
||||||
warning: warning ?? this.warning,
|
Color? accent,
|
||||||
accent: accent ?? this.accent,
|
Gradient? topSectionGradient,
|
||||||
);
|
}) => AppColors(
|
||||||
|
success: success ?? this.success,
|
||||||
|
warning: warning ?? this.warning,
|
||||||
|
accent: accent ?? this.accent,
|
||||||
|
topSectionGradient: topSectionGradient ?? this.topSectionGradient,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
|
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
|
||||||
@@ -27,6 +39,11 @@ class AppColors extends ThemeExtension<AppColors> {
|
|||||||
success: Color.lerp(success, other.success, t)!,
|
success: Color.lerp(success, other.success, t)!,
|
||||||
warning: Color.lerp(warning, other.warning, t)!,
|
warning: Color.lerp(warning, other.warning, t)!,
|
||||||
accent: Color.lerp(accent, other.accent, 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 lg = 10.0;
|
||||||
static const double md = 8.0;
|
static const double md = 8.0;
|
||||||
static const double sm = 6.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
|
static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static ThemeData light({ColorScheme? colorScheme}) {
|
static ThemeData light({
|
||||||
|
ColorScheme? colorScheme,
|
||||||
|
Gradient? topSectionGradient,
|
||||||
|
}) {
|
||||||
final scheme = colorScheme ?? lightColorScheme;
|
final scheme = colorScheme ?? lightColorScheme;
|
||||||
final appColors = AppColors(
|
final appColors = AppColors(
|
||||||
success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal
|
success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal
|
||||||
warning: const Color(0xFFDF8E1D), // Latte Yellow
|
warning: const Color(0xFFDF8E1D), // Latte Yellow
|
||||||
accent: scheme.tertiary,
|
accent: scheme.tertiary,
|
||||||
|
topSectionGradient: topSectionGradient,
|
||||||
);
|
);
|
||||||
|
|
||||||
return _buildTheme(
|
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 scheme = colorScheme ?? darkColorScheme;
|
||||||
final appColors = AppColors(
|
final appColors = AppColors(
|
||||||
success: const Color(
|
success: const Color(
|
||||||
@@ -42,6 +50,7 @@ class AppTheme {
|
|||||||
), // Catppuccin Macchiato Green — universal
|
), // Catppuccin Macchiato Green — universal
|
||||||
warning: const Color(0xFFEED49F), // Macchiato Yellow
|
warning: const Color(0xFFEED49F), // Macchiato Yellow
|
||||||
accent: scheme.tertiary,
|
accent: scheme.tertiary,
|
||||||
|
topSectionGradient: topSectionGradient,
|
||||||
);
|
);
|
||||||
|
|
||||||
return _buildTheme(
|
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 'adaptive_theme.dart';
|
||||||
export 'app_colors.dart';
|
export 'app_colors.dart';
|
||||||
export 'app_theme.dart';
|
export 'app_theme.dart';
|
||||||
|
export 'buzz_theme.dart';
|
||||||
export 'color_scheme.dart';
|
export 'color_scheme.dart';
|
||||||
export 'grid.dart';
|
export 'grid.dart';
|
||||||
export 'theme_catalog.dart';
|
export 'theme_catalog.dart';
|
||||||
export 'theme_extensions.dart';
|
export 'theme_extensions.dart';
|
||||||
|
export 'theme_pairs.dart';
|
||||||
export 'theme_provider.dart';
|
export 'theme_provider.dart';
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class ThemeColors {
|
|||||||
|
|
||||||
/// Known light theme names — used to show sun/moon icons before loading.
|
/// Known light theme names — used to show sun/moon icons before loading.
|
||||||
const lightThemeNames = <String>{
|
const lightThemeNames = <String>{
|
||||||
|
'buzz',
|
||||||
'catppuccin-latte',
|
'catppuccin-latte',
|
||||||
'everforest-light',
|
'everforest-light',
|
||||||
'github-light',
|
'github-light',
|
||||||
@@ -79,6 +80,25 @@ const themeCatalog = <ThemeColors>[
|
|||||||
added: Color(0xFF70BF56),
|
added: Color(0xFF70BF56),
|
||||||
deleted: Color(0xFFF26D78),
|
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(
|
ThemeColors(
|
||||||
name: 'catppuccin-frappe',
|
name: 'catppuccin-frappe',
|
||||||
bg: Color(0xFF303446),
|
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 'accent_colors.dart';
|
||||||
import 'adaptive_theme.dart';
|
import 'adaptive_theme.dart';
|
||||||
|
import 'buzz_theme.dart';
|
||||||
import 'color_scheme.dart';
|
import 'color_scheme.dart';
|
||||||
import 'theme_catalog.dart';
|
import 'theme_catalog.dart';
|
||||||
|
import 'theme_pairs.dart';
|
||||||
|
|
||||||
const _themeModeKey = 'buzz_theme_mode';
|
const _themeModeKey = 'buzz_theme_mode';
|
||||||
const _accentKey = 'buzz_accent_color';
|
const _accentKey = 'buzz_accent_color';
|
||||||
const _schemeKey = 'buzz_color_scheme';
|
const _schemeKey = 'buzz_color_scheme';
|
||||||
|
|
||||||
const defaultSchemeName = 'github-light';
|
/// Buzz ships as the default: the first-party pair, so a fresh install gets the
|
||||||
const defaultSchemeDisplayName = 'GitHub Light';
|
/// branded top-section gradient without picking a theme first.
|
||||||
|
const defaultSchemeName = buzzThemeName;
|
||||||
|
const defaultSchemeDisplayName = 'Buzz';
|
||||||
|
|
||||||
/// Pre-loaded SharedPreferences instance, overridden in main().
|
/// Pre-loaded SharedPreferences instance, overridden in main().
|
||||||
final savedPrefsProvider = Provider<SharedPreferences>(
|
final savedPrefsProvider = Provider<SharedPreferences>(
|
||||||
(_) => throw UnimplementedError('Must be overridden'),
|
(_) => throw UnimplementedError('Must be overridden'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Tracks the appearance mode: follow the OS, or pin light/dark.
|
||||||
class ThemeNotifier extends Notifier<ThemeMode> {
|
class ThemeNotifier extends Notifier<ThemeMode> {
|
||||||
@override
|
@override
|
||||||
ThemeMode build() {
|
ThemeMode build() {
|
||||||
@@ -30,6 +35,11 @@ class ThemeNotifier extends Notifier<ThemeMode> {
|
|||||||
}
|
}
|
||||||
return ThemeMode.system;
|
return ThemeMode.system;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setMode(ThemeMode mode) {
|
||||||
|
state = mode;
|
||||||
|
ref.read(savedPrefsProvider).setString(_themeModeKey, mode.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(
|
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(
|
||||||
@@ -68,19 +78,21 @@ class SchemeNotifier extends Notifier<String?> {
|
|||||||
@override
|
@override
|
||||||
String? build() {
|
String? build() {
|
||||||
final prefs = ref.read(savedPrefsProvider);
|
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
|
if (compatible != stored) {
|
||||||
// non-system themeMode persisted (from the old Light/System/Dark toggle),
|
if (compatible == null) {
|
||||||
// reset it because default schemes now control their own brightness.
|
prefs.remove(_schemeKey);
|
||||||
if (scheme == null) {
|
} else {
|
||||||
final storedMode = prefs.getString(_themeModeKey);
|
prefs.setString(_schemeKey, compatible);
|
||||||
if (storedMode != null && storedMode != ThemeMode.system.name) {
|
|
||||||
prefs.setString(_themeModeKey, ThemeMode.system.name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return scheme;
|
return compatible;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setScheme(String? name) {
|
void setScheme(String? name) {
|
||||||
@@ -98,25 +110,160 @@ final schemeProvider = NotifierProvider<SchemeNotifier, String?>(
|
|||||||
SchemeNotifier.new,
|
SchemeNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Resolves the current scheme selection into light and dark [ColorScheme]s.
|
/// Returns a scheme that can honor [mode] without silently pinning brightness.
|
||||||
/// When a named scheme is selected, generates it via the adaptive engine.
|
///
|
||||||
/// When null (default), resolves to [defaultSchemeName].
|
/// System mode only offers paired themes because they have both light and dark
|
||||||
({ColorScheme light, ColorScheme dark, ThemeMode? forcedMode}) resolveSchemes(
|
/// variants. Switching to it from an unpaired or unknown selection therefore
|
||||||
String? schemeName,
|
/// falls back to the first paired theme, matching the picker ordering.
|
||||||
) {
|
String? schemeForAppearanceMode(String? schemeName, ThemeMode mode) {
|
||||||
final theme =
|
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(schemeName ?? defaultSchemeName) ??
|
||||||
findTheme(defaultSchemeName);
|
findTheme(defaultSchemeName);
|
||||||
if (theme == null) {
|
if (selected == null) {
|
||||||
return (light: lightColorScheme, dark: darkColorScheme, forcedMode: null);
|
return (
|
||||||
|
light: lightColorScheme,
|
||||||
|
dark: darkColorScheme,
|
||||||
|
lightTheme: null,
|
||||||
|
darkTheme: null,
|
||||||
|
forcedMode: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final scheme = generateColorScheme(theme);
|
switch (mode) {
|
||||||
|
case ThemeMode.system:
|
||||||
// A named scheme is inherently light or dark — force the mode.
|
final pairName = themePairFor(selected.name);
|
||||||
return (
|
if (pairName == null) {
|
||||||
light: scheme,
|
final scheme = generateColorScheme(selected);
|
||||||
dark: scheme,
|
return (
|
||||||
forcedMode: theme.isDark ? ThemeMode.dark : ThemeMode.light,
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/theme.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
|
/// A flush, borderless settings/list row: leading icon, title, optional
|
||||||
/// subtitle and trailing widget. No card, no background — groups are
|
/// subtitle and trailing widget. Its own background comes from whatever
|
||||||
/// separated by [AppListSection] dividers instead.
|
/// contains it — a grouped card, or the page itself.
|
||||||
class AppListRow extends StatelessWidget {
|
class AppListRow extends StatelessWidget {
|
||||||
const AppListRow({
|
const AppListRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -13,6 +22,7 @@ class AppListRow extends StatelessWidget {
|
|||||||
this.subtitle,
|
this.subtitle,
|
||||||
this.subtitleStyle,
|
this.subtitleStyle,
|
||||||
this.subtitleMaxLines,
|
this.subtitleMaxLines,
|
||||||
|
this.value,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
this.titleColor,
|
this.titleColor,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
@@ -23,6 +33,11 @@ class AppListRow extends StatelessWidget {
|
|||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final TextStyle? subtitleStyle;
|
final TextStyle? subtitleStyle;
|
||||||
final int? subtitleMaxLines;
|
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 Widget? trailing;
|
||||||
final Color? titleColor;
|
final Color? titleColor;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
@@ -30,21 +45,20 @@ class AppListRow extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final row = Padding(
|
final row = Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: Grid.gutter,
|
horizontal: AppListInset.of(context),
|
||||||
vertical: Grid.twelve,
|
vertical: _rowVerticalPadding,
|
||||||
),
|
),
|
||||||
child: Row(
|
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: [
|
children: [
|
||||||
if (icon != null) ...[
|
if (icon != null) ...[
|
||||||
Padding(
|
Icon(
|
||||||
padding: const EdgeInsets.only(top: 1),
|
icon,
|
||||||
child: Icon(
|
size: 22,
|
||||||
icon,
|
color: titleColor ?? context.colors.onSurfaceVariant,
|
||||||
size: 22,
|
|
||||||
color: titleColor ?? context.colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: Grid.xs),
|
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!],
|
if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -108,9 +140,9 @@ class AppListRowRaw extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final row = Padding(
|
final row = Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: Grid.gutter,
|
horizontal: AppListInset.of(context),
|
||||||
vertical: Grid.twelve,
|
vertical: _rowVerticalPadding,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -138,51 +170,3 @@ class AppListRowRaw extends StatelessWidget {
|
|||||||
return InkWell(onTap: onTap, child: row);
|
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.
|
/// Color applied to icons in the app bar.
|
||||||
final Color? iconColor;
|
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({
|
const FrostedAppBar({
|
||||||
super.key,
|
super.key,
|
||||||
this.leading,
|
this.leading,
|
||||||
@@ -43,6 +48,7 @@ class FrostedAppBar extends StatelessWidget {
|
|||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
this.horizontalInset = Grid.quarter,
|
this.horizontalInset = Grid.quarter,
|
||||||
this.iconColor,
|
this.iconColor,
|
||||||
|
this.gradient,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,7 +81,12 @@ class FrostedAppBar extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.only(top: topPadding),
|
padding: EdgeInsets.only(top: topPadding),
|
||||||
decoration: BoxDecoration(
|
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(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
color: context.colors.outlineVariant.withValues(alpha: 0.3),
|
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/channels_provider.dart';
|
||||||
import 'package:buzz/features/channels/read_state/read_state_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/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/profile_provider.dart';
|
||||||
import 'package:buzz/features/profile/user_profile.dart';
|
import 'package:buzz/features/profile/user_profile.dart';
|
||||||
import 'package:buzz/shared/theme/theme.dart';
|
import 'package:buzz/shared/theme/theme.dart';
|
||||||
@@ -42,7 +43,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
home: const Stack(
|
home: const Stack(
|
||||||
children: [
|
children: [
|
||||||
ChannelsPage(),
|
ChannelsPage(settingsPageBuilder: _buildSettingsPage),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: ChannelQuickActionsLauncher(
|
child: ChannelQuickActionsLauncher(
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -117,6 +118,24 @@ void main() {
|
|||||||
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
|
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', (
|
testWidgets('quick actions slide behind navigation when leaving home', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -885,6 +904,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSettingsPage(BuildContext context) =>
|
||||||
|
const Scaffold(body: Text('Injected settings'));
|
||||||
|
|
||||||
class _FakeNotifier extends ChannelsNotifier {
|
class _FakeNotifier extends ChannelsNotifier {
|
||||||
final List<Channel> _channels;
|
final List<Channel> _channels;
|
||||||
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
|
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/channels_provider.dart';
|
||||||
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
|
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
|
||||||
import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart';
|
import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart';
|
||||||
import 'package:buzz/features/custom_emoji/custom_emoji.dart';
|
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
|
||||||
import 'package:buzz/features/custom_emoji/custom_emoji_provider.dart';
|
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
||||||
import 'package:buzz/shared/relay/relay.dart';
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
import 'package:buzz/shared/theme/theme.dart';
|
import 'package:buzz/shared/theme/theme.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -67,7 +70,10 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -95,7 +101,10 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -135,3 +144,5 @@ void main() {
|
|||||||
expect(opacity(), closeTo(1, 0.001));
|
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: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';
|
import 'package:buzz/shared/relay/nostr_models.dart';
|
||||||
|
|
||||||
NostrEvent _event(
|
NostrEvent _event(
|
||||||
@@ -5,7 +5,7 @@ import 'package:buzz/shared/theme/theme.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
group('default accent', () {
|
group('default accent', () {
|
||||||
test('uses black on the default light scheme', () {
|
test('uses black on the default light scheme', () {
|
||||||
final resolved = resolveSchemes(null);
|
final resolved = resolveSchemes(null, ThemeMode.light);
|
||||||
|
|
||||||
final accented = applyAccent(resolved.light, defaultAccentIndex);
|
final accented = applyAccent(resolved.light, defaultAccentIndex);
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('uses the theme foreground on forced dark schemes', () {
|
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 base = resolved.dark;
|
||||||
|
|
||||||
final accented = applyAccent(base, defaultAccentIndex);
|
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