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)); + } + }); + } +}