mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(mobile): restore channel icons and align chat colors with desktop (#8478)
## Summary - Restores `#` and lock icons for public and private channels on Home, with matching loading placeholders. - Updates the dark-mode navigation colors to the desktop design-system tokens (read and unread text, section headings). - Replaces the chat color picker with the seven desktop accents (Gray, Chartreuse, Flame, Magenta, Iris, Cobalt, Jade). Saved legacy colors map to the closest one. - Uses white text in your own bubbles, and black where white falls below 4.5:1 (Chartreuse, dark-mode Gray). Links (underlined), quotes, tables and code use the same text color. - Insets code blocks in bubbles evenly, with corners concentric to the bubble and even inner padding. - The swipe-to-reply indicator uses the chosen chat color. ## Testing - `flutter analyze` and `dart format` are clean. The full mobile suite passes (3,705 tests). - Installed and checked on a Pixel 10 and an iPhone Air against a real community. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Queue <874bf2d815c2c4b459b90438c669c57f2702f3a76bb6e16fa97184259aac3e9e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,8 +52,16 @@ export const ACCENT_COLORS = [
|
||||
{ name: "Lilac", value: "#c0a2f1" },
|
||||
{ name: "Purple", value: "#a855f7" },
|
||||
{ name: "Indigo", value: "#6366f1" },
|
||||
{ name: "Chartreuse", value: "#d7d72e" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Accents too light to read as `text-primary` on light themes. They darken
|
||||
* there; the legacy accents keep their established rendering.
|
||||
*/
|
||||
const LIGHT_ONLY_ACCENTS = new Set<string>(["#d7d72e"]);
|
||||
const ACCENT_TEXT_CONTRAST = 4.5;
|
||||
|
||||
const DEFAULT_ACCENT = "#3b82f6";
|
||||
|
||||
type ThemeContextValue = {
|
||||
@@ -195,6 +203,62 @@ function rgbToHex({ r, g, b }: Rgb): string {
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
/** Parses a `--background`-style `H S% L%` triplet. */
|
||||
function hslTripletToRgb(value: string): Rgb | null {
|
||||
const m = /^(-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/.exec(value.trim());
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]) / 360;
|
||||
const s = Number(m[2]) / 100;
|
||||
const l = Number(m[3]) / 100;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const channel = (offset: number) => {
|
||||
const t = (((h + offset) % 1) + 1) % 1;
|
||||
if (t < 1 / 6) return (p + (q - p) * 6 * t) * 255;
|
||||
if (t < 1 / 2) return q * 255;
|
||||
if (t < 2 / 3) return (p + (q - p) * (2 / 3 - t) * 6) * 255;
|
||||
return p * 255;
|
||||
};
|
||||
return { r: channel(1 / 3), g: channel(0), b: channel(-1 / 3) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--primary` fill and its ink for an accent on the theme `background`.
|
||||
* `--primary` is also drawn directly as text, so a light-only accent darkens
|
||||
* until it reads on that background.
|
||||
*/
|
||||
export function accentPrimary(
|
||||
value: string,
|
||||
background: string,
|
||||
): { fill: string; ink: string } {
|
||||
const accent = hexToRgb(value);
|
||||
const surface = hslTripletToRgb(background);
|
||||
let fill = value;
|
||||
if (
|
||||
LIGHT_ONLY_ACCENTS.has(value) &&
|
||||
surface &&
|
||||
contrastRatio(accent, surface) < ACCENT_TEXT_CONTRAST
|
||||
) {
|
||||
const black = { r: 0, g: 0, b: 0 };
|
||||
// Search over rounded hex so the published fill itself meets the ratio.
|
||||
const darken = (amount: number) => rgbToHex(mixRgb(accent, black, amount));
|
||||
let low = 0;
|
||||
let high = 1;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const mid = (low + high) / 2;
|
||||
if (
|
||||
contrastRatio(hexToRgb(darken(mid)), surface) >= ACCENT_TEXT_CONTRAST
|
||||
) {
|
||||
high = mid;
|
||||
} else {
|
||||
low = mid;
|
||||
}
|
||||
}
|
||||
fill = darken(high);
|
||||
}
|
||||
return { fill, ink: getContrastColor(fill) };
|
||||
}
|
||||
|
||||
function applyAccentColor(value: string) {
|
||||
const root = document.documentElement;
|
||||
if (value === NEUTRAL_ACCENT) {
|
||||
@@ -219,14 +283,18 @@ function applyAccentColor(value: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hex = value;
|
||||
const accentHsl = hexToHsl(hex);
|
||||
const fgHsl = hexToHsl(getContrastColor(hex));
|
||||
const { fill, ink } = accentPrimary(
|
||||
value,
|
||||
window.getComputedStyle(root).getPropertyValue("--background"),
|
||||
);
|
||||
const accentHsl = hexToHsl(fill);
|
||||
const fgHsl = hexToHsl(ink);
|
||||
root.style.setProperty("--buzz-selected-accent", accentHsl);
|
||||
root.style.setProperty("--buzz-video-review-accent", accentHsl);
|
||||
// The review chip sits on its own dark surface, so it keeps the raw accent.
|
||||
root.style.setProperty("--buzz-video-review-accent", hexToHsl(value));
|
||||
root.style.setProperty(
|
||||
"--buzz-video-review-accent-foreground",
|
||||
getReviewAccentForeground(hex),
|
||||
getReviewAccentForeground(value),
|
||||
);
|
||||
root.style.setProperty("--primary", accentHsl);
|
||||
root.style.setProperty("--primary-foreground", fgHsl);
|
||||
|
||||
45
desktop/src/shared/theme/accentPrimary.test.mjs
Normal file
45
desktop/src/shared/theme/accentPrimary.test.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { hexToHsl } from "./adaptive-theme.ts";
|
||||
import { ACCENT_COLORS, accentPrimary } from "./ThemeProvider.tsx";
|
||||
|
||||
function luminance(hex) {
|
||||
const channels = [1, 3, 5].map((i) => {
|
||||
const value = parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrast(a, b) {
|
||||
const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (high + 0.05) / (low + 0.05);
|
||||
}
|
||||
|
||||
// Light backgrounds down to the adaptive engine's 0.5-luminance light cutoff.
|
||||
const LIGHT = ["#ffffff", "#fafafa", "#f6f8fa", "#eff1f5", "#bcbcbc"];
|
||||
const DARK = ["#0d1117", "#24273a", "#1e1e1e"];
|
||||
|
||||
test("Chartreuse reads as text and as a filled control on every theme", () => {
|
||||
for (const background of [...LIGHT, ...DARK]) {
|
||||
const { fill, ink } = accentPrimary("#d7d72e", hexToHsl(background));
|
||||
assert.ok(contrast(fill, background) >= 4.5, `text on ${background}`);
|
||||
assert.ok(contrast(ink, fill) >= 4.5, `ink on fill over ${background}`);
|
||||
}
|
||||
// Dark themes keep the raw accent.
|
||||
for (const background of DARK) {
|
||||
assert.equal(
|
||||
accentPrimary("#d7d72e", hexToHsl(background)).fill,
|
||||
"#d7d72e",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy accents keep their established fill", () => {
|
||||
for (const { value } of ACCENT_COLORS) {
|
||||
if (value === "neutral" || value === "#d7d72e") continue;
|
||||
for (const background of [...LIGHT, ...DARK]) {
|
||||
assert.equal(accentPrimary(value, hexToHsl(background)).fill, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { ACCENT_COLORS } from "./ThemeProvider.tsx";
|
||||
import {
|
||||
DEFAULT_COMMUNITY_THEME,
|
||||
cacheAndApplyCommunityTheme,
|
||||
@@ -48,6 +50,41 @@ test("parses only the versioned stable appearance contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("accents follow the cross-client v1 contract", () => {
|
||||
// Mobile's chat color test reads the same fixture, so both clients agree on
|
||||
// every value either can save.
|
||||
const contract = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
"../../../../test-fixtures/community-theme-accents.json",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const base = { version: 1, theme: "houston", followSystem: false };
|
||||
for (const { accent, desktop } of [...contract.emitted, ...contract.legacy]) {
|
||||
assert.equal(
|
||||
parseCommunityThemePreference({ ...base, accent })?.accent,
|
||||
accent,
|
||||
accent,
|
||||
);
|
||||
assert.equal(
|
||||
ACCENT_COLORS.find(({ value }) => value === accent)?.name,
|
||||
desktop,
|
||||
accent,
|
||||
);
|
||||
}
|
||||
for (const accent of contract.rejected) {
|
||||
assert.equal(parseCommunityThemePreference({ ...base, accent }), null);
|
||||
}
|
||||
// Older clients reject an unknown accent with the whole preference, so the
|
||||
// default published without a picker action stays in their set.
|
||||
assert.ok(
|
||||
contract.baseReaders.accepted.includes(DEFAULT_COMMUNITY_THEME.accent),
|
||||
);
|
||||
});
|
||||
|
||||
test("local preferences are isolated by pubkey and normalized relay", () => {
|
||||
globalThis.window = { localStorage: localStorageStub() };
|
||||
const aliceA = {
|
||||
|
||||
@@ -1810,7 +1810,7 @@ test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
|
||||
return { x: box.x, y: box.y };
|
||||
}),
|
||||
);
|
||||
expect(swatchBoxes).toHaveLength(10);
|
||||
expect(swatchBoxes).toHaveLength(11);
|
||||
expect(new Set(swatchBoxes.map((box) => Math.round(box.y))).size).toBe(1);
|
||||
await expect(page.getByTestId("accent-color-options")).toHaveCSS(
|
||||
"overflow-x",
|
||||
|
||||
@@ -27,8 +27,8 @@ class _ChannelTile extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final contentColor = isMuted
|
||||
? navigationSecondaryForeground(context)
|
||||
: navigationPrimaryForeground(context);
|
||||
? navigationMetadataForeground(context)
|
||||
: channelNavigationForeground(context);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
@@ -55,6 +55,22 @@ class _ChannelTile extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kChannelLabelGap),
|
||||
] else ...[
|
||||
SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(
|
||||
channel.visibility == 'private'
|
||||
? BuzzIcons.lock
|
||||
: BuzzIcons.hash,
|
||||
key: ValueKey('channel-icon-${channel.id}'),
|
||||
size: _kChannelIconSize,
|
||||
color: contentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kChannelLabelGap),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -73,7 +89,12 @@ class _ChannelTile extends ConsumerWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: contentListTitleTextStyle.copyWith(
|
||||
color: contentColor,
|
||||
color: isMuted
|
||||
? contentColor
|
||||
: channelNavigationForeground(
|
||||
context,
|
||||
unread: isUnread,
|
||||
),
|
||||
fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
@@ -92,7 +113,7 @@ class _ChannelTile extends ConsumerWidget {
|
||||
child: Icon(
|
||||
BuzzIcons.bellOff,
|
||||
size: 12,
|
||||
color: context.colors.onSurface.withValues(alpha: 0.4),
|
||||
color: navigationMetadataForeground(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -115,9 +115,7 @@ class _CustomSectionHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionColor = context.colors.brightness == Brightness.dark
|
||||
? const Color(0xFF595959)
|
||||
: const Color(0xFF959595);
|
||||
final sectionColor = navigationMetadataForeground(context);
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -144,7 +142,7 @@ class _CustomSectionHeader extends StatelessWidget {
|
||||
icon: Icon(
|
||||
BuzzIcons.ellipsisVertical,
|
||||
size: _kChannelIconSize,
|
||||
color: sectionColor,
|
||||
color: navigationSecondaryForeground(context),
|
||||
),
|
||||
onPressed: () async {
|
||||
final value = await showAnchoredPopover<String>(
|
||||
@@ -468,9 +466,7 @@ class _SectionHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionColor = context.colors.brightness == Brightness.dark
|
||||
? const Color(0xFF595959)
|
||||
: const Color(0xFF959595);
|
||||
final sectionColor = navigationMetadataForeground(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
@@ -499,7 +495,7 @@ class _SectionHeader extends StatelessWidget {
|
||||
icon: Icon(
|
||||
BuzzIcons.ellipsisVertical,
|
||||
size: _kChannelIconSize,
|
||||
color: sectionColor,
|
||||
color: navigationSecondaryForeground(context),
|
||||
),
|
||||
onPressed: () async {
|
||||
final value = await showAnchoredPopover<String>(
|
||||
@@ -551,7 +547,10 @@ class _SectionLabel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
_SectionChevron(expanded: expanded, color: color),
|
||||
_SectionChevron(
|
||||
expanded: expanded,
|
||||
color: navigationSecondaryForeground(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,17 @@ class _ChannelSkeletonSection extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SkeletonBar(
|
||||
width: _kChannelIconSize,
|
||||
height: _kChannelIconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kChannelLabelGap),
|
||||
SkeletonBar(
|
||||
key: Key('channels-skeleton-row-label-$index'),
|
||||
width: widths[index],
|
||||
|
||||
@@ -200,12 +200,9 @@ class MessageContent extends HookConsumerWidget {
|
||||
final markdownTheme = GptMarkdownTheme.of(context);
|
||||
final chatColors = ChatColors.of(context);
|
||||
final ownTextColor = chatColors.foreground;
|
||||
// Desktop uses blue links on neutral bubbles and the bubble ink on colors.
|
||||
final linkColor = ownMessage && ownTextColor == Colors.white
|
||||
? ownTextColor
|
||||
: context.colors.brightness == Brightness.dark
|
||||
? const Color(0xFF83C4FF)
|
||||
: const Color(0xFF0B5FA8);
|
||||
// Own-bubble links share the bubble ink; markdown marks them by underline.
|
||||
final linkColor = ownMessage ? ownTextColor : context.colors.tertiary;
|
||||
final bubbleInk = ownMessage ? ownTextColor : context.colors.onSurface;
|
||||
final baseTextStyle =
|
||||
(baseStyle ??
|
||||
context.textTheme.bodyMedium?.copyWith(
|
||||
@@ -361,12 +358,27 @@ class MessageContent extends HookConsumerWidget {
|
||||
child: GptMarkdown(
|
||||
text,
|
||||
style: style,
|
||||
styleSheet: bubble
|
||||
? GptMarkdownStyleSheet(
|
||||
blockQuote: BlockQuoteStyle(barColor: bubbleInk),
|
||||
hr: HrStyle(color: bubbleInk.withValues(alpha: 0.3)),
|
||||
table: TableStyle(
|
||||
borderColor: bubbleInk.withValues(alpha: 0.3),
|
||||
headerBackground: Colors.transparent,
|
||||
headerTextStyle: TextStyle(color: bubbleInk),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
followLinkColor: false,
|
||||
// normalizeBareLinks() already turns bare URLs into Markdown links;
|
||||
// gpt_markdown 1.2.0 autolinks by default, so both would run.
|
||||
autolink: false,
|
||||
codeBuilder: (context, name, code, closed) =>
|
||||
_MessageCodeBlock(name: name, code: code),
|
||||
codeBuilder: (context, name, code, closed) => _MessageCodeBlock(
|
||||
name: name,
|
||||
code: code,
|
||||
bubble: bubble,
|
||||
ink: bubble && ownMessage ? ownTextColor : null,
|
||||
),
|
||||
linkBuilder: (context, linkText, url, linkStyle) => _buildLink(
|
||||
context,
|
||||
ref,
|
||||
@@ -426,7 +438,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
child: LocalSendBubbleSurface(
|
||||
color: ownMessage
|
||||
? chatColors.outgoing
|
||||
: context.colors.secondaryContainer,
|
||||
: receivedMessageSurface(context.colors.brightness),
|
||||
shape: const _MessageBubbleShape(),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@@ -445,6 +457,21 @@ class MessageContent extends HookConsumerWidget {
|
||||
),
|
||||
child: GptMarkdownTheme(
|
||||
gptThemeData: markdownTheme.copyWith(
|
||||
linkColor: linkColor,
|
||||
// A neutral chip would put white ink on a light
|
||||
// fill; tint the accent instead.
|
||||
inlineCode: ownMessage
|
||||
? markdownTheme.inlineCode.copyWith(
|
||||
color: ownTextColor,
|
||||
backgroundColor: chatCodeTint(
|
||||
ownTextColor,
|
||||
0.18,
|
||||
),
|
||||
borderColor: ownTextColor.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
h1: markdownTheme.h1?.copyWith(
|
||||
color: ownMessage
|
||||
? ownTextColor
|
||||
@@ -869,7 +896,18 @@ class _MessageCodeBlock extends HookWidget {
|
||||
final String name;
|
||||
final String code;
|
||||
|
||||
const _MessageCodeBlock({required this.name, required this.code});
|
||||
/// Whether the block sits inside a message bubble.
|
||||
final bool bubble;
|
||||
|
||||
/// Text color on an accent bubble; null keeps the neutral highlighted card.
|
||||
final Color? ink;
|
||||
|
||||
const _MessageCodeBlock({
|
||||
required this.name,
|
||||
required this.code,
|
||||
this.bubble = false,
|
||||
this.ink,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -884,83 +922,101 @@ class _MessageCodeBlock extends HookWidget {
|
||||
});
|
||||
}
|
||||
|
||||
final ink = this.ink;
|
||||
// A fence's closing newline would otherwise render as a blank last line.
|
||||
final shownCode = code.trimRight();
|
||||
final codeBaseStyle = TextStyle(
|
||||
fontFamily: CodeStyle.fontFamily,
|
||||
fontSize: CodeStyle.fontSize,
|
||||
height: CodeStyle.lineHeight,
|
||||
color: context.colors.onSurface,
|
||||
color: ink ?? context.colors.onSurface,
|
||||
);
|
||||
final isDark = context.theme.brightness == Brightness.dark;
|
||||
final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme;
|
||||
// Syntax colors are tuned for neutral cards and lose contrast on accents.
|
||||
final codeSpans = useMemoized(
|
||||
() => highlightCode(code, name, codeTheme, codeBaseStyle),
|
||||
[code, name, isDark],
|
||||
() => ink == null
|
||||
? highlightCode(shownCode, name, codeTheme, codeBaseStyle)
|
||||
: [TextSpan(text: shownCode)],
|
||||
[code, name, isDark, ink],
|
||||
);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: Grid.half),
|
||||
// In a bubble, the vertical margin brings the bubble's 8px vertical
|
||||
// padding up to its 16px side padding, so the block is inset evenly and
|
||||
// its corners stay concentric with the bubble's.
|
||||
margin: bubble
|
||||
? const EdgeInsets.symmetric(vertical: Grid.xs - Grid.xxs)
|
||||
: const EdgeInsets.only(top: Grid.half),
|
||||
decoration: BoxDecoration(
|
||||
color: CodeStyle.background(context.colors),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: CodeStyle.border(context.colors)),
|
||||
color: ink == null
|
||||
? CodeStyle.background(context.colors)
|
||||
: chatCodeTint(ink, 0.14),
|
||||
borderRadius: BorderRadius.circular(
|
||||
bubble ? Radii.container - Grid.xs : 12,
|
||||
),
|
||||
border: Border.all(
|
||||
color:
|
||||
ink?.withValues(alpha: 0.24) ?? CodeStyle.border(context.colors),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (name.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Grid.twelve,
|
||||
top: Grid.half + Grid.quarter,
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Even 12px insets; the right side also reserves the copy button.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.twelve,
|
||||
Grid.twelve,
|
||||
Grid.twelve + 28,
|
||||
Grid.twelve,
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.twelve,
|
||||
name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter,
|
||||
44,
|
||||
Grid.half + Grid.quarter,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (name.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.half),
|
||||
child: Text(
|
||||
name,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: ink ?? context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: RichText(
|
||||
softWrap: false,
|
||||
text: TextSpan(style: codeBaseStyle, children: codeSpans),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: Grid.quarter,
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
onPressed: handleCopy,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: Icon(
|
||||
isCopied.value ? BuzzIcons.check : BuzzIcons.copy,
|
||||
size: 14,
|
||||
color: isCopied.value
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: Grid.half + Grid.quarter,
|
||||
right: Grid.half + Grid.quarter,
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
onPressed: handleCopy,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: Icon(
|
||||
isCopied.value ? BuzzIcons.check : BuzzIcons.copy,
|
||||
size: 14,
|
||||
color:
|
||||
ink ??
|
||||
(isCopied.value
|
||||
? context.colors.primary
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
: context.colors.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -139,11 +139,10 @@ class MessageSwipeReply extends HookConsumerWidget {
|
||||
child: child,
|
||||
builder: (context, child) {
|
||||
final progress = (offset.value / 72).clamp(0.0, 1.0);
|
||||
final accent = context.appColors.accent;
|
||||
final filledForeground =
|
||||
ThemeData.estimateBrightnessForColor(accent) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black;
|
||||
// Follows the chosen message color, like the user's own bubbles.
|
||||
final chatColors = ChatColors.of(context);
|
||||
final accent = chatColors.outgoing;
|
||||
final filledForeground = chatColors.foreground;
|
||||
return Stack(
|
||||
key: layoutKey,
|
||||
clipBehavior: Clip.none,
|
||||
|
||||
@@ -79,6 +79,12 @@ const accentColors = [
|
||||
dark: Color(0xFF818CF8),
|
||||
wireValue: '#6366f1',
|
||||
),
|
||||
AccentColor(
|
||||
name: 'Chartreuse',
|
||||
light: Color(0xFFD7D72E),
|
||||
dark: Color(0xFFD7D72E),
|
||||
wireValue: '#d7d72e',
|
||||
),
|
||||
];
|
||||
|
||||
Color accentColorForScheme(ColorScheme scheme, int accentIndex) {
|
||||
|
||||
@@ -100,3 +100,17 @@ LinearGradient? buzzTopSectionGradient(
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Desktop navigation ink distinguishes read destinations from unread labels.
|
||||
Color channelNavigationForeground(BuildContext context, {bool unread = false}) {
|
||||
if (unread || Theme.of(context).brightness != Brightness.dark) {
|
||||
return navigationPrimaryForeground(context);
|
||||
}
|
||||
return Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
/// Metadata ink used by Home section headings and muted destinations.
|
||||
Color navigationMetadataForeground(BuildContext context) =>
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? const Color(0xFFA4A4A4)
|
||||
: const Color(0xFF5F5F5F);
|
||||
|
||||
@@ -2,59 +2,82 @@ 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.
|
||||
/// Desktop accent families in picker order. Fills are accent-9 from buzz-app's
|
||||
/// design-system tokens (4ad00a8a).
|
||||
///
|
||||
/// Wire values are desktop's v1 `community-theme` accents for the same color
|
||||
/// family, so a synced preference means the same color on every client. The
|
||||
/// cross-client mapping lives in `test-fixtures/community-theme-accents.json`.
|
||||
const chatColorChoices = [
|
||||
AccentColor(
|
||||
name: 'Neutral',
|
||||
light: Color(0xFFE9E9EA),
|
||||
dark: Color(0xFF2E2E2E),
|
||||
name: 'Gray',
|
||||
light: Color(0xFF525252),
|
||||
dark: Color(0xFFC7C7C7),
|
||||
wireValue: 'neutral',
|
||||
),
|
||||
AccentColor(
|
||||
name: 'Blue',
|
||||
light: Color(0xFF0077DB),
|
||||
dark: Color(0xFF0077DB),
|
||||
wireValue: '#3b82f6',
|
||||
name: 'Chartreuse',
|
||||
light: Color(0xFFD7D72E),
|
||||
dark: Color(0xFFD7D72E),
|
||||
wireValue: '#d7d72e',
|
||||
),
|
||||
AccentColor(
|
||||
name: 'Green',
|
||||
light: Color(0xFF008934),
|
||||
dark: Color(0xFF008934),
|
||||
wireValue: '#22c55e',
|
||||
),
|
||||
AccentColor(
|
||||
name: 'Orange',
|
||||
light: Color(0xFFC95100),
|
||||
dark: Color(0xFFC95100),
|
||||
name: 'Flame',
|
||||
light: Color(0xFFD93A0C),
|
||||
dark: Color(0xFFD93A0C),
|
||||
wireValue: '#f97316',
|
||||
),
|
||||
AccentColor(
|
||||
name: 'Pink',
|
||||
light: Color(0xFFD33785),
|
||||
dark: Color(0xFFD33785),
|
||||
name: 'Magenta',
|
||||
light: Color(0xFFCC1F7A),
|
||||
dark: Color(0xFFCC1F7A),
|
||||
wireValue: '#ec4899',
|
||||
),
|
||||
AccentColor(
|
||||
name: 'Lilac',
|
||||
light: Color(0xFFA94AD8),
|
||||
dark: Color(0xFFA94AD8),
|
||||
name: 'Iris',
|
||||
light: Color(0xFF6C4EF0),
|
||||
dark: Color(0xFF6C4EF0),
|
||||
wireValue: '#c0a2f1',
|
||||
),
|
||||
AccentColor(
|
||||
name: 'Cobalt',
|
||||
light: Color(0xFF2B4FE0),
|
||||
dark: Color(0xFF2B4FE0),
|
||||
wireValue: '#3b82f6',
|
||||
),
|
||||
AccentColor(
|
||||
name: 'Jade',
|
||||
light: Color(0xFF0B8467),
|
||||
dark: Color(0xFF0B8467),
|
||||
wireValue: '#22c55e',
|
||||
),
|
||||
];
|
||||
|
||||
/// Resolves old community colors using desktop's retired-color aliases.
|
||||
/// Resolves a v1 accent: each choice's own value, desktop's remaining legacy
|
||||
/// values via their closest family, and family names written by earlier
|
||||
/// preview builds. Anything else falls back to Chartreuse.
|
||||
int chatColorIndex(String wireValue) {
|
||||
final normalized = switch (wireValue) {
|
||||
'#06b6d4' || '#6366f1' => '#3b82f6',
|
||||
'#06b6d4' => '#22c55e',
|
||||
'#6366f1' => '#3b82f6',
|
||||
'#ef4444' => '#ec4899',
|
||||
'#a855f7' => '#c0a2f1',
|
||||
_ => wireValue,
|
||||
};
|
||||
final index = chatColorChoices.indexWhere(
|
||||
(color) => color.wireValue == normalized,
|
||||
(color) =>
|
||||
color.wireValue == normalized || color.name.toLowerCase() == normalized,
|
||||
);
|
||||
return index < 0 ? 0 : index;
|
||||
return index < 0 ? 1 : index;
|
||||
}
|
||||
|
||||
/// Desktop-compatible value for a family name written by earlier preview
|
||||
/// builds, so those preferences sync again once republished.
|
||||
String? previewFamilyWireValue(String value) {
|
||||
for (final color in chatColorChoices) {
|
||||
if (color.name.toLowerCase() == value) return color.wireValue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Resolves a picker swatch for the active appearance mode.
|
||||
@@ -62,3 +85,23 @@ Color chatColorForScheme(ColorScheme scheme, int index) {
|
||||
final color = chatColorChoices[index];
|
||||
return scheme.brightness == Brightness.dark ? color.dark : color.light;
|
||||
}
|
||||
|
||||
/// Bubble text is white unless white falls below 4.5:1 on the fill, as on
|
||||
/// Chartreuse and dark-mode Gray; those light fills take black instead.
|
||||
Color chatForegroundForScheme(ColorScheme scheme, int index) {
|
||||
final fill = chatColorForScheme(scheme, index).computeLuminance();
|
||||
return 1.05 / (fill + .05) >= 4.5 ? Colors.white : Colors.black;
|
||||
}
|
||||
|
||||
/// Translucent code surface behind [ink] on an accent bubble. It shades toward
|
||||
/// the opposite of the ink, so it can only raise contrast over the fill.
|
||||
Color chatCodeTint(Color ink, double opacity) =>
|
||||
(ink == Colors.white ? Colors.black : Colors.white).withValues(
|
||||
alpha: opacity,
|
||||
);
|
||||
|
||||
/// Received messages use their own surface rather than a control fill.
|
||||
Color receivedMessageSurface(Brightness brightness) =>
|
||||
brightness == Brightness.dark
|
||||
? const Color(0xFF292929)
|
||||
: const Color(0xFFF3F3F4);
|
||||
|
||||
@@ -25,8 +25,7 @@ abstract final class CodeStyle {
|
||||
static final fontSizeFactor = fontSize / messageBodyTextStyle.fontSize!;
|
||||
|
||||
/// Fill behind a code surface.
|
||||
static Color background(ColorScheme scheme) =>
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.6);
|
||||
static Color background(ColorScheme scheme) => scheme.surfaceContainerHighest;
|
||||
|
||||
/// Outline around a code surface.
|
||||
static Color border(ColorScheme scheme) =>
|
||||
|
||||
@@ -14,7 +14,7 @@ const lightColorScheme = ColorScheme(
|
||||
onSecondary: Color(0xFFFFFFFF),
|
||||
secondaryContainer: Color(0xFFF0F0F0),
|
||||
onSecondaryContainer: Color(0xFF0A0A0A),
|
||||
tertiary: Color(0xFF0D74CE),
|
||||
tertiary: Color(0xFF0B5FA8),
|
||||
onTertiary: Color(0xFFFFFFFF),
|
||||
tertiaryContainer: Color(0xFFE6F4FE),
|
||||
onTertiaryContainer: Color(0xFF113264),
|
||||
@@ -42,37 +42,37 @@ const lightColorScheme = ColorScheme(
|
||||
|
||||
const darkColorScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
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),
|
||||
primary: Color(0xFFFFFFFF),
|
||||
onPrimary: Color(0xFF000000),
|
||||
primaryContainer: Color(0xFF282828),
|
||||
onPrimaryContainer: Color(0xFFFFFFFF),
|
||||
secondary: Color(0xFFC7C7C7),
|
||||
onSecondary: Color(0xFF000000),
|
||||
secondaryContainer: Color(0xFF282828),
|
||||
onSecondaryContainer: Color(0xFFFFFFFF),
|
||||
tertiary: Color(0xFF83C4FF),
|
||||
onTertiary: Color(0xFF000000),
|
||||
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),
|
||||
surface: Color(0xFF000000),
|
||||
onSurface: Color(0xFFFFFFFF),
|
||||
onSurfaceVariant: Color(0xFFC7C7C7),
|
||||
outline: Color(0xFF1A1A1A),
|
||||
outlineVariant: Color(0xFF1A1A1A),
|
||||
inverseSurface: Color(0xFFC7C7C7),
|
||||
onInverseSurface: Color(0xFF000000),
|
||||
inversePrimary: Color(0xFF262626),
|
||||
shadow: Color(0xFF000000),
|
||||
scrim: Color(0xFF000000),
|
||||
surfaceContainerLowest: Color(0xFF0F0F0F),
|
||||
surfaceContainerLow: Color(0xFF161616),
|
||||
surfaceContainer: Color(0xFF232323),
|
||||
surfaceContainerHigh: Color(0xFF2A2A2A),
|
||||
surfaceContainerHighest: Color(0xFF2A2A2A),
|
||||
surfaceContainerLowest: Color(0xFF000000),
|
||||
surfaceContainerLow: Color(0xFF101010),
|
||||
surfaceContainer: Color(0xFF1A1A1A),
|
||||
surfaceContainerHigh: Color(0xFF282828),
|
||||
surfaceContainerHighest: Color(0xFF282828),
|
||||
surfaceTint: Colors.transparent,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'accent_colors.dart';
|
||||
import 'chat_colors.dart';
|
||||
import 'theme_catalog.dart';
|
||||
import 'theme_provider.dart' show effectiveTheme, schemeForAppearanceMode;
|
||||
|
||||
const communityThemeDTag = 'community-theme';
|
||||
const defaultCommunityTheme = CommunityThemePreference(
|
||||
theme: 'buzz',
|
||||
accent: '#3b82f6',
|
||||
// Seeded without a picker action, so it must stay readable by older clients.
|
||||
accent: '#3b82f6', // Cobalt
|
||||
followSystem: true,
|
||||
);
|
||||
|
||||
@@ -32,13 +34,16 @@ class CommunityThemePreference {
|
||||
json['theme'] is! String ||
|
||||
findTheme(json['theme'] as String) == null ||
|
||||
json['accent'] is! String ||
|
||||
accentIndexForWireValue(json['accent'] as String) == null ||
|
||||
(accentIndexForWireValue(json['accent'] as String) == null &&
|
||||
previewFamilyWireValue(json['accent'] as String) == null) ||
|
||||
json['followSystem'] is! bool) {
|
||||
throw const FormatException('Invalid community theme preference');
|
||||
}
|
||||
return CommunityThemePreference(
|
||||
theme: json['theme'] as String,
|
||||
accent: json['accent'] as String,
|
||||
accent:
|
||||
previewFamilyWireValue(json['accent'] as String) ??
|
||||
json['accent'] as String,
|
||||
followSystem: json['followSystem'] as bool,
|
||||
);
|
||||
}
|
||||
@@ -157,7 +162,9 @@ class CommunityThemeStorage {
|
||||
};
|
||||
return CommunityThemePreference(
|
||||
theme: resolvedTheme,
|
||||
accent: legacyAccentWireValue(legacyAccent),
|
||||
accent: legacyAccent == null
|
||||
? defaultCommunityTheme.accent
|
||||
: legacyAccentWireValue(legacyAccent),
|
||||
followSystem: mode == ThemeMode.system,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ ThemeData mobileAppearanceTheme(Brightness brightness, String chatColor) {
|
||||
...theme.extensions.values,
|
||||
ChatColors(
|
||||
outgoing: chatColorForScheme(theme.colorScheme, index),
|
||||
foreground: index == 0 ? theme.colorScheme.onSurface : Colors.white,
|
||||
foreground: chatForegroundForScheme(theme.colorScheme, index),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'app_colors.dart';
|
||||
/// Shared neutral background for mobile controls and selected tabs.
|
||||
Color neutralControlSurface(Brightness brightness) =>
|
||||
brightness == Brightness.dark
|
||||
? const Color(0xFF595959)
|
||||
? const Color(0xFF101010)
|
||||
: const Color(0xFFF0F0F0);
|
||||
|
||||
extension AppThemeExtension on BuildContext {
|
||||
|
||||
@@ -238,6 +238,7 @@ Widget _buildTestable({
|
||||
ProfileNotifier? profileNotifier,
|
||||
String? signingPubkey,
|
||||
bool darkTheme = false,
|
||||
String? chatColor,
|
||||
bool personalChat = false,
|
||||
required List<NostrEvent> messages,
|
||||
PresenceCacheNotifier? presenceCache,
|
||||
@@ -465,7 +466,14 @@ Widget _buildTestable({
|
||||
],
|
||||
child: MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
theme: darkTheme ? AppTheme.dark() : AppTheme.light(),
|
||||
theme: chatColor != null
|
||||
? mobileAppearanceTheme(
|
||||
darkTheme ? Brightness.dark : Brightness.light,
|
||||
chatColor,
|
||||
)
|
||||
: darkTheme
|
||||
? AppTheme.dark()
|
||||
: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: textScaler,
|
||||
|
||||
@@ -16,6 +16,7 @@ void swipeReplyTests() {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: events,
|
||||
chatColor: 'magenta',
|
||||
home: thread
|
||||
? ThreadDetailPage(
|
||||
threadHead: messages.first,
|
||||
@@ -86,7 +87,8 @@ void swipeReplyTests() {
|
||||
expect(tester.getRect(circle).left, initial.dx);
|
||||
final fill =
|
||||
tester.widget<DecoratedBox>(circle).decoration as BoxDecoration;
|
||||
expect(fill.color, tester.element(circle).appColors.accent);
|
||||
// The armed circle uses the chosen message color, not the app accent.
|
||||
expect(fill.color, const Color(0xFFCC1F7A));
|
||||
expect(find.text('Reply'), findsOneWidget);
|
||||
final centerBeforePulse = tester.getCenter(
|
||||
find.byKey(const ValueKey('swipe-reply-feedback')),
|
||||
|
||||
@@ -2,14 +2,18 @@ import 'package:buzz/features/home/home_page.dart';
|
||||
import 'package:buzz/shared/widgets/app_route_observer.dart';
|
||||
import 'package:buzz/features/pairing/pairing_provider.dart';
|
||||
import 'package:buzz/shared/community/paired_community_landing.dart';
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../profile/presence_snapshot_test.dart'
|
||||
show PresenceTestRelay, presenceEvent;
|
||||
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:buzz/shared/widgets/buzz_sheet_header.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:buzz/shared/community/community_membership_provider.dart';
|
||||
@@ -340,6 +344,43 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows channel visibility icons and excludes Solo chats', (
|
||||
tester,
|
||||
) async {
|
||||
final channels = [
|
||||
testChannels.first,
|
||||
for (final type in ['stream', 'solo'])
|
||||
Channel(
|
||||
id: 'private-$type',
|
||||
name: 'private-$type',
|
||||
channelType: type,
|
||||
visibility: 'private',
|
||||
description: '',
|
||||
createdBy: 'aabb',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 1,
|
||||
isMember: true,
|
||||
),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final publicIcon = find.byKey(const ValueKey('channel-icon-1'));
|
||||
final privateIcon = find.byKey(
|
||||
const ValueKey('channel-icon-private-stream'),
|
||||
);
|
||||
expect(tester.widget<Icon>(publicIcon).icon, BuzzIcons.hash);
|
||||
expect(tester.widget<Icon>(privateIcon).icon, BuzzIcons.lock);
|
||||
expect(tester.getTopLeft(publicIcon).dx, tester.getTopLeft(privateIcon).dx);
|
||||
expect(find.text('private-solo'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows grouped channel list when data loads', (tester) async {
|
||||
// Valid fixture keys whose npub encodings were verified against the
|
||||
// NIP-19 codec independently of the code under test.
|
||||
@@ -454,7 +495,7 @@ void main() {
|
||||
Theme.of(tester.element(find.text(label))).colorScheme.onSurface,
|
||||
);
|
||||
}
|
||||
expect(find.byKey(const ValueKey('channel-icon-1')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('channel-icon-1')), findsOneWidget);
|
||||
final sectionTitle = tester.widget<Text>(find.text('Channels'));
|
||||
expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize);
|
||||
expect(sectionTitle.style?.fontWeight, FontWeight.w400);
|
||||
@@ -679,7 +720,11 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final lastChannel = tester.getRect(find.text('general'));
|
||||
final lastChannel = tester
|
||||
.getRect(find.text('general'))
|
||||
.expandToInclude(
|
||||
tester.getRect(find.byKey(const ValueKey('channel-icon-1'))),
|
||||
);
|
||||
final divider = tester.getRect(find.byType(Divider).last);
|
||||
final nextSectionHeader = tester.getRect(find.text('DMs'));
|
||||
|
||||
@@ -1183,9 +1228,11 @@ void main() {
|
||||
expect(find.text('Community'), findsNothing);
|
||||
final sectionLabelX = tester.getTopLeft(find.text('Channels')).dx;
|
||||
final rowLabelX = tester.getTopLeft(find.text('general')).dx;
|
||||
// The community title shares the leading row with its avatar. Channel
|
||||
// labels stay aligned below it.
|
||||
expect(sectionLabelX, rowLabelX);
|
||||
final rowIconX = tester
|
||||
.getTopLeft(find.byKey(const ValueKey('channel-icon-1')))
|
||||
.dx;
|
||||
expect(sectionLabelX, rowIconX);
|
||||
expect(rowLabelX, greaterThan(rowIconX));
|
||||
|
||||
relaySession.setReconnecting();
|
||||
await tester.pump();
|
||||
@@ -1202,7 +1249,7 @@ void main() {
|
||||
find.byKey(const Key('channels-skeleton-row-label-0')).first,
|
||||
)
|
||||
.dx;
|
||||
expect(skeletonSectionLabelX, skeletonRowLabelX);
|
||||
expect(skeletonRowLabelX, rowLabelX);
|
||||
expect(skeletonSectionLabelX, sectionLabelX);
|
||||
});
|
||||
|
||||
@@ -3737,6 +3784,7 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
brightness: Brightness.dark,
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(
|
||||
() => _FakeNotifier(
|
||||
@@ -3761,6 +3809,19 @@ void main() {
|
||||
Theme.of(tester.element(find.text('general'))).colorScheme.onSurface,
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Channels')).style?.color,
|
||||
const Color(0xFFA4A4A4),
|
||||
);
|
||||
final options = find.descendant(
|
||||
of: find.byTooltip('Channels options'),
|
||||
matching: find.byIcon(BuzzIcons.ellipsisVertical),
|
||||
);
|
||||
expect(tester.widget<Icon>(options).color, const Color(0xFFC7C7C7));
|
||||
expect(
|
||||
tester.widget<Icon>(find.byKey(const ValueKey('channel-icon-1'))).color,
|
||||
const Color(0xFFC7C7C7),
|
||||
);
|
||||
readState.markContextRead('1', 20);
|
||||
await tester.pump();
|
||||
|
||||
@@ -3768,6 +3829,10 @@ void main() {
|
||||
tester.widget<Text>(find.text('general')).style?.fontWeight,
|
||||
FontWeight.w400,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('general')).style?.color,
|
||||
const Color(0xFFC7C7C7),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('bolds channels with unread thread activity without a badge', (
|
||||
|
||||
@@ -2,6 +2,7 @@ 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:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
@@ -37,7 +38,7 @@ void main() {
|
||||
)
|
||||
.toList();
|
||||
expect(bubbles[0].color, theme.extension<ChatColors>()!.outgoing);
|
||||
expect(bubbles[1].color, theme.colorScheme.secondaryContainer);
|
||||
expect(bubbles[1].color, receivedMessageSurface(brightness));
|
||||
final text = tester
|
||||
.widgetList<RichText>(find.byType(RichText))
|
||||
.firstWhere((text) => text.text.toPlainText() == 'My message');
|
||||
@@ -59,6 +60,130 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
for (final brightness in Brightness.values) {
|
||||
for (final choice in chatColorChoices) {
|
||||
testWidgets('markdown stays readable on ${choice.name} in $brightness', (
|
||||
tester,
|
||||
) async {
|
||||
final theme = mobileAppearanceTheme(brightness, choice.wireValue);
|
||||
final ink = theme.extension<ChatColors>()!.foreground;
|
||||
expect(
|
||||
ink,
|
||||
chatForegroundForScheme(
|
||||
theme.colorScheme,
|
||||
chatColorIndex(choice.wireValue),
|
||||
),
|
||||
);
|
||||
final expectedLink = ink;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: theme,
|
||||
home: const AppMarkdownTheme(
|
||||
child: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: MessageContent(
|
||||
content:
|
||||
'# Heading\n\n[Link](https://example.com) and `code`\n\n'
|
||||
'> Quote\n\n| Header |\n| --- |\n| Cell |\n\n'
|
||||
'```dart\nfinal x = 1;\n```',
|
||||
bubble: true,
|
||||
ownMessage: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final markdown = find.byType(GptMarkdown);
|
||||
final context = tester.element(markdown);
|
||||
final data = GptMarkdownTheme.of(context);
|
||||
expect(data.h1?.color, ink);
|
||||
expect(data.linkColor, expectedLink);
|
||||
expect(data.inlineCode.color, ink);
|
||||
expect(Theme.of(context).colorScheme.primary, expectedLink);
|
||||
final sheet = tester.widget<GptMarkdown>(markdown).styleSheet!;
|
||||
expect(sheet.blockQuote?.barColor, ink);
|
||||
expect(sheet.table?.headerTextStyle?.color, ink);
|
||||
// Code ink must stay readable on the composited tint, not just the fill.
|
||||
final codes = <CodeTextSpan>[];
|
||||
for (final rich in tester.widgetList<RichText>(
|
||||
find.byWidgetPredicate((widget) => widget is RichText),
|
||||
)) {
|
||||
rich.text.visitChildren((span) {
|
||||
if (span is CodeTextSpan && span.text == 'code') codes.add(span);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
for (final label in ['Heading', 'Quote', 'Header', 'Cell']) {
|
||||
final renderedColors = <Color?>[];
|
||||
void inspect(InlineSpan span, TextStyle? inherited) {
|
||||
final effective = inherited?.merge(span.style) ?? span.style;
|
||||
if (span is TextSpan) {
|
||||
if (span.text?.trim() == label) {
|
||||
renderedColors.add(effective?.color);
|
||||
}
|
||||
for (final child in span.children ?? const <InlineSpan>[]) {
|
||||
inspect(child, effective);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final rich in tester.widgetList<RichText>(
|
||||
find.byWidgetPredicate((widget) => widget is RichText),
|
||||
)) {
|
||||
inspect(rich.text, null);
|
||||
}
|
||||
expect(renderedColors, isNotEmpty, reason: label);
|
||||
expect(renderedColors, everyElement(ink), reason: label);
|
||||
}
|
||||
expect(codes, hasLength(1));
|
||||
expect(codes.single.style?.color, ink);
|
||||
final fill = theme.extension<ChatColors>()!.outgoing;
|
||||
expect(
|
||||
_contrast(
|
||||
ink,
|
||||
Color.alphaBlend(codes.single.codeStyle.backgroundColor!, fill),
|
||||
),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: 'inline code',
|
||||
);
|
||||
final label = find.text('dart');
|
||||
final block = tester.widget<Container>(
|
||||
find
|
||||
.ancestor(
|
||||
of: label,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Container &&
|
||||
widget.decoration is BoxDecoration &&
|
||||
(widget.decoration! as BoxDecoration).borderRadius !=
|
||||
null,
|
||||
),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final blockSurface = Color.alphaBlend(
|
||||
(block.decoration! as BoxDecoration).color!,
|
||||
fill,
|
||||
);
|
||||
for (final (name, color) in [
|
||||
('code block', ink),
|
||||
('language label', tester.widget<Text>(label).style!.color!),
|
||||
]) {
|
||||
expect(
|
||||
_contrast(Color.alphaBlend(color, blockSurface), blockSurface),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: name,
|
||||
);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('System follows platform brightness with the same chat choice', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -88,3 +213,9 @@ void main() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
double _contrast(Color a, Color b) {
|
||||
final la = a.computeLuminance();
|
||||
final lb = b.computeLuminance();
|
||||
return la > lb ? (la + .05) / (lb + .05) : (lb + .05) / (la + .05);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ void bubbleContentTests() {
|
||||
surface.color,
|
||||
own
|
||||
? theme.extension<ChatColors>()!.outgoing
|
||||
: theme.colorScheme.secondaryContainer,
|
||||
: receivedMessageSurface(brightness),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -105,11 +105,9 @@ void bubbleContentTests() {
|
||||
inspect(text.text, null);
|
||||
expect(colors, [foreground]);
|
||||
}
|
||||
final linkColor = foreground == Colors.white
|
||||
? foreground
|
||||
: dark
|
||||
? const Color(0xFF83C4FF)
|
||||
: const Color(0xFF0B5FA8);
|
||||
final linkColor = ChatColors.of(
|
||||
tester.element(find.byType(MessageContent)),
|
||||
).foreground;
|
||||
for (final label in ['Alice', 'Helper']) {
|
||||
expect(tester.widget<Text>(find.text(label)).style?.color, linkColor);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Chat color'), findsOneWidget);
|
||||
final green = chatColorChoices.indexWhere((color) => color.name == 'Green');
|
||||
final green = chatColorChoices.indexWhere((color) => color.name == 'Jade');
|
||||
await tester.ensureVisible(find.byKey(ValueKey('accent-option-$green')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(ValueKey('accent-option-$green')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(notifier.preferenceWrites, 0);
|
||||
@@ -126,7 +128,7 @@ void main() {
|
||||
.color;
|
||||
final surface = surfaceColor();
|
||||
final fills = <Color>[];
|
||||
for (final name in ['Blue', 'Green', 'Neutral']) {
|
||||
for (final name in ['Cobalt', 'Jade', 'Chartreuse', 'Gray']) {
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('theme-preview-accent-action-button')),
|
||||
);
|
||||
@@ -135,6 +137,8 @@ void main() {
|
||||
(color) => color.name == name,
|
||||
);
|
||||
final option = find.byKey(ValueKey('accent-option-$index'));
|
||||
await tester.ensureVisible(option);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(option);
|
||||
await tester.pumpAndSettle();
|
||||
Navigator.of(tester.element(option)).pop();
|
||||
@@ -150,7 +154,7 @@ void main() {
|
||||
expect(notifier.preferenceWrites, 0);
|
||||
fills.add(bubbleColor());
|
||||
}
|
||||
expect(fills.toSet(), hasLength(3));
|
||||
expect(fills.toSet(), hasLength(4));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,61 +1,122 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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', () {
|
||||
test('matches all seven desktop accent fills and readable inks', () {
|
||||
expect(chatColorChoices.map((color) => color.name), [
|
||||
'Neutral',
|
||||
'Blue',
|
||||
'Green',
|
||||
'Orange',
|
||||
'Pink',
|
||||
'Lilac',
|
||||
'Gray',
|
||||
'Chartreuse',
|
||||
'Flame',
|
||||
'Magenta',
|
||||
'Iris',
|
||||
'Cobalt',
|
||||
'Jade',
|
||||
]);
|
||||
expect(chatColorChoices.map((color) => color.light.toARGB32()), [
|
||||
0xFFE9E9EA,
|
||||
0xFF0077DB,
|
||||
0xFF008934,
|
||||
0xFFC95100,
|
||||
0xFFD33785,
|
||||
0xFFA94AD8,
|
||||
0xFF525252,
|
||||
0xFFD7D72E,
|
||||
0xFFD93A0C,
|
||||
0xFFCC1F7A,
|
||||
0xFF6C4EF0,
|
||||
0xFF2B4FE0,
|
||||
0xFF0B8467,
|
||||
]);
|
||||
expect(chatColorChoices.map((color) => color.dark.toARGB32()), [
|
||||
0xFF2E2E2E,
|
||||
0xFF0077DB,
|
||||
0xFF008934,
|
||||
0xFFC95100,
|
||||
0xFFD33785,
|
||||
0xFFA94AD8,
|
||||
0xFFC7C7C7,
|
||||
0xFFD7D72E,
|
||||
0xFFD93A0C,
|
||||
0xFFCC1F7A,
|
||||
0xFF6C4EF0,
|
||||
0xFF2B4FE0,
|
||||
0xFF0B8467,
|
||||
]);
|
||||
for (final brightness in Brightness.values) {
|
||||
final scheme = brightness == Brightness.dark
|
||||
? darkColorScheme
|
||||
: lightColorScheme;
|
||||
for (var index = 0; index < chatColorChoices.length; index++) {
|
||||
final ink = chatForegroundForScheme(scheme, index);
|
||||
final a = ink.computeLuminance();
|
||||
final b = chatColorForScheme(scheme, index).computeLuminance();
|
||||
final contrast = a > b ? (a + .05) / (b + .05) : (b + .05) / (a + .05);
|
||||
expect(
|
||||
contrast,
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: '${chatColorChoices[index].name} $brightness',
|
||||
);
|
||||
// Only the light fills fall back to black: Chartreuse and dark Gray.
|
||||
final black = index == 1 || (index == 0 && scheme == darkColorScheme);
|
||||
expect(ink, black ? Colors.black : Colors.white);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
test('accents follow the cross-client v1 contract', () {
|
||||
// Desktop's parser test reads the same fixture, so both clients agree on
|
||||
// every value either can save.
|
||||
final contract =
|
||||
jsonDecode(
|
||||
File(
|
||||
'../test-fixtures/community-theme-accents.json',
|
||||
).readAsStringSync(),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
CommunityThemePreference parse(String accent) =>
|
||||
CommunityThemePreference.fromJson({
|
||||
'version': 1,
|
||||
'theme': 'buzz-dark',
|
||||
'accent': entry.key,
|
||||
'accent': accent,
|
||||
'followSystem': false,
|
||||
});
|
||||
expect(preference.mode, ThemeMode.dark);
|
||||
expect(preference.accent, entry.key);
|
||||
expect(
|
||||
mobileAppearanceTheme(
|
||||
Brightness.dark,
|
||||
preference.accent,
|
||||
).extension<ChatColors>()!.outgoing,
|
||||
chatColorChoices[chatColorIndex(entry.key)].dark,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final emitted = (contract['emitted'] as List).cast<Map<String, dynamic>>();
|
||||
expect(
|
||||
emitted.map((entry) => entry['family']),
|
||||
chatColorChoices.map((color) => color.name),
|
||||
);
|
||||
for (final (index, entry) in emitted.indexed) {
|
||||
final accent = entry['accent'] as String;
|
||||
expect(chatColorChoices[index].wireValue, accent);
|
||||
// Family names from earlier preview builds republish as the v1 value.
|
||||
expect(parse(chatColorChoices[index].name.toLowerCase()).accent, accent);
|
||||
}
|
||||
// Older clients reject an unknown accent with the whole preference, so
|
||||
// anything published without a picker action stays in their set.
|
||||
final baseReaders = ((contract['baseReaders'] as Map)['accepted'] as List)
|
||||
.cast<String>();
|
||||
for (final automatic in [
|
||||
defaultCommunityTheme.accent,
|
||||
for (final index in [null, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8])
|
||||
legacyAccentWireValue(index),
|
||||
]) {
|
||||
expect(baseReaders, contains(automatic));
|
||||
}
|
||||
|
||||
final legacy = (contract['legacy'] as List).cast<Map<String, dynamic>>();
|
||||
for (final entry in [...emitted, ...legacy]) {
|
||||
final accent = entry['accent'] as String;
|
||||
final preference = parse(accent);
|
||||
expect(preference.mode, ThemeMode.dark);
|
||||
expect(preference.accent, accent);
|
||||
final family = chatColorChoices.firstWhere(
|
||||
(color) => color.name == entry['family'],
|
||||
);
|
||||
expect(
|
||||
mobileAppearanceTheme(
|
||||
Brightness.dark,
|
||||
preference.accent,
|
||||
).extension<ChatColors>()!.outgoing,
|
||||
family.dark,
|
||||
reason: accent,
|
||||
);
|
||||
}
|
||||
|
||||
for (final accent in (contract['rejected'] as List).cast<String>()) {
|
||||
expect(() => parse(accent), throwsFormatException, reason: accent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ void main() {
|
||||
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));
|
||||
expect(dark.colorScheme.surface, const Color(0xFF000000));
|
||||
expect(dark.colorScheme.onSurface, const Color(0xFFFFFFFF));
|
||||
expect(dark.colorScheme.onSurfaceVariant, const Color(0xFFC7C7C7));
|
||||
expect(dark.colorScheme.outline, const Color(0xFF1A1A1A));
|
||||
});
|
||||
|
||||
for (final brightness in Brightness.values) {
|
||||
|
||||
83
test-fixtures/community-theme-accents.json
Normal file
83
test-fixtures/community-theme-accents.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"emitted": [
|
||||
{
|
||||
"family": "Gray",
|
||||
"accent": "neutral",
|
||||
"desktop": "Neutral"
|
||||
},
|
||||
{
|
||||
"family": "Chartreuse",
|
||||
"accent": "#d7d72e",
|
||||
"desktop": "Chartreuse"
|
||||
},
|
||||
{
|
||||
"family": "Flame",
|
||||
"accent": "#f97316",
|
||||
"desktop": "Orange"
|
||||
},
|
||||
{
|
||||
"family": "Magenta",
|
||||
"accent": "#ec4899",
|
||||
"desktop": "Pink"
|
||||
},
|
||||
{
|
||||
"family": "Iris",
|
||||
"accent": "#c0a2f1",
|
||||
"desktop": "Lilac"
|
||||
},
|
||||
{
|
||||
"family": "Cobalt",
|
||||
"accent": "#3b82f6",
|
||||
"desktop": "Blue"
|
||||
},
|
||||
{
|
||||
"family": "Jade",
|
||||
"accent": "#22c55e",
|
||||
"desktop": "Green"
|
||||
}
|
||||
],
|
||||
"legacy": [
|
||||
{
|
||||
"accent": "#06b6d4",
|
||||
"desktop": "Cyan",
|
||||
"family": "Jade"
|
||||
},
|
||||
{
|
||||
"accent": "#ef4444",
|
||||
"desktop": "Red",
|
||||
"family": "Magenta"
|
||||
},
|
||||
{
|
||||
"accent": "#a855f7",
|
||||
"desktop": "Purple",
|
||||
"family": "Iris"
|
||||
},
|
||||
{
|
||||
"accent": "#6366f1",
|
||||
"desktop": "Indigo",
|
||||
"family": "Cobalt"
|
||||
}
|
||||
],
|
||||
"rejected": [
|
||||
"url(image)",
|
||||
"#123456",
|
||||
"Chartreuse",
|
||||
"cyan",
|
||||
""
|
||||
],
|
||||
"baseReaders": {
|
||||
"note": "v1 accents accepted by Mobile and Desktop before Chartreuse; anything a client publishes without a picker action must stay in this set",
|
||||
"accepted": [
|
||||
"neutral",
|
||||
"#3b82f6",
|
||||
"#06b6d4",
|
||||
"#22c55e",
|
||||
"#f97316",
|
||||
"#ef4444",
|
||||
"#ec4899",
|
||||
"#c0a2f1",
|
||||
"#a855f7",
|
||||
"#6366f1"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user