From a5dfdd6d55773decff0ae3486f571fcce94b39f9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 15 Oct 2026 08:45:16 -0400 Subject: [PATCH] Align mobile appearance and channels with desktop (#8358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns mobile with desktop's neutral palette and six chat colors. Appearance now offers Light, Dark, and System instead of themes, while preserving community preferences and the single Home and Chat preview pair. Chat colors survive local preference reloads without historical-index remapping, and the Chat preview shows the draft outgoing bubble color. Channel and section icons are removed, section chevrons sit beside names, and section dividers use 7% opacity. DM avatars remain. Validation: `just mobile-check`, 35 focused tests, and the full mobile suite passed (3,351 tests; 4 skipped). Updated light/dark previews were rendered and visually checked. The earlier release was verified on iPhone; this follow-up has not been reinstalled. The earlier repository-wide lint passed, but full `just ci` was stopped for low disk space. Hosted CI is running for the new head. **Intentional design parity:** Section labels and chevrons retain desktop’s `--text-unavailable` colors: light `#959595`, dark `#595959`. Verified against `block/buzz-app` main at `0201b54c496120a44e020834663dc534cae9a79f`. These colors do not meet 4.5:1 text contrast on the mobile section surfaces; the product decision for this PR is to preserve desktop parity and leave them unchanged. Kenneth explicitly accepts this known contrast tradeoff for this PR/release and requests that reviewers treat it as a scoped product exception rather than a blocker. This does not claim WCAG compliance or change the repository-wide accessibility target. Any future contrast adjustment should be coordinated across desktop and mobile. ### Snapshots Channel list: physical iPhone. Appearance and chat colors: Flutter-rendered previews. | Channels | Light | Dark | Chat colors | | --- | --- | --- | --- | | ![channel-list-iphone](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--channel-list-iphone.png) | ![appearance-light](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--appearance-light.png) | ![appearance-dark](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--appearance-dark.png) | ![chat-colors](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--chat-colors.png) | --------- Signed-off-by: kenny lopez --- mobile/lib/app.dart | 36 +- .../lib/features/channels/channels_page.dart | 19 +- .../features/channels/channels_page/body.dart | 3 - .../channels/channels_page/channel_tile.dart | 30 +- .../channels/channels_page/sections.dart | 125 +-- .../channels/channels_page/skeleton.dart | 22 - .../features/channels/message_content.dart | 9 +- .../settings/theme_option_sheets.dart | 8 +- .../features/settings/theme_picker_page.dart | 35 +- .../theme_picker_page/theme_home_preview.dart | 52 +- .../theme_preview_sheet.dart | 240 ++--- mobile/lib/shared/theme/app_theme.dart | 10 +- mobile/lib/shared/theme/chat_colors.dart | 64 ++ mobile/lib/shared/theme/color_scheme.dart | 108 +-- .../theme/community_theme_preference.dart | 12 +- .../lib/shared/theme/mobile_appearance.dart | 58 ++ mobile/lib/shared/theme/theme.dart | 2 + .../channels/channel_detail_page_test.dart | 3 +- .../features/channels/channels_page_test.dart | 42 +- .../features/channels/chat_color_test.dart | 90 ++ .../settings/theme_picker_page_test.dart | 823 +++--------------- .../test/shared/theme/chat_colors_test.dart | 61 ++ .../community_theme_preference_test.dart | 40 + .../shared/theme/mobile_appearance_test.dart | 34 + 24 files changed, 752 insertions(+), 1174 deletions(-) create mode 100644 mobile/lib/shared/theme/chat_colors.dart create mode 100644 mobile/lib/shared/theme/mobile_appearance.dart create mode 100644 mobile/test/features/channels/chat_color_test.dart create mode 100644 mobile/test/shared/theme/chat_colors_test.dart create mode 100644 mobile/test/shared/theme/mobile_appearance_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 3c16eb5dba..000827e44e 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -311,11 +311,6 @@ class App extends HookConsumerWidget { ? ref.watch(communityThemeProvider) : defaultCommunityTheme; final themeMode = communityTheme.mode; - final accentIndex = effectiveAccentIndex( - communityTheme.theme, - communityTheme.accent, - ); - final schemeName = communityTheme.theme; final authState = ref.watch(authProvider); final pairedCommunity = ref.watch(pairedCommunityLandingProvider); @@ -326,25 +321,6 @@ class App extends HookConsumerWidget { return null; }, const []); - final resolved = resolveSchemes(schemeName, themeMode); - final lightScheme = applyAccent(resolved.light, accentIndex); - final darkScheme = applyAccent(resolved.dark, accentIndex); - // Light/Dark modes pin the brightness; System leaves it null so Flutter - // follows the OS across the selected theme and its pair. - final effectiveMode = resolved.forcedMode ?? themeMode; - - // Derive the gradient from the themes that produced each color scheme. - // This keeps fallbacks and pinned brightness changes aligned with the - // rendered palette rather than the raw persisted selection. - final buzzLightGradient = buzzTopSectionGradient( - resolved.lightTheme?.name ?? '', - lightScheme.brightness, - ); - final buzzDarkGradient = buzzTopSectionGradient( - resolved.darkTheme?.name ?? '', - darkScheme.brightness, - ); - // Eagerly initialize websocket session and lifecycle observer when // authenticated. These providers connect and manage the websocket. var hasUnreadInbox = false; @@ -394,15 +370,9 @@ class App extends HookConsumerWidget { navigatorKey: _mobileRootNavigatorKey, navigatorObservers: [appRouteObserver], title: 'Buzz', - theme: AppTheme.light( - colorScheme: lightScheme, - topSectionGradient: buzzLightGradient, - ), - darkTheme: AppTheme.dark( - colorScheme: darkScheme, - topSectionGradient: buzzDarkGradient, - ), - themeMode: effectiveMode, + theme: mobileAppearanceTheme(Brightness.light, communityTheme.accent), + darkTheme: mobileAppearanceTheme(Brightness.dark, communityTheme.accent), + themeMode: themeMode, // Above the navigator, so an age restriction cannot be bypassed by a // route that was pushed while the store signal request was in flight. builder: (context, child) => switch (ageSignalState) { diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 4719cd5ca3..e655c82084 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -32,9 +32,6 @@ import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/confirmation_dialog.dart'; import '../../shared/widgets/skeleton.dart'; -import '../../shared/custom_emoji/custom_emoji.dart'; -import '../../shared/custom_emoji/custom_emoji_provider.dart'; -import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; import '../profile/presence_cache_provider.dart'; @@ -87,24 +84,16 @@ const double _kSectionHeaderVerticalPadding = // centre sits lower than a channel row's. This keeps an expanded section's // final row equally spaced from the following divider. const double _kExpandedSectionTrailingPadding = - 11.0 - _kSectionSpacingTightening; -const double _kChannelLabelInset = - _kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap; + 11.5 - _kSectionSpacingTightening; +const double _kChannelLabelInset = _kChannelSectionInset; -/// 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]. +/// Compact avatars remain on direct-message rows. const double _kDmAvatarSize = _kChannelIconSize; const double _kTopSectionProfileAvatarSize = 36.0; const double _kTopSectionBottomPadding = Grid.xxs; -/// The top section's avatars are 40dp 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 -/// near the channel-icon column. +/// Inset for the community and profile avatar row. const double _kTopSectionInset = Grid.twelve; const Duration _kSectionExpandDuration = Duration(milliseconds: 220); const Duration _kSectionCollapseDuration = Duration(milliseconds: 170); diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 4ea88c1891..ad477e8355 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -261,7 +261,6 @@ class _SliverChannelsList extends HookConsumerWidget { if (starredStreamChannels.isNotEmpty) _ChannelSection( title: 'Starred', - icon: BuzzIcons.star, showTopDivider: false, expanded: starredExpanded.value, onToggle: () => starredExpanded.value = !starredExpanded.value, @@ -372,7 +371,6 @@ class _SliverChannelsList extends HookConsumerWidget { ), _ChannelSection( title: 'Channels', - icon: BuzzIcons.hash, showTopDivider: starredStreamChannels.isNotEmpty || userSections.isNotEmpty, expanded: channelsExpanded.value, @@ -388,7 +386,6 @@ class _SliverChannelsList extends HookConsumerWidget { ), _ChannelSection( title: 'DMs', - icon: BuzzIcons.messagesSquare, showTopDivider: true, expanded: dmsExpanded.value, onToggle: () => dmsExpanded.value = !dmsExpanded.value, diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index caa05ff9f3..765e54af26 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -28,9 +28,7 @@ class _ChannelTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final contentColor = isMuted ? navigationSecondaryForeground(context) - : navigationPrimaryForeground( - context, - ).withValues(alpha: isUnread ? 1 : 0.8); + : navigationPrimaryForeground(context); return InkWell( borderRadius: BorderRadius.circular(Radii.md), @@ -45,21 +43,19 @@ class _ChannelTile extends ConsumerWidget { ), child: Row( children: [ - SizedBox( - width: _kChannelLeadingWidth, - child: Align( - alignment: Alignment.centerLeft, - child: channel.isDm - ? _DmAvatar(channel: channel, currentPubkey: currentPubkey) - : Icon( - channelIcon(channel), - key: ValueKey('channel-icon-${channel.id}'), - size: _kChannelIconSize, - color: contentColor, - ), + if (channel.isDm) ...[ + SizedBox( + width: _kChannelLeadingWidth, + child: Align( + alignment: Alignment.centerLeft, + child: _DmAvatar( + channel: channel, + currentPubkey: currentPubkey, + ), + ), ), - ), - const SizedBox(width: _kChannelLabelGap), + const SizedBox(width: _kChannelLabelGap), + ], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index f0ac7d58e8..bc110f700e 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -86,7 +86,7 @@ class _CustomChannelSection extends StatelessWidget { } } -class _CustomSectionHeader extends ConsumerWidget { +class _CustomSectionHeader extends StatelessWidget { final ChannelSection section; final bool expanded; final bool isFirst; @@ -114,13 +114,10 @@ class _CustomSectionHeader extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { - final sectionColor = navigationSectionForeground(context); - final icon = section.icon; - final customEmoji = icon == null - ? null - : _resolveCustomEmoji(icon, ref.watch(customEmojiListProvider)); - + Widget build(BuildContext context) { + final sectionColor = context.colors.brightness == Brightness.dark + ? const Color(0xFF595959) + : const Color(0xFF959595); return GestureDetector( onTap: onToggle, behavior: HitTestBehavior.opaque, @@ -133,44 +130,11 @@ class _CustomSectionHeader extends ConsumerWidget { ), child: Row( children: [ - SizedBox( - width: _kChannelLeadingWidth, - child: Align( - alignment: Alignment.centerLeft, - child: icon == null || icon.isEmpty - ? Icon( - BuzzIcons.folder, - size: _kChannelIconSize, - color: sectionColor, - ) - : customEmoji != null - ? CustomEmojiImage( - shortcode: customEmoji.shortcode, - url: customEmoji.url, - size: _kChannelIconSize, - ) - : Text( - icon, - maxLines: 1, - overflow: TextOverflow.visible, - style: TextStyle( - fontSize: _kChannelIconSize, - height: 1, - color: sectionColor, - ), - ), - ), - ), - const SizedBox(width: _kChannelLabelGap), Expanded( - child: Text( - section.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: contentListTitleTextStyle.copyWith( - color: sectionColor, - fontWeight: FontWeight.w600, - ), + child: _SectionLabel( + label: section.name, + expanded: expanded, + color: sectionColor, ), ), Builder( @@ -246,7 +210,6 @@ class _CustomSectionHeader extends ConsumerWidget { ), ), const SizedBox(width: Grid.quarter), - _SectionChevron(expanded: expanded, color: sectionColor), ], ), ), @@ -284,16 +247,6 @@ class _SectionMenuItemContent extends StatelessWidget { } } -CustomEmoji? _resolveCustomEmoji(String icon, List palette) { - if (!icon.startsWith(':') || !icon.endsWith(':')) return null; - final shortcode = normalizeShortcode(icon); - if (shortcode == null) return null; - for (final emoji in palette) { - if (emoji.shortcode == shortcode) return emoji; - } - return null; -} - class _SectionNameDialog extends HookWidget { final String title; final String confirmLabel; @@ -372,7 +325,6 @@ List> _sortMenuItems( class _ChannelSection extends StatelessWidget { final String title; - final IconData icon; final bool expanded; final VoidCallback onToggle; final List channels; @@ -387,7 +339,6 @@ class _ChannelSection extends StatelessWidget { const _ChannelSection({ required this.title, - required this.icon, required this.expanded, required this.onToggle, required this.channels, @@ -409,7 +360,6 @@ class _ChannelSection extends StatelessWidget { if (showTopDivider) const _SectionDivider(), _SectionHeader( label: title, - icon: icon, expanded: expanded, onToggle: onToggle, sortMode: sortMode, @@ -497,7 +447,7 @@ class _SectionDivider extends StatelessWidget { thickness: 1, indent: _kChannelSectionInset, endIndent: _kChannelSectionInset, - color: context.colors.primary.withValues(alpha: 0.15), + color: context.colors.onSurface.withValues(alpha: 0.07), ), ); } @@ -505,7 +455,6 @@ class _SectionDivider extends StatelessWidget { class _SectionHeader extends StatelessWidget { final String label; - final IconData icon; final bool expanded; final VoidCallback onToggle; final ChannelSortMode? sortMode; @@ -513,7 +462,6 @@ class _SectionHeader extends StatelessWidget { const _SectionHeader({ required this.label, - required this.icon, required this.expanded, required this.onToggle, this.sortMode, @@ -522,7 +470,9 @@ class _SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final sectionColor = navigationSectionForeground(context); + final sectionColor = context.colors.brightness == Brightness.dark + ? const Color(0xFF595959) + : const Color(0xFF959595); return GestureDetector( onTap: onToggle, @@ -536,22 +486,13 @@ class _SectionHeader extends StatelessWidget { ), child: Row( children: [ - SizedBox( - width: _kChannelLeadingWidth, - child: Align( - alignment: Alignment.centerLeft, - child: Icon(icon, size: _kChannelIconSize, color: sectionColor), - ), - ), - const SizedBox(width: _kChannelLabelGap), - Text( - label, - style: contentListTitleTextStyle.copyWith( + Expanded( + child: _SectionLabel( + label: label, + expanded: expanded, color: sectionColor, - fontWeight: FontWeight.w600, ), ), - const Spacer(), if (sortMode case final mode?) ...[ Builder( builder: (buttonContext) => IconButton( @@ -590,7 +531,6 @@ class _SectionHeader extends StatelessWidget { ), const SizedBox(width: Grid.quarter), ], - _SectionChevron(expanded: expanded, color: sectionColor), ], ), ), @@ -598,6 +538,37 @@ class _SectionHeader extends StatelessWidget { } } +class _SectionLabel extends StatelessWidget { + const _SectionLabel({ + required this.label, + required this.expanded, + required this.color, + }); + + final String label; + final bool expanded; + final Color color; + + @override + Widget build(BuildContext context) => Row( + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: contentListTitleTextStyle.copyWith( + color: color, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(width: Grid.quarter), + _SectionChevron(expanded: expanded, color: color), + ], + ); +} + class _SectionChevron extends StatelessWidget { final bool expanded; final Color color; diff --git a/mobile/lib/features/channels/channels_page/skeleton.dart b/mobile/lib/features/channels/channels_page/skeleton.dart index 83a665ba14..132d57886a 100644 --- a/mobile/lib/features/channels/channels_page/skeleton.dart +++ b/mobile/lib/features/channels/channels_page/skeleton.dart @@ -68,14 +68,6 @@ class _ChannelSkeletonSection extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: Grid.half), child: Row( children: [ - SizedBox( - width: _kChannelLeadingWidth, - child: Align( - alignment: Alignment.centerLeft, - child: SkeletonBar(width: 18, height: 18), - ), - ), - SizedBox(width: Grid.xxs), SkeletonBar( key: Key('channels-skeleton-section-label'), width: 88, @@ -91,20 +83,6 @@ class _ChannelSkeletonSection extends StatelessWidget { ), child: Row( children: [ - SizedBox( - width: _kChannelLeadingWidth, - child: Align( - alignment: Alignment.centerLeft, - child: SkeletonBar( - width: _kChannelIconSize, - height: _kChannelIconSize, - borderRadius: BorderRadius.circular( - index.isEven ? Radii.xs : Radii.full, - ), - ), - ), - ), - const SizedBox(width: _kChannelLabelGap), SkeletonBar( key: Key('channels-skeleton-row-label-$index'), width: widths[index], diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index cfbe413fa3..2fd262ea14 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -192,9 +192,8 @@ class MessageContent extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final markdownTheme = GptMarkdownTheme.of(context); - final ownTextColor = context.colors.brightness == Brightness.dark - ? Colors.black - : Colors.white; + final chatColors = ChatColors.of(context); + final ownTextColor = chatColors.foreground; final baseTextStyle = (baseStyle ?? context.textTheme.bodyMedium?.copyWith( @@ -409,9 +408,7 @@ class MessageContent extends HookConsumerWidget { padding: const EdgeInsets.only(bottom: Grid.quarter), child: LocalSendBubbleSurface( color: ownMessage - ? (context.colors.brightness == Brightness.dark - ? const Color(0xFFF2F2F2) - : Colors.black) + ? chatColors.outgoing : context.colors.secondaryContainer, shape: _MessageBubbleShape( start: bubbleStart && index == 0, diff --git a/mobile/lib/features/settings/theme_option_sheets.dart b/mobile/lib/features/settings/theme_option_sheets.dart index 8de22df5af..1cff7a2e67 100644 --- a/mobile/lib/features/settings/theme_option_sheets.dart +++ b/mobile/lib/features/settings/theme_option_sheets.dart @@ -36,7 +36,7 @@ Future showAccentColorPickerSheet({ required ValueChanged onSelected, }) => showBuzzModalBottomSheet( context: context, - title: 'Accent color', + title: 'Chat color', showDragHandle: true, builder: (_) => _AccentColorPicker( colorScheme: colorScheme, @@ -204,10 +204,10 @@ class _AccentColorPicker extends HookWidget { crossAxisSpacing: Grid.xxs, mainAxisSpacing: Grid.xs, ), - itemCount: accentColors.length, + itemCount: chatColorChoices.length, itemBuilder: (context, index) => _AccentSheetOption( index: index, - color: accentColorForScheme(colorScheme, index), + color: chatColorForScheme(colorScheme, index), selected: selected.value == index, onTap: () { unawaited(HapticFeedback.selectionClick()); @@ -236,7 +236,7 @@ class _AccentSheetOption extends StatelessWidget { @override Widget build(BuildContext context) => Semantics( - label: '${accentColors[index].name} accent color', + label: '${chatColorChoices[index].name} chat color', button: true, selected: selected, child: InkResponse( diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index edeab2d30c..12664d48cf 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -15,48 +15,15 @@ import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/ios_glass_navigation_action.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; -import '../../shared/widgets/page_indicator.dart'; import 'theme_option_sheets.dart'; part 'theme_picker_page/theme_home_preview.dart'; part 'theme_picker_page/theme_preview_sheet.dart'; -List _themeEntriesForMode(ThemeMode mode) { - final groups = themeGroups(); - return switch (mode) { - ThemeMode.system => groups.paired, - ThemeMode.light => groups.light, - ThemeMode.dark => groups.dark, - }; -} - String _themeLabelFor(ThemeColors theme, ThemeMode mode) => mode == ThemeMode.system ? pairedThemeLabel(theme.name) : theme.displayName; -ThemeColors _displayedThemeFor( - ThemeColors theme, - ThemeMode mode, - Brightness platformBrightness, -) { - if (mode != ThemeMode.system || platformBrightness == Brightness.light) { - return theme; - } - return findTheme(themePairFor(theme.name) ?? '') ?? theme; -} - -bool _themeEntryIsSelected( - ThemeColors entry, - ThemeMode mode, - String selectedScheme, -) { - final active = effectiveTheme(selectedScheme, mode); - if (active == null) return false; - if (mode != ThemeMode.system) return active.name == entry.name; - return active.name == entry.name || themePairFor(entry.name) == active.name; -} - -/// Direct, swipeable theme preview. Theme, accent, and appearance changes stay -/// as a draft until Set is pressed. +/// Appearance preview with draft mode and chat color, applied with Set. class ThemePickerPage extends StatelessWidget { const ThemePickerPage({super.key}); diff --git a/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart b/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart index 4d475814ec..e8265e4f9e 100644 --- a/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart +++ b/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart @@ -341,13 +341,7 @@ class _FigmaChatScreen extends StatelessWidget { authorWidth: 118, bodyWidths: const [244, 198, 126], ), - _PreviewTimelineMessage( - key: const ValueKey('theme-chat-timeline-message-3'), - top: 432, - avatarColor: chrome, - authorWidth: 76, - bodyWidths: const [206, 148], - ), + const _PreviewOutgoingMessage(), Positioned( key: const ValueKey('theme-chat-composer-preview'), left: 16, @@ -392,6 +386,50 @@ class _FigmaChatScreen extends StatelessWidget { } } +class _PreviewOutgoingMessage extends StatelessWidget { + const _PreviewOutgoingMessage(); + + @override + Widget build(BuildContext context) { + final chat = ChatColors.of(context); + return Positioned( + top: 432, + right: 18, + width: 238, + child: DecoratedBox( + key: const ValueKey('theme-chat-outgoing-bubble'), + decoration: BoxDecoration( + color: chat.outgoing, + borderRadius: BorderRadius.circular(Radii.dialog), + ), + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final width in [206.0, 148.0]) + Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: SizedBox( + width: width, + height: 11, + child: DecoratedBox( + key: ValueKey('theme-chat-outgoing-line-$width'), + decoration: BoxDecoration( + color: chat.foreground, + borderRadius: BorderRadius.circular(Radii.full), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + class _PreviewTimelineMessage extends StatelessWidget { const _PreviewTimelineMessage({ required this.top, diff --git a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart index cb0d5f3ec9..cd7e6bd8ce 100644 --- a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart +++ b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart @@ -10,89 +10,41 @@ class _ThemePreviewExperience extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final stored = ref.read(communityThemeProvider); - final initialEntries = _themeEntriesForMode(stored.mode); - var initialIndex = initialEntries.indexWhere( - (entry) => _themeEntryIsSelected(entry, stored.mode, stored.theme), - ); - if (initialIndex < 0) initialIndex = 0; - final draftMode = useState(stored.mode); - final draftThemeName = useState(initialEntries[initialIndex].name); - final draftAccent = useState( - accentIndexForWireValue(stored.accent) ?? defaultAccentIndex, - ); - final currentPage = useState(initialIndex); - final suppressNextPageHaptic = useRef(false); - final controller = usePageController(initialPage: initialIndex); - final entries = _themeEntriesForMode(draftMode.value); + final draftAccent = useState(chatColorIndex(stored.accent)); final platformBrightness = MediaQuery.platformBrightnessOf(context); final reduceMotion = MediaQuery.disableAnimationsOf(context); - final previousDraftMode = usePrevious(draftMode.value); - final animatePaginationChanges = - previousDraftMode == null || previousDraftMode == draftMode.value; - - var selectedIndex = entries.indexWhere( - (entry) => - _themeEntryIsSelected(entry, draftMode.value, draftThemeName.value), - ); - if (selectedIndex < 0) selectedIndex = 0; - final selectedEntry = entries[selectedIndex]; - final selectedDisplayed = _displayedThemeFor( - selectedEntry, - draftMode.value, - platformBrightness, - ); - final selectedResolved = resolveSchemes( - selectedEntry.name, - draftMode.value, - ); - final selectedBaseScheme = platformBrightness == Brightness.dark - ? selectedResolved.dark - : selectedResolved.light; - final supportsAccent = !isBuzzTheme(selectedDisplayed.name); - - void updateMode(ThemeMode mode) { - final normalized = mode == ThemeMode.system - ? schemeForAppearanceMode(draftThemeName.value, mode) - : effectiveTheme(draftThemeName.value, mode)?.name; - final nextEntries = _themeEntriesForMode(mode); - final nextTheme = normalized ?? nextEntries.first.name; - var nextIndex = nextEntries.indexWhere( - (entry) => _themeEntryIsSelected(entry, mode, nextTheme), - ); - if (nextIndex < 0) nextIndex = 0; - - draftMode.value = mode; - draftThemeName.value = nextEntries[nextIndex].name; - if (nextIndex != currentPage.value) { - suppressNextPageHaptic.value = true; - } - currentPage.value = nextIndex; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (controller.hasClients) controller.jumpToPage(nextIndex); - }); - } + final brightness = switch (draftMode.value) { + ThemeMode.light => Brightness.light, + ThemeMode.dark => Brightness.dark, + ThemeMode.system => platformBrightness, + }; + final selectedEntry = findTheme( + brightness == Brightness.dark ? buzzDarkThemeName : buzzThemeName, + )!; + final selectedBaseScheme = brightness == Brightness.dark + ? darkColorScheme + : lightColorScheme; void cycleAppearance() { final index = _appearanceOrder.indexOf(draftMode.value); - final next = _appearanceOrder[(index + 1) % _appearanceOrder.length]; unawaited(HapticFeedback.selectionClick()); - updateMode(next); + draftMode.value = _appearanceOrder[(index + 1) % _appearanceOrder.length]; } void applySelection() { unawaited(HapticFeedback.mediumImpact()); - final notifier = ref.read(communityThemeProvider.notifier); - final currentAccent = ref.read(communityThemeProvider).accent; - notifier.setPreference( - CommunityThemePreference( - theme: selectedEntry.name, - accent: supportsAccent - ? accentColors[draftAccent.value].wireValue - : currentAccent, - followSystem: draftMode.value == ThemeMode.system, - ), - ); + ref + .read(communityThemeProvider.notifier) + .setPreference( + CommunityThemePreference( + theme: draftMode.value == ThemeMode.dark + ? buzzDarkThemeName + : buzzThemeName, + accent: chatColorChoices[draftAccent.value].wireValue, + followSystem: draftMode.value == ThemeMode.system, + ), + ); Navigator.of(context).pop(); } @@ -123,38 +75,12 @@ class _ThemePreviewExperience extends HookConsumerWidget { children: [ SizedBox(height: frostedAppBarHeight(context)), Expanded( - child: PageView.builder( - key: const ValueKey('theme-preview-pages'), - controller: controller, - itemCount: entries.length, - onPageChanged: (index) { - if (suppressNextPageHaptic.value) { - suppressNextPageHaptic.value = false; - } else if (index != currentPage.value) { - unawaited(HapticFeedback.selectionClick()); - } - currentPage.value = index; - draftThemeName.value = entries[index].name; - }, - itemBuilder: (context, index) => AnimatedBuilder( - animation: controller, - builder: (context, _) { - var titleOffset = 0.0; - if (!reduceMotion && controller.hasClients) { - final page = controller.page; - if (page != null) { - titleOffset = (index - page).clamp(-1.0, 1.0) * Grid.md; - } - } - return _ThemePreviewPage( - theme: entries[index], - mode: draftMode.value, - accentIndex: draftAccent.value, - platformBrightness: platformBrightness, - titleHorizontalOffset: titleOffset, - ); - }, - ), + child: _ThemePreviewPage( + theme: selectedEntry, + mode: draftMode.value, + accentIndex: draftAccent.value, + platformBrightness: platformBrightness, + titleHorizontalOffset: 0, ), ), SafeArea( @@ -171,79 +97,25 @@ class _ThemePreviewExperience extends HookConsumerWidget { children: [ SizedBox( width: 64, - child: IgnorePointer( - ignoring: !supportsAccent, - child: AnimatedSwitcher( - key: const ValueKey( - 'theme-preview-accent-availability', - ), - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 180), - reverseDuration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 140), - switchInCurve: _appearanceMotionCurve, - switchOutCurve: Curves.easeInOutCubic, - layoutBuilder: (currentChild, previousChildren) => - Stack( - alignment: Alignment.center, - children: [...previousChildren, ?currentChild], - ), - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: Tween( - begin: 0.78, - end: 1.0, - ).animate(animation), - child: child, - ), - ), - child: supportsAccent - ? _PreviewSheetCircleAction( - key: const ValueKey( - 'theme-preview-accent-action-button', - ), - semanticLabel: 'Accent color', - color: accentColorForScheme( - selectedBaseScheme, - draftAccent.value, - ), - onTap: () { - unawaited(HapticFeedback.lightImpact()); - showAccentColorPickerSheet( - context: context, - colorScheme: selectedBaseScheme, - selectedIndex: draftAccent.value, - onSelected: (index) => - draftAccent.value = index, - ); - }, - ) - : const SizedBox( - key: ValueKey( - 'theme-preview-accent-unavailable', - ), - ), + child: _PreviewSheetCircleAction( + key: const ValueKey('theme-preview-accent-action-button'), + semanticLabel: 'Chat color', + color: chatColorForScheme( + selectedBaseScheme, + draftAccent.value, + ), + onTap: () => showAccentColorPickerSheet( + context: context, + colorScheme: selectedBaseScheme, + selectedIndex: draftAccent.value, + onSelected: (index) => draftAccent.value = index, ), ), ), Expanded( - child: PageIndicator( - semanticLabel: 'Theme', - controlKey: const ValueKey('theme-preview-scrubber'), - dotKeyPrefix: 'theme-pagination-dot-', - count: entries.length, - selected: currentPage.value.clamp(0, entries.length - 1), - animateChanges: animatePaginationChanges, - onSelected: (index) { - if (index == currentPage.value || - !controller.hasClients) { - return; - } - controller.jumpToPage(index); - }, + child: Text( + appearanceModeLabel(draftMode.value), + textAlign: TextAlign.center, ), ), SizedBox( @@ -369,19 +241,15 @@ class _ThemeDevicePairPreview extends StatelessWidget { @override Widget build(BuildContext context) { - final displayed = _displayedThemeFor(theme, mode, platformBrightness); - final resolved = resolveSchemes(theme.name, mode); - final base = platformBrightness == Brightness.dark - ? resolved.dark - : resolved.light; - final effectiveAccent = isBuzzTheme(displayed.name) - ? defaultAccentIndex - : accentIndex; - final scheme = applyAccent(base, effectiveAccent); - final gradient = buzzTopSectionGradient(displayed.name, scheme.brightness); - final previewTheme = scheme.brightness == Brightness.dark - ? AppTheme.dark(colorScheme: scheme, topSectionGradient: gradient) - : AppTheme.light(colorScheme: scheme, topSectionGradient: gradient); + final brightness = switch (mode) { + ThemeMode.light => Brightness.light, + ThemeMode.dark => Brightness.dark, + ThemeMode.system => platformBrightness, + }; + final previewTheme = mobileAppearanceTheme( + brightness, + chatColorChoices[accentIndex].wireValue, + ); return Row( key: ValueKey('theme-device-pair-preview-${theme.name}'), diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index 234294e2a2..e31c14af5b 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -33,8 +33,8 @@ class AppTheme { }) { final scheme = colorScheme ?? lightColorScheme; final appColors = AppColors( - success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal - warning: const Color(0xFFDF8E1D), // Latte Yellow + success: const Color(0xFF218358), // Desktop green-11 + warning: const Color(0xFFAB6400), // Desktop amber-11 accent: scheme.tertiary, huddleDrawerSurface: const Color(0xFF000000), huddleControlSurface: const Color(0xFF333333), @@ -57,10 +57,8 @@ class AppTheme { }) { final scheme = colorScheme ?? darkColorScheme; final appColors = AppColors( - success: const Color( - 0xFFA6DA95, - ), // Catppuccin Macchiato Green — universal - warning: const Color(0xFFEED49F), // Macchiato Yellow + success: const Color(0xFF3DD68C), // Desktop green-11 + warning: const Color(0xFFFFCA16), // Desktop amber-11 accent: scheme.tertiary, huddleDrawerSurface: scheme.primaryContainer, huddleControlSurface: Color.alphaBlend( diff --git a/mobile/lib/shared/theme/chat_colors.dart b/mobile/lib/shared/theme/chat_colors.dart new file mode 100644 index 0000000000..e2a939bc6e --- /dev/null +++ b/mobile/lib/shared/theme/chat_colors.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'accent_colors.dart'; + +/// Desktop message palette, in picker order. Wire values remain compatible +/// with existing community preferences; fills match buzz-app bubble tokens. +const chatColorChoices = [ + AccentColor( + name: 'Neutral', + light: Color(0xFFE9E9EA), + dark: Color(0xFF2E2E2E), + wireValue: 'neutral', + ), + AccentColor( + name: 'Blue', + light: Color(0xFF0077DB), + dark: Color(0xFF0077DB), + wireValue: '#3b82f6', + ), + AccentColor( + name: 'Green', + light: Color(0xFF008934), + dark: Color(0xFF008934), + wireValue: '#22c55e', + ), + AccentColor( + name: 'Orange', + light: Color(0xFFC95100), + dark: Color(0xFFC95100), + wireValue: '#f97316', + ), + AccentColor( + name: 'Pink', + light: Color(0xFFD33785), + dark: Color(0xFFD33785), + wireValue: '#ec4899', + ), + AccentColor( + name: 'Lilac', + light: Color(0xFFA94AD8), + dark: Color(0xFFA94AD8), + wireValue: '#c0a2f1', + ), +]; + +/// Resolves old community colors using desktop's retired-color aliases. +int chatColorIndex(String wireValue) { + final normalized = switch (wireValue) { + '#06b6d4' || '#6366f1' => '#3b82f6', + '#ef4444' => '#ec4899', + '#a855f7' => '#c0a2f1', + _ => wireValue, + }; + final index = chatColorChoices.indexWhere( + (color) => color.wireValue == normalized, + ); + return index < 0 ? 0 : index; +} + +/// Resolves a picker swatch for the active appearance mode. +Color chatColorForScheme(ColorScheme scheme, int index) { + final color = chatColorChoices[index]; + return scheme.brightness == Brightness.dark ? color.dark : color.light; +} diff --git a/mobile/lib/shared/theme/color_scheme.dart b/mobile/lib/shared/theme/color_scheme.dart index 37381a78f0..be655ca493 100644 --- a/mobile/lib/shared/theme/color_scheme.dart +++ b/mobile/lib/shared/theme/color_scheme.dart @@ -2,70 +2,78 @@ import 'package:flutter/material.dart'; import 'accent_colors.dart'; -// Catppuccin Latte (mauve accent) — matches Buzz desktop light theme +// Buzz desktop design-system tokens: neutral surfaces/text and Radix status roles. +// Source: buzz-app/src/shared/design-system/styles/tokens.css. const lightColorScheme = ColorScheme( brightness: Brightness.light, - primary: Color(0xFF8839EF), // Latte Mauve - onPrimary: Color(0xFFEFF1F5), // Latte Base - primaryContainer: Color(0xFFE6E9EF), // Latte Mantle - onPrimaryContainer: Color(0xFF4C4F69), // Latte Text - secondary: Color(0xFF6C6F85), // Latte Subtext0 + primary: Color(0xFF262626), + onPrimary: Color(0xFFFFFFFF), + primaryContainer: Color(0xFFF0F0F0), + onPrimaryContainer: Color(0xFF0A0A0A), + secondary: Color(0xFF646464), onSecondary: Color(0xFFFFFFFF), - secondaryContainer: Color(0xFFCCD0DA), // Latte Surface1 - onSecondaryContainer: Color(0xFF4C4F69), // Latte Text - tertiary: Color(0xFF1E66F5), // Latte Blue + secondaryContainer: Color(0xFFF0F0F0), + onSecondaryContainer: Color(0xFF0A0A0A), + tertiary: Color(0xFF0D74CE), onTertiary: Color(0xFFFFFFFF), - tertiaryContainer: Color(0xFFBCC0CC), // Latte Surface2 - onTertiaryContainer: Color(0xFF1E66F5), // Latte Blue - error: Color(0xFFD20F39), // Latte Red + tertiaryContainer: Color(0xFFE6F4FE), + onTertiaryContainer: Color(0xFF113264), + error: Color(0xFFCE2C31), onError: Color(0xFFFFFFFF), - errorContainer: Color(0xFFFDD8E0), - onErrorContainer: Color(0xFFD20F39), + errorContainer: Color(0xFFFEEBEC), + onErrorContainer: Color(0xFF641723), surface: Color(0xFFFFFFFF), - onSurface: Color(0xFF4C4F69), // Latte Text - onSurfaceVariant: Color(0xFF6C6F85), // Latte Subtext0 - outline: Color(0xFFBCC0CC), // Latte Surface2 - outlineVariant: Color(0xFFCCD0DA), // Latte Surface1 - inverseSurface: Color(0xFF4C4F69), // Latte Text - onInverseSurface: Color(0xFFEFF1F5), // Latte Base - inversePrimary: Color(0xFFA875F5), // Macchiato Mauve (saturated) + onSurface: Color(0xFF0A0A0A), + onSurfaceVariant: Color(0xFF646464), + outline: Color(0xFFE8E8E8), + outlineVariant: Color(0xFFE8E8E8), + inverseSurface: Color(0xFF262626), + onInverseSurface: Color(0xFFFFFFFF), + inversePrimary: Color(0xFFE5E5E5), shadow: Color(0xFF000000), scrim: Color(0xFF000000), - surfaceTint: Color(0xFF8839EF), // Latte Mauve - surfaceContainerHighest: Color(0xFFFFFFFF), + surfaceContainerLowest: Color(0xFFFFFFFF), + surfaceContainerLow: Color(0xFFF5F5F6), + surfaceContainer: Color(0xFFF0F0F0), + surfaceContainerHigh: Color(0xFFE8E8E8), + surfaceContainerHighest: Color(0xFFF5F5F6), + surfaceTint: Colors.transparent, ); -// Catppuccin Macchiato (mauve accent) — matches Buzz desktop dark theme const darkColorScheme = ColorScheme( brightness: Brightness.dark, - primary: Color(0xFFA875F5), // Macchiato Mauve (saturated) - onPrimary: Color(0xFF24273A), // Macchiato Base - primaryContainer: Color(0xFF363A4F), // Macchiato Surface0 - onPrimaryContainer: Color(0xFFCAD3F5), // Macchiato Text - secondary: Color(0xFFA5ADCB), // Macchiato Subtext0 - onSecondary: Color(0xFF24273A), // Macchiato Base - secondaryContainer: Color(0xFF494D64), // Macchiato Surface1 - onSecondaryContainer: Color(0xFFCAD3F5), // Macchiato Text - tertiary: Color(0xFF8AADF4), // Macchiato Blue - onTertiary: Color(0xFF24273A), // Macchiato Base - tertiaryContainer: Color(0xFF363A4F), // Macchiato Surface0 - onTertiaryContainer: Color(0xFF8AADF4), // Macchiato Blue - error: Color(0xFFED8796), // Macchiato Red - onError: Color(0xFF24273A), // Macchiato Base - errorContainer: Color(0xFF3D2030), - onErrorContainer: Color(0xFFED8796), - surface: Color(0xFF24273A), // Macchiato Base - onSurface: Color(0xFFCAD3F5), // Macchiato Text - onSurfaceVariant: Color(0xFFA5ADCB), // Macchiato Subtext0 - outline: Color(0xFF494D64), // Macchiato Surface1 - outlineVariant: Color(0xFF363A4F), // Macchiato Surface0 - inverseSurface: Color(0xFFCAD3F5), // Macchiato Text - onInverseSurface: Color(0xFF24273A), // Macchiato Base - inversePrimary: Color(0xFF8839EF), // Latte Mauve + primary: Color(0xFFE5E5E5), + onPrimary: Color(0xFF0F0F0F), + primaryContainer: Color(0xFF2A2A2A), + onPrimaryContainer: Color(0xFFF5F5F5), + secondary: Color(0xFFC1C1C1), + onSecondary: Color(0xFF0F0F0F), + secondaryContainer: Color(0xFF2A2A2A), + onSecondaryContainer: Color(0xFFF5F5F5), + tertiary: Color(0xFF70B8FF), + onTertiary: Color(0xFF0F0F0F), + tertiaryContainer: Color(0xFF0D2847), + onTertiaryContainer: Color(0xFFC2E6FF), + error: Color(0xFFFF9592), + onError: Color(0xFF191111), + errorContainer: Color(0xFF3B1219), + onErrorContainer: Color(0xFFFFD1D9), + surface: Color(0xFF1C1C1C), + onSurface: Color(0xFFF5F5F5), + onSurfaceVariant: Color(0xFFC1C1C1), + outline: Color(0xFF333333), + outlineVariant: Color(0xFF333333), + inverseSurface: Color(0xFFE5E5E5), + onInverseSurface: Color(0xFF0F0F0F), + inversePrimary: Color(0xFF262626), shadow: Color(0xFF000000), scrim: Color(0xFF000000), - surfaceTint: Color(0xFFA875F5), // Macchiato Mauve (saturated) - surfaceContainerHighest: Color(0xFF1E2030), // Macchiato Mantle + surfaceContainerLowest: Color(0xFF0F0F0F), + surfaceContainerLow: Color(0xFF161616), + surfaceContainer: Color(0xFF232323), + surfaceContainerHigh: Color(0xFF2A2A2A), + surfaceContainerHighest: Color(0xFF2A2A2A), + surfaceTint: Colors.transparent, ); /// Compute a contrast-safe foreground color for a given background. diff --git a/mobile/lib/shared/theme/community_theme_preference.dart b/mobile/lib/shared/theme/community_theme_preference.dart index 216f6a8d3c..260c9520d4 100644 --- a/mobile/lib/shared/theme/community_theme_preference.dart +++ b/mobile/lib/shared/theme/community_theme_preference.dart @@ -70,6 +70,7 @@ class CommunityThemeStorage { static const _prefix = 'buzz-community-theme.v1'; static const _outboxPrefix = 'buzz-community-theme-outbox.v1'; static const _migrationPrefix = 'buzz-community-theme-migrated.v1'; + static const _localPreferenceKey = 'buzz-local-theme.v1'; static const _legacyModeKey = 'buzz_theme_mode'; static const _legacyAccentKey = 'buzz_accent_color'; static const _legacySchemeKey = 'buzz_color_scheme'; @@ -134,15 +135,14 @@ class CommunityThemeStorage { prefs.setBool('$_migrationPrefix:$pubkey', true); Future writeLegacy(CommunityThemePreference preference) async { - await prefs.setString(_legacyModeKey, preference.mode.name); - await prefs.setString(_legacySchemeKey, preference.theme); - await prefs.setInt( - _legacyAccentKey, - accentIndexForWireValue(preference.accent) ?? defaultAccentIndex, - ); + // New writes use one versioned snapshot. Historical integer indexes remain + // read-only migration input and must never encode the current palette. + await prefs.setString(_localPreferenceKey, jsonEncode(preference.toJson())); } CommunityThemePreference legacyPreference() { + final local = _readKey(_localPreferenceKey); + if (local != null) return local; final modeName = prefs.getString(_legacyModeKey); final mode = ThemeMode.values.where((value) => value.name == modeName).firstOrNull ?? diff --git a/mobile/lib/shared/theme/mobile_appearance.dart b/mobile/lib/shared/theme/mobile_appearance.dart new file mode 100644 index 0000000000..2fdf8a34b1 --- /dev/null +++ b/mobile/lib/shared/theme/mobile_appearance.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'chat_colors.dart'; +import 'color_scheme.dart'; +import 'app_theme.dart'; + +/// Chat-only colors, independent of controls and navigation. +@immutable +class ChatColors extends ThemeExtension { + /// Creates the outgoing message palette. + const ChatColors({required this.outgoing, required this.foreground}); + + /// Fill for messages authored by the active signing identity. + final Color outgoing; + + /// Desktop text color paired with the outgoing fill. + final Color foreground; + + /// Reads chat colors while supporting isolated widget previews. + static ChatColors of(BuildContext context) => + Theme.of(context).extension() ?? + ChatColors( + outgoing: Theme.of(context).colorScheme.primary, + foreground: contrastForeground(Theme.of(context).colorScheme.primary), + ); + + @override + ChatColors copyWith({Color? outgoing, Color? foreground}) => ChatColors( + outgoing: outgoing ?? this.outgoing, + foreground: foreground ?? this.foreground, + ); + + @override + ChatColors lerp(covariant ChatColors? other, double t) => other == null + ? this + : ChatColors( + outgoing: Color.lerp(outgoing, other.outgoing, t)!, + foreground: Color.lerp(foreground, other.foreground, t)!, + ); +} + +/// Builds the single mobile palette. Legacy theme names remain readable for +/// community preference compatibility, but never select an application palette. +ThemeData mobileAppearanceTheme(Brightness brightness, String chatColor) { + final theme = brightness == Brightness.dark + ? AppTheme.dark() + : AppTheme.light(); + final index = chatColorIndex(chatColor); + return theme.copyWith( + extensions: [ + ...theme.extensions.values, + ChatColors( + outgoing: chatColorForScheme(theme.colorScheme, index), + foreground: index == 0 ? theme.colorScheme.onSurface : Colors.white, + ), + ], + ); +} diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index 55615b5b73..41140159d0 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -17,3 +17,5 @@ export 'theme_pairs.dart'; export 'theme_provider.dart'; export 'text_theme.dart' show bodyExtraSmallTextStyle; export 'utility_surface_theme.dart'; +export 'mobile_appearance.dart'; +export 'chat_colors.dart'; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 9d005d9954..67c425f364 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5418,7 +5418,8 @@ void main() { ); final subscription = container.listen(profileProvider, (_, _) {}); addTearDown(subscription.close); - final expected = dark ? const Color(0xFFF2F2F2) : Colors.black; + final expected = + (dark ? AppTheme.dark() : AppTheme.light()).colorScheme.primary; expect(ownColor(), expected); for (final state in >[ const AsyncLoading(), diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 5d6047e9b8..88fd266cf5 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -347,23 +347,13 @@ void main() { expect(text.style?.height, contentListTitleTextStyle.height); expect( text.style?.color, - Theme.of( - tester.element(find.text(label)), - ).colorScheme.onSurface.withValues(alpha: 0.8), + Theme.of(tester.element(find.text(label))).colorScheme.onSurface, ); } - final channelIcon = tester.widget( - find.byKey(const ValueKey('channel-icon-1')), - ); - expect( - channelIcon.color, - Theme.of( - tester.element(find.byKey(const ValueKey('channel-icon-1'))), - ).colorScheme.onSurface.withValues(alpha: 0.8), - ); + expect(find.byKey(const ValueKey('channel-icon-1')), findsNothing); final sectionTitle = tester.widget(find.text('Channels')); expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize); - expect(sectionTitle.style?.fontWeight, FontWeight.w600); + expect(sectionTitle.style?.fontWeight, FontWeight.w400); }); testWidgets('keys DM tile fallback avatars to the non-self counterpart', ( @@ -903,7 +893,12 @@ void main() { () => _FakeChannelSectionsNotifier( const ChannelSectionStore( sections: [ - ChannelSection(id: 'section-1', name: 'Design', order: 0), + ChannelSection( + id: 'section-1', + name: 'Design', + icon: '🎨', + order: 0, + ), ], ), ), @@ -913,6 +908,25 @@ void main() { ); await tester.pumpAndSettle(); + expect(find.text('🎨'), findsNothing); + final sectionRow = find + .ancestor(of: find.text('Design'), matching: find.byType(Row)) + .first; + final chevron = find.descendant( + of: sectionRow, + matching: find.byIcon(BuzzIcons.chevronDown), + ); + expect( + tester.getTopLeft(chevron).dx - + tester.getTopRight(find.text('Design')).dx, + closeTo(Grid.quarter, 0.01), + ); + for (final divider in tester.widgetList(find.byType(Divider))) { + expect( + divider.color, + AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.07), + ); + } await tester.tap(find.byKey(const Key('section-menu-section-1'))); await tester.pumpAndSettle(); diff --git a/mobile/test/features/channels/chat_color_test.dart b/mobile/test/features/channels/chat_color_test.dart new file mode 100644 index 0000000000..ba4ea4b7ea --- /dev/null +++ b/mobile/test/features/channels/chat_color_test.dart @@ -0,0 +1,90 @@ +import 'package:buzz/features/channels/local_send_bubble_surface.dart'; +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('chat color reaches own text bubbles in $brightness', ( + tester, + ) async { + final theme = mobileAppearanceTheme(brightness, '#22c55e'); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: theme, + home: const Scaffold( + body: Column( + children: [ + MessageContent( + content: 'My message', + bubble: true, + ownMessage: true, + ), + MessageContent(content: 'Their message', bubble: true), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final bubbles = tester + .widgetList( + find.byType(LocalSendBubbleSurface), + ) + .toList(); + expect(bubbles[0].color, theme.extension()!.outgoing); + expect(bubbles[1].color, theme.colorScheme.secondaryContainer); + final text = tester + .widgetList(find.byType(RichText)) + .firstWhere((text) => text.text.toPlainText() == 'My message'); + final colors = []; + void inspect(InlineSpan span, TextStyle? inherited) { + final effective = inherited?.merge(span.style) ?? span.style; + if (span is TextSpan) { + if (span.text?.contains('My message') == true) { + colors.add(effective?.color); + } + for (final child in span.children ?? const []) { + inspect(child, effective); + } + } + } + + inspect(text.text, null); + expect(colors, [Colors.white]); + }); + } + + testWidgets('System follows platform brightness with the same chat choice', ( + tester, + ) async { + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + Color? surface; + Color? bubble; + await tester.pumpWidget( + MaterialApp( + theme: mobileAppearanceTheme(Brightness.light, '#ef4444'), + darkTheme: mobileAppearanceTheme(Brightness.dark, '#ef4444'), + themeMode: ThemeMode.system, + home: Builder( + builder: (context) { + surface = Theme.of(context).colorScheme.surface; + bubble = ChatColors.of(context).outgoing; + return const SizedBox(); + }, + ), + ), + ); + for (final brightness in [Brightness.dark, Brightness.light]) { + tester.platformDispatcher.platformBrightnessTestValue = brightness; + await tester.pumpAndSettle(); + final expected = mobileAppearanceTheme(brightness, '#ef4444'); + expect(surface, expected.colorScheme.surface); + expect(bubble, expected.extension()!.outgoing); + } + }); +} diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 6f576b8cc6..bad4a8c4cf 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -1,8 +1,6 @@ import 'package:buzz/features/settings/theme_picker_page.dart'; import 'package:buzz/shared/theme/theme.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -37,713 +35,152 @@ Future _pumpPicker( return instance; } -Future _swipeToNextTheme(WidgetTester tester) async { - await tester.fling( - find.byKey(const ValueKey('theme-preview-pages')), - const Offset(-600, 0), - 1200, - ); - await tester.pumpAndSettle(); -} - void main() { - group('ThemePickerPage', () { - testWidgets( - 'is the direct preview page with the name inside its container', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpPicker(tester); - - expect( - find.byKey(const ValueKey('theme-preview-pages')), - findsOneWidget, - ); - expect(find.byKey(const ValueKey('theme-preview-set')), findsOneWidget); - expect( - find.byKey(const ValueKey('theme-preview-close')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('theme-selection-grid')), - findsNothing, - ); - expect( - find.byKey(const ValueKey('theme-preview-name-buzz')), - findsOneWidget, - ); - - final nameBounds = tester.getRect( - find.byKey(const ValueKey('theme-preview-name-buzz')), - ); - final previewBounds = tester.getRect( - find.byKey(const ValueKey('theme-device-pair-preview-buzz')), - ); - expect(nameBounds.bottom, lessThan(previewBounds.bottom)); - expect(previewBounds.top - nameBounds.bottom, closeTo(Grid.md, 1)); - expect( - tester - .widget( - find.byKey(const ValueKey('theme-full-preview-buzz')), - ) - .alignment, - Alignment.center, - ); - expect( - tester - .getSize(find.byKey(const ValueKey('theme-preview-close'))) - .height, - 44, - ); - expect( - tester - .getSize(find.byKey(const ValueKey('theme-preview-set'))) - .height, - 44, - ); - }, + testWidgets('one Home and Chat pair replaces the theme carousel', ( + tester, + ) async { + await _pumpPicker(tester, prefs: {'buzz_color_scheme': 'dracula'}); + expect(find.byType(PageView), findsNothing); + expect(find.bySemanticsLabel('Home preview'), findsOneWidget); + expect(find.bySemanticsLabel('Chat preview'), findsOneWidget); + expect( + find.byKey(const ValueKey('theme-preview-accent-action-button')), + findsOneWidget, ); + expect(find.text('Dracula'), findsNothing); + }); - testWidgets('keeps the theme name inside the card in landscape', ( - tester, - ) async { - tester.view.physicalSize = const Size(844, 390); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await _pumpPicker(tester); - - final nameBounds = tester.getRect( - find.byKey(const ValueKey('theme-preview-name-buzz')), + testWidgets('mode and chat color remain drafts until Set', (tester) async { + final notifier = _RecordingCommunityThemeNotifier(); + await _pumpPicker(tester, themeNotifier: () => notifier); + // System -> Light -> Dark. + for (var i = 0; i < 2; i++) { + await tester.tap( + find.byKey(const ValueKey('theme-preview-appearance-action-button')), ); - final cardBounds = tester.getRect( - find.byKey(const ValueKey('theme-preview-page-buzz')), - ); - expect(cardBounds.contains(nameBounds.topLeft), isTrue); - expect(cardBounds.contains(nameBounds.bottomRight), isTrue); - }); - - testWidgets( - 'keeps accurate Home and Chat frames in one preview container', - (tester) async { - await _pumpPicker(tester); - - expect( - find.byKey(const ValueKey('theme-preview-surface')), - findsWidgets, - ); - expect( - find.byKey(const ValueKey('theme-chat-timeline-message-1')), - findsWidgets, - ); - expect( - find.byKey(const ValueKey('theme-preview-status-time')), - findsNothing, - ); - - final chatFrame = tester.widget( - find - .descendant( - of: find.byKey(const ValueKey('theme-full-chat-buzz')), - matching: find.byType(Container), - ) - .first, - ); - final decoration = chatFrame.decoration as BoxDecoration; - final border = decoration.border! as Border; - expect(chatFrame.clipBehavior, Clip.none); - expect(border.top.strokeAlign, BorderSide.strokeAlignOutside); - }, + await tester.pumpAndSettle(); + } + expect(find.text('Dark'), findsOneWidget); + await tester.tap( + find.byKey(const ValueKey('theme-preview-accent-action-button')), ); + await tester.pumpAndSettle(); + expect(find.text('Chat color'), findsOneWidget); + final green = chatColorChoices.indexWhere((color) => color.name == 'Green'); + await tester.tap(find.byKey(ValueKey('accent-option-$green'))); + await tester.pumpAndSettle(); + expect(notifier.preferenceWrites, 0); + Navigator.of( + tester.element(find.byKey(ValueKey('accent-option-$green'))), + ).pop(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('theme-preview-set'))); + await tester.pumpAndSettle(); + expect(notifier.preferenceWrites, 1); + expect(notifier.lastPreference?.mode, ThemeMode.dark); + expect(notifier.lastPreference?.theme, buzzDarkThemeName); + expect(notifier.lastPreference?.accent, chatColorChoices[green].wireValue); + }); - testWidgets('exposes only image labels for decorative previews', ( + for (final brightness in Brightness.values) { + testWidgets('${brightness.name} chat preview follows draft colors only', ( tester, ) async { - final semantics = tester.ensureSemantics(); - try { - await _pumpPicker(tester); - - expect(find.bySemanticsLabel(RegExp('Theme preview')), findsNothing); - expect(find.bySemanticsLabel(RegExp('Home preview')), findsWidgets); - expect(find.bySemanticsLabel(RegExp('Chat preview')), findsWidgets); - expect(find.bySemanticsLabel(RegExp('Community')), findsNothing); - } finally { - semantics.dispose(); - } - }); - - testWidgets('theme name trails the preview during a swipe', (tester) async { - await _pumpPicker(tester); - - final pages = find.byKey(const ValueKey('theme-preview-pages')); - final gesture = await tester.startGesture(tester.getCenter(pages)); - await gesture.moveBy(const Offset(-120, 0)); - await tester.pump(); - - final titleMotion = tester.widget( - find.byKey(const ValueKey('theme-preview-name-motion-buzz')), - ); - expect(titleMotion.transform.getTranslation().x, lessThan(0)); - - await gesture.up(); - await tester.pumpAndSettle(); - }); - - testWidgets('theme name does not trail with Reduce Motion', (tester) async { - await _pumpPicker(tester, reduceMotion: true); - - final pages = find.byKey(const ValueKey('theme-preview-pages')); - final gesture = await tester.startGesture(tester.getCenter(pages)); - await gesture.moveBy(const Offset(-120, 0)); - await tester.pump(); - - final titleMotion = tester.widget( - find.byKey(const ValueKey('theme-preview-name-motion-buzz')), - ); - expect(titleMotion.transform.getTranslation().x, 0); - - await gesture.up(); - await tester.pumpAndSettle(); - }); - - testWidgets('matches the updated Figma Home skeleton', (tester) async { - await _pumpPicker(tester); - - const rowTops = [ - 116.0, - 152.0, - 188.0, - 224.0, - 266.0, - 302.0, - 338.0, - 374.0, - 416.0, - 452.0, - 488.0, - 524.0, - ]; - const widths = [69.0, 69.0, 107.0, 91.0]; - for (var index = 0; index < rowTops.length; index++) { - final isSection = index % 4 == 0; - final row = find - .byKey( - isSection - ? ValueKey('theme-preview-section-${index ~/ 4}') - : ValueKey('theme-preview-channel-$index'), - ) - .first; - final capsule = find - .byKey( - isSection - ? ValueKey('theme-preview-section-${index ~/ 4}-capsule') - : ValueKey('theme-preview-channel-$index-capsule'), - ) - .first; - expect(row, findsOneWidget); - final position = tester.widget( - find.ancestor(of: row, matching: find.byType(Positioned)).first, - ); - expect(position.top, rowTops[index]); - expect(position.left, isSection ? 22 : 38); - expect(tester.getSize(capsule).height, 11); - expect(tester.getSize(capsule).width, widths[index % widths.length]); - - final capsuleWidget = tester.widget(capsule); - final capsuleContext = tester.element(capsule); - expect( - (capsuleWidget.decoration! as BoxDecoration).color, - isSection - ? navigationSectionForeground( - capsuleContext, - ).withValues(alpha: 0.52) - : navigationPrimaryForeground( - capsuleContext, - ).withValues(alpha: 0.38), - ); - } - expect( - find.byKey(const ValueKey('theme-preview-section-3')), - findsNothing, - ); - - final identity = find - .byKey(const ValueKey('theme-preview-community-identity')) - .first; - final home = tester.getRect( - find.byKey(const ValueKey('theme-full-home-buzz')), - ); - final identityBounds = tester.getRect(identity); - expect( - (identityBounds.top - home.top) / home.height * 852, - closeTo(38, 4), - ); - expect( - find.byKey(const ValueKey('theme-preview-community-icon')), - findsWidgets, - ); - expect( - find.byKey(const ValueKey('theme-preview-community-name')), - findsWidgets, - ); - }); - - testWidgets('keeps the chat header left aligned and accents only Send', ( - tester, - ) async { - await _pumpPicker(tester); - - expect( - find.byKey(const ValueKey('theme-chat-header-avatar')), - findsNothing, - ); - final title = tester.getRect( - find.byKey(const ValueKey('theme-chat-header-title')).first, - ); - final chatFrame = tester.getRect( - find.byKey(const ValueKey('theme-full-chat-buzz')), - ); - expect( - (title.left - chatFrame.left) / chatFrame.width * 393, - closeTo(70, 4), - ); - expect(title.center.dx, lessThan(chatFrame.center.dx)); - - final send = tester.widget( - find - .descendant( - of: find.byKey(const ValueKey('theme-chat-send-action')).first, - matching: find.byType(DecoratedBox), - ) - .first, - ); - final sendDecoration = send.decoration as BoxDecoration; - final previewContext = tester.element( - find.byKey(const ValueKey('theme-chat-send-action')).first, - ); - expect( - sendDecoration.color, - Theme.of(previewContext).colorScheme.primary, - ); - }); - - testWidgets('uses native liquid-glass pagination on iOS', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - - await _pumpPicker(tester); - - final nativePagination = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is UiKitView && - widget.viewType == 'buzz/theme_pagination_glass', - ), - ); - final params = nativePagination.creationParams! as Map; - expect(params['count'], greaterThan(1)); - expect(params['selected'], 0); - expect(params['animateChanges'], isTrue); - expect( - tester - .getSize( - find.byWidgetPredicate( - (widget) => - widget is UiKitView && - widget.viewType == 'buzz/theme_pagination_glass', - ), - ) - .width, - 116, - ); - - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('uses matching native glass bottom actions on iOS', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - - await _pumpPicker( - tester, - prefs: { - 'buzz_theme_mode': 'light', - 'buzz_color_scheme': 'github-light', - }, - ); - - final nativeActions = tester - .widgetList(find.byType(UiKitView)) - .where((view) => view.viewType == 'buzz/navigation_glass'); - final accentAction = nativeActions.firstWhere( - (view) => - (view.creationParams! as Map)['icon'] == - 'colorSwatch', - ); - final appearanceAction = nativeActions.firstWhere( - (view) => - (view.creationParams! as Map)['icon'] == 'sun', - ); - final accentParams = accentAction.creationParams! as Map; - final appearanceParams = - appearanceAction.creationParams! as Map; - expect(accentParams['controlSize'], 54); - expect(appearanceParams['controlSize'], 54); - expect(accentParams['swatchColor'], isNotNull); - - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('swipes between drafts and saves only when Set is pressed', ( - tester, - ) async { - final instance = await _pumpPicker( - tester, - prefs: {'buzz_theme_mode': 'system', 'buzz_color_scheme': 'buzz'}, - ); - - await _swipeToNextTheme(tester); - expect(instance.getString('buzz_color_scheme'), 'buzz'); - expect( - find.byKey(const ValueKey('theme-preview-name-buzz')), - findsNothing, - ); - - await tester.tap(find.byKey(const ValueKey('theme-preview-set'))); - await tester.pumpAndSettle(); - expect(instance.getString('buzz_color_scheme'), isNot('buzz')); - }); - - testWidgets('Set submits one complete theme preference', (tester) async { final notifier = _RecordingCommunityThemeNotifier(); await _pumpPicker(tester, themeNotifier: () => notifier); - - await _swipeToNextTheme(tester); - expect(notifier.preferenceWrites, 0); - - await tester.tap(find.byKey(const ValueKey('theme-preview-set'))); - await tester.pumpAndSettle(); - - expect(notifier.preferenceWrites, 1); - expect(notifier.lastPreference?.theme, isNot('buzz')); - expect(notifier.lastPreference?.followSystem, isTrue); - expect(notifier.lastPreference?.accent, defaultCommunityTheme.accent); - }); - - testWidgets('preserves the stored accent when leaving Buzz', ( - tester, - ) async { - final green = accentColors.indexWhere((accent) => accent.name == 'Green'); - final legacyGreen = green - 1; - final instance = await _pumpPicker( - tester, - prefs: { - 'buzz_theme_mode': 'system', - 'buzz_color_scheme': 'buzz', - 'buzz_accent_color': legacyGreen, - }, - ); - - await _swipeToNextTheme(tester); - await tester.tap(find.byKey(const ValueKey('theme-preview-set'))); - await tester.pumpAndSettle(); - - expect(instance.getString('buzz_color_scheme'), isNot('buzz')); - expect(instance.getInt('buzz_accent_color'), green); - }); - - testWidgets('accent action scales and fades with theme availability', ( - tester, - ) async { - await _pumpPicker( - tester, - prefs: {'buzz_theme_mode': 'system', 'buzz_color_scheme': 'buzz'}, - ); - - expect( - find.byKey(const ValueKey('theme-preview-accent-unavailable')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('theme-preview-accent-action-button')), - findsNothing, - ); - - final pageView = tester.widget( - find.byKey(const ValueKey('theme-preview-pages')), - ); - pageView.controller!.jumpToPage(1); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 80)); - - expect( - find.byKey(const ValueKey('theme-preview-accent-action-button')), - findsOneWidget, - ); - final scaleValues = tester - .widgetList( - find.descendant( - of: find.byKey( - const ValueKey('theme-preview-accent-availability'), - ), - matching: find.byType(ScaleTransition), + // System starts with the test platform's light appearance. + if (brightness == Brightness.dark) { + for (var i = 0; i < 2; i++) { + await tester.tap( + find.byKey( + const ValueKey('theme-preview-appearance-action-button'), ), + ); + await tester.pumpAndSettle(); + } + } + Color bubbleColor() => + (tester + .widget( + find.byKey( + const ValueKey('theme-chat-outgoing-bubble'), + ), + ) + .decoration + as BoxDecoration) + .color!; + Color foregroundColor() => + (tester + .widget( + find.byKey( + const ValueKey('theme-chat-outgoing-line-206.0'), + ), + ) + .decoration + as BoxDecoration) + .color!; + Color surfaceColor() => tester + .widget( + find.byKey(const ValueKey('theme-chat-preview-surface')), ) - .map((transition) => transition.scale.value); - expect(scaleValues.any((value) => value > 0.78 && value < 0.95), isTrue); - - await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('theme-preview-accent-unavailable')), - findsNothing, - ); - }); - - testWidgets('scrubs directly across the pagination control', ( - tester, - ) async { - final instance = await _pumpPicker( - tester, - prefs: { - 'buzz_theme_mode': 'dark', - 'buzz_color_scheme': 'catppuccin-macchiato', - }, - ); - final pageView = tester.widget( - find.byKey(const ValueKey('theme-preview-pages')), - ); - final scrubber = tester.getRect( - find.byKey(const ValueKey('theme-preview-scrubber')), - ); - - await tester.tapAt( - Offset(scrubber.left + scrubber.width * 0.85, scrubber.center.dy), - ); - await tester.pumpAndSettle(); - - expect(pageView.controller!.page, greaterThan(2)); - expect(instance.getString('buzz_color_scheme'), 'catppuccin-macchiato'); - }); - - testWidgets('cycles appearance inline with scale and opacity motion', ( - tester, - ) async { - final instance = await _pumpPicker( - tester, - prefs: { - 'buzz_theme_mode': 'light', - 'buzz_color_scheme': 'github-light', - }, - ); - - expect( - find.byKey(const ValueKey('theme-appearance-light')), - findsOneWidget, - ); - await tester.tap( - find.byKey(const ValueKey('theme-preview-appearance-action-button')), - ); - await tester.pump(); - - expect(find.byKey(const ValueKey('appearance-mode-dark')), findsNothing); - expect( - find.byKey(const ValueKey('theme-appearance-dark')), - findsOneWidget, - ); - expect( - find.descendant( - of: find.byKey(const ValueKey('theme-preview-appearance-switcher')), - matching: find.byType(FadeTransition), - ), - findsWidgets, - ); - - await tester.pump(const Duration(milliseconds: 75)); - final opacityValues = tester - .widgetList( - find.descendant( - of: find.byKey( - const ValueKey('theme-preview-appearance-switcher'), - ), - matching: find.byType(FadeTransition), - ), - ) - .map((transition) => transition.opacity.value); - expect(opacityValues.any((value) => value > 0 && value < 1), isTrue); - - await tester.pumpAndSettle(); - expect(instance.getString('buzz_theme_mode'), 'light'); - await tester.tap(find.byKey(const ValueKey('theme-preview-set'))); - await tester.pumpAndSettle(); - expect(instance.getString('buzz_theme_mode'), 'dark'); - }); - - testWidgets('appearance changes replace pagination state without motion', ( - tester, - ) async { - await _pumpPicker( - tester, - prefs: { - 'buzz_theme_mode': 'light', - 'buzz_color_scheme': 'github-light', - }, - ); - - Duration paginationDuration() => tester - .widgetList( - find.descendant( - of: find.byKey(const ValueKey('theme-preview-scrubber')), - matching: find.byType(AnimatedPositioned), - ), - ) - .first - .duration; - - expect(paginationDuration(), const Duration(milliseconds: 150)); - await tester.tap( - find.byKey(const ValueKey('theme-preview-appearance-action-button')), - ); - await tester.pump(); - expect(paginationDuration(), Duration.zero); - }); - - testWidgets('appearance cycle becomes instant under Reduce Motion', ( - tester, - ) async { - await _pumpPicker( - tester, - prefs: {'buzz_theme_mode': 'light'}, - reduceMotion: true, - ); - final switcher = tester.widget( - find.byKey(const ValueKey('theme-preview-appearance-switcher')), - ); - expect(switcher.duration, Duration.zero); - - await tester.tap( - find.byKey(const ValueKey('theme-preview-appearance-action-button')), - ); - await tester.pump(); - expect( - find.byKey(const ValueKey('theme-appearance-dark')), - findsOneWidget, - ); - }); - - testWidgets('emits one selection haptic for appearance and scrub choices', ( - tester, - ) async { - await _pumpPicker( - tester, - prefs: { - 'buzz_theme_mode': 'dark', - 'buzz_color_scheme': 'catppuccin-macchiato', - }, - ); - var selectionHaptics = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'HapticFeedback.vibrate' && - call.arguments == 'HapticFeedbackType.selectionClick') { - selectionHaptics++; - } - return null; - }); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null), - ); - - await tester.tap( - find.byKey(const ValueKey('theme-preview-appearance-action-button')), - ); - await tester.pumpAndSettle(); - expect(selectionHaptics, 1); - - await tester.tap(find.byKey(const ValueKey('theme-pagination-dot-1'))); - await tester.pumpAndSettle(); - expect(selectionHaptics, 2); - }); - - testWidgets('accent remains a sheet and is committed with Set', ( - tester, - ) async { - final instance = await _pumpPicker( - tester, - prefs: { - 'buzz_theme_mode': 'light', - 'buzz_color_scheme': 'github-light', - 'buzz_accent_color': 0, - }, - ); - final storedBefore = instance.getInt('buzz_accent_color'); - await tester.tap( - find.byKey(const ValueKey('theme-preview-accent-action-button')), - ); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('accent-selection-grid')), - findsOneWidget, - ); - final green = accentColors.indexWhere((accent) => accent.name == 'Green'); - expect(find.text('Green'), findsNothing); - expect( - tester.getSemantics(find.byKey(ValueKey('accent-option-$green'))).label, - contains('Green accent color'), - ); - await tester.tap(find.byKey(ValueKey('accent-option-$green'))); - await tester.pumpAndSettle(); - expect(instance.getInt('buzz_accent_color'), storedBefore); - - Navigator.of( - tester.element(find.byKey(ValueKey('accent-option-$green'))), - ).pop(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('theme-preview-set'))); - await tester.pumpAndSettle(); - expect(instance.getInt('buzz_accent_color'), green); - }); - - testWidgets('accent swatch is optically smaller than its tap target', ( - tester, - ) async { - await _pumpPicker( - tester, - prefs: { - 'buzz_theme_mode': 'light', - 'buzz_color_scheme': 'github-light', - }, - ); - - expect( - tester.getSize( + .color; + final surface = surfaceColor(); + final fills = []; + for (final name in ['Blue', 'Green', 'Neutral']) { + await tester.tap( find.byKey(const ValueKey('theme-preview-accent-action-button')), - ), - const Size(64, 54), - ); - expect( - tester.getSize( - find.byKey(const ValueKey('theme-preview-accent-swatch')), - ), - const Size.square(46), - ); + ); + await tester.pumpAndSettle(); + final index = chatColorChoices.indexWhere( + (color) => color.name == name, + ); + final option = find.byKey(ValueKey('accent-option-$index')); + await tester.tap(option); + await tester.pumpAndSettle(); + Navigator.of(tester.element(option)).pop(); + await tester.pumpAndSettle(); + final theme = mobileAppearanceTheme( + brightness, + chatColorChoices[index].wireValue, + ); + final chat = theme.extension()!; + expect(bubbleColor(), chat.outgoing); + expect(foregroundColor(), chat.foreground); + expect(surfaceColor(), surface); + expect(notifier.preferenceWrites, 0); + fills.add(bubbleColor()); + } + expect(fills.toSet(), hasLength(3)); + expect(tester.takeException(), isNull); }); + } - testWidgets('Buzz keeps the accent action unavailable', (tester) async { - await _pumpPicker(tester, prefs: {'buzz_color_scheme': 'buzz'}); - expect( - find.byKey(const ValueKey('theme-preview-accent-action-button')), - findsNothing, - ); - }); + testWidgets('Close discards mode changes', (tester) async { + final notifier = _RecordingCommunityThemeNotifier(); + await _pumpPicker(tester, themeNotifier: () => notifier); + await tester.tap( + find.byKey(const ValueKey('theme-preview-appearance-action-button')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('theme-preview-close'))); + await tester.pumpAndSettle(); + expect(notifier.preferenceWrites, 0); }); + + testWidgets( + 'appearance cycles through Light Dark System with reduced motion', + (tester) async { + await _pumpPicker(tester, reduceMotion: true); + for (final label in ['Light', 'Dark', 'System']) { + await tester.tap( + find.byKey(const ValueKey('theme-preview-appearance-action-button')), + ); + await tester.pumpAndSettle(); + expect(find.text(label), findsOneWidget); + expect(tester.takeException(), isNull); + } + }, + ); } class _RecordingCommunityThemeNotifier extends CommunityThemeNotifier { diff --git a/mobile/test/shared/theme/chat_colors_test.dart b/mobile/test/shared/theme/chat_colors_test.dart new file mode 100644 index 0000000000..cb2b320b63 --- /dev/null +++ b/mobile/test/shared/theme/chat_colors_test.dart @@ -0,0 +1,61 @@ +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('matches the six desktop choices and bubble fills in order', () { + expect(chatColorChoices.map((color) => color.name), [ + 'Neutral', + 'Blue', + 'Green', + 'Orange', + 'Pink', + 'Lilac', + ]); + expect(chatColorChoices.map((color) => color.light.toARGB32()), [ + 0xFFE9E9EA, + 0xFF0077DB, + 0xFF008934, + 0xFFC95100, + 0xFFD33785, + 0xFFA94AD8, + ]); + expect(chatColorChoices.map((color) => color.dark.toARGB32()), [ + 0xFF2E2E2E, + 0xFF0077DB, + 0xFF008934, + 0xFFC95100, + 0xFFD33785, + 0xFFA94AD8, + ]); + }); + + test( + 'retired saved colors follow desktop aliases without losing preferences', + () { + for (final entry in { + '#06b6d4': 'Blue', + '#6366f1': 'Blue', + '#ef4444': 'Pink', + '#a855f7': 'Lilac', + }.entries) { + expect(chatColorChoices[chatColorIndex(entry.key)].name, entry.value); + final preference = CommunityThemePreference.fromJson({ + 'version': 1, + 'theme': 'buzz-dark', + 'accent': entry.key, + 'followSystem': false, + }); + expect(preference.mode, ThemeMode.dark); + expect(preference.accent, entry.key); + expect( + mobileAppearanceTheme( + Brightness.dark, + preference.accent, + ).extension()!.outgoing, + chatColorChoices[chatColorIndex(entry.key)].dark, + ); + } + }, + ); +} diff --git a/mobile/test/shared/theme/community_theme_preference_test.dart b/mobile/test/shared/theme/community_theme_preference_test.dart index ce6a2afaec..a2a1d186a1 100644 --- a/mobile/test/shared/theme/community_theme_preference_test.dart +++ b/mobile/test/shared/theme/community_theme_preference_test.dart @@ -99,6 +99,46 @@ void main() { expect(storage.readOutbox('pk', 'wss://relay.example'), isNull); }); + for (final color in chatColorChoices) { + for (final mode in ThemeMode.values) { + test( + 'local ${color.name} ${mode.name} survives a storage restart', + () async { + SharedPreferences.setMockInitialValues({ + 'buzz_accent_color': 0, + 'buzz_theme_mode': 'light', + }); + final prefs = await SharedPreferences.getInstance(); + final preference = CommunityThemePreference( + theme: mode == ThemeMode.dark ? buzzDarkThemeName : buzzThemeName, + accent: color.wireValue, + followSystem: mode == ThemeMode.system, + ); + await CommunityThemeStorage(prefs).writeLegacy(preference); + // Recreate preferences and storage from only the persisted snapshot. + final disk = { + for (final key in prefs.getKeys()) key: prefs.get(key)!, + }; + SharedPreferences.setMockInitialValues(disk); + final restarted = CommunityThemeStorage( + await SharedPreferences.getInstance(), + ); + expect(restarted.legacyPreference(), preference); + expect(restarted.legacyPreference().mode, mode); + }, + ); + } + } + + test('invalid local snapshot falls back to historical migration', () async { + SharedPreferences.setMockInitialValues({ + 'buzz-local-theme.v1': '{"version":99}', + 'buzz_accent_color': 8, + }); + final prefs = await SharedPreferences.getInstance(); + expect(CommunityThemeStorage(prefs).legacyPreference().accent, 'neutral'); + }); + test('legacy accent indexes migrate without inventing wire values', () async { SharedPreferences.setMockInitialValues({ 'buzz_theme_mode': 'dark', diff --git a/mobile/test/shared/theme/mobile_appearance_test.dart b/mobile/test/shared/theme/mobile_appearance_test.dart new file mode 100644 index 0000000000..e01b56c829 --- /dev/null +++ b/mobile/test/shared/theme/mobile_appearance_test.dart @@ -0,0 +1,34 @@ +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('desktop surface and text roles match in both modes', () { + final light = mobileAppearanceTheme(Brightness.light, 'neutral'); + final dark = mobileAppearanceTheme(Brightness.dark, 'neutral'); + expect(light.colorScheme.surface, const Color(0xFFFFFFFF)); + expect(light.colorScheme.onSurface, const Color(0xFF0A0A0A)); + expect(light.colorScheme.onSurfaceVariant, const Color(0xFF646464)); + expect(light.colorScheme.outline, const Color(0xFFE8E8E8)); + expect(dark.colorScheme.surface, const Color(0xFF1C1C1C)); + expect(dark.colorScheme.onSurface, const Color(0xFFF5F5F5)); + expect(dark.colorScheme.onSurfaceVariant, const Color(0xFFC1C1C1)); + expect(dark.colorScheme.outline, const Color(0xFF333333)); + }); + + for (final brightness in Brightness.values) { + test('chat colors do not recolor controls in $brightness', () { + final base = mobileAppearanceTheme(brightness, 'neutral'); + for (final accent in accentColors) { + final theme = mobileAppearanceTheme(brightness, accent.wireValue); + expect(theme.colorScheme, base.colorScheme); + final fill = theme.extension()!.outgoing; + final foreground = theme.extension()!.foreground; + final a = fill.computeLuminance(); + final b = foreground.computeLuminance(); + final ratio = a > b ? (a + .05) / (b + .05) : (b + .05) / (a + .05); + expect(ratio, greaterThanOrEqualTo(4.5)); + } + }); + } +}