Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions mobile/ios/Runner/NativePresenceMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
),
),
Expand Down
10 changes: 1 addition & 9 deletions mobile/lib/features/channels/channels_page/channel_tile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
}
}
6 changes: 6 additions & 0 deletions mobile/lib/features/profile/ios_presence_menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -63,6 +65,10 @@ class _IosPresenceMenuState extends State<IosPresenceMenu> {
'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),
};
Expand Down
9 changes: 1 addition & 8 deletions mobile/lib/features/profile/settings_profile_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
Expand Down Expand Up @@ -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,
};
9 changes: 9 additions & 0 deletions mobile/lib/shared/theme/theme_extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppColors>();
assert(ext != null, 'AppColors not found in ThemeData.extensions');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ void presenceListTests(
expect((dot.decoration as BoxDecoration).color, switch (status) {
'online' => theme.extension<AppColors>()!.success,
'away' => theme.extension<AppColors>()!.warning,
_ => theme.colorScheme.outline,
_ => theme.colorScheme.onSurfaceVariant,
});
expect(tester.getRect(avatar), bounds);
}
Expand Down
19 changes: 19 additions & 0 deletions mobile/test/features/settings/settings_overlay_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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/'),
);
Expand Down
56 changes: 56 additions & 0 deletions mobile/test/shared/theme/presence_color_test.dart
Original file line number Diff line number Diff line change
@@ -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<BuildContext> 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'));
},
);
}
}
Loading