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/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/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..1670d44dda6 100644 --- a/mobile/lib/shared/theme/theme_extensions.dart +++ b/mobile/lib/shared/theme/theme_extensions.dart @@ -19,6 +19,15 @@ extension AppThemeExtension on BuildContext { : const Color(0xFF8D8D8D); TextTheme get textTheme => theme.textTheme; + /// 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.onSurfaceVariant, + }; + AppColors get appColors { final ext = theme.extension(); assert(ext != null, 'AppColors not found in ThemeData.extensions'); 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 d4a11ffb929..83569063651 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.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')); + }, + ); + } +}