From e0f442533b43e979ca311d66a19d1e249766bc4e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 10 Nov 2026 11:17:28 -0800 Subject: [PATCH 1/2] Fill iOS availability menu dots with design-system colors Co-authored-by: sol Signed-off-by: Tom Brow --- mobile/ios/Runner/NativePresenceMenu.swift | 17 ++++++++++++++--- .../channels/channels_page/channel_tile.dart | 10 +--------- .../features/profile/ios_presence_menu.dart | 6 ++++++ .../profile/settings_profile_header.dart | 9 +-------- mobile/lib/shared/theme/theme_extensions.dart | 7 +++++++ .../settings/settings_overlay_test.dart | 19 +++++++++++++++++++ 6 files changed, 48 insertions(+), 20 deletions(-) diff --git a/mobile/ios/Runner/NativePresenceMenu.swift b/mobile/ios/Runner/NativePresenceMenu.swift index b1165411324..c512ff694c7 100644 --- a/mobile/ios/Runner/NativePresenceMenu.swift +++ b/mobile/ios/Runner/NativePresenceMenu.swift @@ -62,15 +62,26 @@ private final class NativePresenceMenu: NSObject, FlutterPlatformView { button.configuration = configuration button.accessibilityLabel = "Availability" button.accessibilityValue = label - let options: [(String, String, UIColor)] = [("online", "Online", .systemGreen), ("away", "Away", .systemOrange), ("offline", "Offline", .systemGray)] - button.menu = UIMenu(children: options.map { value, title, color in + // Dot colors come from the Flutter design system (context.presenceColor). + let dotColors = data["dotColors"] as? [String: Any] ?? [:] + let options = [("online", "Online"), ("away", "Away"), ("offline", "Offline")] + button.menu = UIMenu(children: options.map { value, title in UIAction(title: title, - image: NativeTabler.image("circle.fill")?.withTintColor(color, renderingMode: .alwaysOriginal), + image: Self.dot(Self.color(dotColors[value])), state: presence == value ? .on : .off ) { [weak self] _ in self?.channel.invokeMethod("selected", arguments: value) } }) } + /// A solid availability dot, matching Flutter's 10pt filled circle. + private static func dot(_ color: UIColor, size: CGFloat = 20, diameter: CGFloat = 10) -> UIImage { + UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { _ in + color.setFill() + let inset = (size - diameter) / 2 + UIBezierPath(ovalIn: CGRect(x: inset, y: inset, width: diameter, height: diameter)).fill() + }.withRenderingMode(.alwaysOriginal) + } + private static func color(_ value: Any?) -> UIColor { let argb = (value as? NSNumber)?.uint32Value ?? 0 return UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255, diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 54117a35464..18b06370277 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -275,7 +275,7 @@ class _DmAvatar extends ConsumerWidget { width: 8, height: 8, decoration: BoxDecoration( - color: _presenceColor(context, presence), + color: context.presenceColor(presence), shape: BoxShape.circle, border: Border.all( color: context.theme.scaffoldBackgroundColor, @@ -288,12 +288,4 @@ class _DmAvatar extends ConsumerWidget { ), ); } - - Color _presenceColor(BuildContext context, String? presence) { - return switch (presence) { - 'online' => context.appColors.success, - 'away' => context.appColors.warning, - _ => context.colors.outline, - }; - } } diff --git a/mobile/lib/features/profile/ios_presence_menu.dart b/mobile/lib/features/profile/ios_presence_menu.dart index 2212bb70e4e..6bae73304a1 100644 --- a/mobile/lib/features/profile/ios_presence_menu.dart +++ b/mobile/lib/features/profile/ios_presence_menu.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../shared/theme/theme.dart'; + /// Native iOS availability pill with a system menu owned by UIKit. class IosPresenceMenu extends StatefulWidget { const IosPresenceMenu({ @@ -63,6 +65,10 @@ class _IosPresenceMenuState extends State { 'label': widget.label, 'foreground': widget.foreground.toARGB32(), 'background': widget.background.toARGB32(), + 'dotColors': { + for (final value in const ['online', 'away', 'offline']) + value: context.presenceColor(value).toARGB32(), + }, 'dark': Theme.of(context).brightness == Brightness.dark, 'fontSize': MediaQuery.textScalerOf(context).scale(15), }; diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index 41f30223034..875dbc3361e 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -296,7 +296,7 @@ class _PresencePill extends StatelessWidget { width: 10, height: 10, decoration: BoxDecoration( - color: _presenceColor(context, option), + color: context.presenceColor(option), shape: BoxShape.circle, ), ), @@ -361,10 +361,3 @@ String _presenceLabel(String presence) => switch (presence) { 'away' => 'Away', _ => 'Offline', }; - -Color _presenceColor(BuildContext context, String presence) => - switch (presence) { - 'online' => context.appColors.success, - 'away' => context.appColors.warning, - _ => context.colors.outline, - }; diff --git a/mobile/lib/shared/theme/theme_extensions.dart b/mobile/lib/shared/theme/theme_extensions.dart index 2286b8c1827..e720cf4a10f 100644 --- a/mobile/lib/shared/theme/theme_extensions.dart +++ b/mobile/lib/shared/theme/theme_extensions.dart @@ -19,6 +19,13 @@ extension AppThemeExtension on BuildContext { : const Color(0xFF8D8D8D); TextTheme get textTheme => theme.textTheme; + /// Design-system availability color: success, warning, or outline. + Color presenceColor(String? presence) => switch (presence) { + 'online' => appColors.success, + 'away' => appColors.warning, + _ => colors.outline, + }; + AppColors get appColors { final ext = theme.extension(); assert(ext != null, 'AppColors not found in ThemeData.extensions'); diff --git a/mobile/test/features/settings/settings_overlay_test.dart b/mobile/test/features/settings/settings_overlay_test.dart index d4a11ffb929..80c9030d49f 100644 --- a/mobile/test/features/settings/settings_overlay_test.dart +++ b/mobile/test/features/settings/settings_overlay_test.dart @@ -160,6 +160,25 @@ void main() { ); expect(menu.viewType, 'buzz/presence_menu'); expect(menu.creationParams, containsPair('presence', 'offline')); + final menuContext = tester.element( + find.byKey(const ValueKey('settings-presence-menu')), + ); + expect((menu.creationParams! as Map)['dotColors'], { + for (final value in const ['online', 'away', 'offline']) + value: menuContext.presenceColor(value).toARGB32(), + }); + expect( + menuContext.presenceColor('online'), + menuContext.appColors.success, + ); + expect( + menuContext.presenceColor('away'), + menuContext.appColors.warning, + ); + expect( + menuContext.presenceColor('offline'), + Theme.of(menuContext).colorScheme.outline, + ); final nativeMenu = nativeChannels.singleWhere( (channel) => channel.name.startsWith('buzz/presence_menu/'), ); From 4ca8ae0a48d448c36c958dcccdcd37e63aee52a4 Mon Sep 17 00:00:00 2001 From: deepseek <7ac6f32e110347b0d36a57d9b69ccdc4d933b591d26f076b1e8a92b56ec5b2a5@buzz.local> Date: Tue, 10 Nov 2026 22:47:21 +0000 Subject: [PATCH 2/2] fix(mobile): make the offline availability dot visible `context.presenceColor()` mapped offline to `colors.outline` (#E8E8E8 in the light scheme), a hairline-border token that reads at 1.17:1 against the light menu surface, so the Offline dot was effectively invisible. Point it at `onSurfaceVariant`, the text-tertiary foreground the profile chip already uses for offline, and adopt the helper in the DM header dot and the native title presence color so there is one source of truth. Adds a contrast test for all three dots in both brightnesses, and asserts the resolved offline token in the settings overlay test so an invisible dot cannot pass again. Co-authored-by: deepseek <7ac6f32e110347b0d36a57d9b69ccdc4d933b591d26f076b1e8a92b56ec5b2a5> Signed-off-by: deepseek <7ac6f32e110347b0d36a57d9b69ccdc4d933b591d26f076b1e8a92b56ec5b2a5@buzz.local> --- .../channels/channel_detail_page.dart | 6 +- .../channels/channel_detail_page/app_bar.dart | 7 ++- mobile/lib/shared/theme/theme_extensions.dart | 6 +- .../channels_page_test/presence_tests.dart | 2 +- .../settings/settings_overlay_test.dart | 2 +- .../shared/theme/presence_color_test.dart | 56 +++++++++++++++++++ 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 mobile/test/shared/theme/presence_color_test.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 02b2383269f..055dbc9d28a 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -821,9 +821,9 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { nativeTitlePresenceColor: switch (isOneToOneDm ? dmHeader?.presence : null) { - 'online' => context.appColors.success, - 'away' => context.appColors.warning, - 'offline' => context.colors.outline, + 'online' || 'away' || 'offline' => context.presenceColor( + dmHeader?.presence, + ), _ => null, }, // Loaded content stays readable while it reconnects and catches up; diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 685d09b1852..baf735f2d88 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -93,9 +93,10 @@ class _ConversationAppBarTitle extends StatelessWidget { decoration: BoxDecoration( shape: BoxShape.circle, color: switch (presence) { - 'online' => context.appColors.success, - 'away' => context.appColors.warning, - _ => context.colors.outline, + 'online' || + 'away' || + 'offline' => context.presenceColor(presence), + _ => context.presenceColor(null), }, ), ), diff --git a/mobile/lib/shared/theme/theme_extensions.dart b/mobile/lib/shared/theme/theme_extensions.dart index e720cf4a10f..1670d44dda6 100644 --- a/mobile/lib/shared/theme/theme_extensions.dart +++ b/mobile/lib/shared/theme/theme_extensions.dart @@ -19,11 +19,13 @@ extension AppThemeExtension on BuildContext { : const Color(0xFF8D8D8D); TextTheme get textTheme => theme.textTheme; - /// Design-system availability color: success, warning, or outline. + /// Design-system availability color: success, warning, or the muted + /// text-tertiary foreground. `outline` is a hairline-border token and reads + /// as invisible on light surfaces, so it must not be used for a dot. Color presenceColor(String? presence) => switch (presence) { 'online' => appColors.success, 'away' => appColors.warning, - _ => colors.outline, + _ => colors.onSurfaceVariant, }; AppColors get appColors { diff --git a/mobile/test/features/channels/channels_page_test/presence_tests.dart b/mobile/test/features/channels/channels_page_test/presence_tests.dart index 308b0358d06..4ce732d37b8 100644 --- a/mobile/test/features/channels/channels_page_test/presence_tests.dart +++ b/mobile/test/features/channels/channels_page_test/presence_tests.dart @@ -35,7 +35,7 @@ void presenceListTests( expect((dot.decoration as BoxDecoration).color, switch (status) { 'online' => theme.extension()!.success, 'away' => theme.extension()!.warning, - _ => theme.colorScheme.outline, + _ => theme.colorScheme.onSurfaceVariant, }); expect(tester.getRect(avatar), bounds); } diff --git a/mobile/test/features/settings/settings_overlay_test.dart b/mobile/test/features/settings/settings_overlay_test.dart index 80c9030d49f..83569063651 100644 --- a/mobile/test/features/settings/settings_overlay_test.dart +++ b/mobile/test/features/settings/settings_overlay_test.dart @@ -177,7 +177,7 @@ void main() { ); expect( menuContext.presenceColor('offline'), - Theme.of(menuContext).colorScheme.outline, + Theme.of(menuContext).colorScheme.onSurfaceVariant, ); final nativeMenu = nativeChannels.singleWhere( (channel) => channel.name.startsWith('buzz/presence_menu/'), diff --git a/mobile/test/shared/theme/presence_color_test.dart b/mobile/test/shared/theme/presence_color_test.dart new file mode 100644 index 00000000000..a563684c158 --- /dev/null +++ b/mobile/test/shared/theme/presence_color_test.dart @@ -0,0 +1,56 @@ +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +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); +} + +void main() { + /// Builds a context carrying the app theme, the same way the app does. + Future pumpTheme(WidgetTester tester, bool dark) async { + late BuildContext captured; + await tester.pumpWidget( + MaterialApp( + theme: dark ? AppTheme.dark() : AppTheme.light(), + home: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + return captured; + } + + for (final dark in [false, true]) { + testWidgets( + 'availability colors stay visible on the ${dark ? 'dark' : 'light'} ' + 'surface', + (tester) async { + final context = await pumpTheme(tester, dark); + final surface = context.colors.surface; + for (final presence in const ['online', 'away', 'offline', null]) { + expect( + _contrast(context.presenceColor(presence), surface), + greaterThanOrEqualTo(3), + reason: 'presence ${presence ?? 'unknown'} is too faint for a dot', + ); + } + // The online and away dots stay the semantic status colors. + expect(context.presenceColor('online'), context.appColors.success); + expect(context.presenceColor('away'), context.appColors.warning); + // Offline is a foreground token, never the hairline-border `outline`. + expect( + context.presenceColor('offline'), + context.colors.onSurfaceVariant, + ); + expect(context.presenceColor('offline'), isNot(context.colors.outline)); + expect(context.presenceColor(null), context.presenceColor('offline')); + }, + ); + } +}