Align mobile appearance and channels with desktop (#8358)

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 <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-15 08:45:16 -04:00
committed by GitHub
parent d28f6b2f70
commit a5dfdd6d55
24 changed files with 752 additions and 1174 deletions

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<CustomEmoji> 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<PopupMenuEntry<String>> _sortMenuItems(
class _ChannelSection extends StatelessWidget {
final String title;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
final List<Channel> 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;

View File

@@ -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],

View File

@@ -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,

View File

@@ -36,7 +36,7 @@ Future<void> showAccentColorPickerSheet({
required ValueChanged<int> onSelected,
}) => showBuzzModalBottomSheet<void>(
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(

View File

@@ -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<ThemeColors> _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});

View File

@@ -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,

View File

@@ -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}'),

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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<void> 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 ??

View File

@@ -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<ChatColors> {
/// 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>() ??
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,
),
],
);
}

View File

@@ -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';