Files
buzz/mobile/lib/shared/identity_transfer/native_community_entry.dart
John Matthew Tennant 6684f7e2f0 fix(mobile): make the Join button readable in dark mode (#8969)
## Summary

After scanning or pasting a community invite in dark mode, the native
Join button can display white text on a white background. This fix
passes the theme’s foreground color to native confirmation and applies
it alongside the button’s background color.

### Related issue

Related invite handoff: https://github.com/block/buzz/pull/8601.

### Testing

A widget regression checks the foreground and background colors sent to
native confirmation. The isolated PR build shows readable black Join
text on the white native button in dark mode.

### Screenshots

Before (consent-only build `3d566de`):

![Join label invisible in dark
mode](https://raw.githubusercontent.com/block/buzz/e12a750a31badc9e2a95a18c277b33968c7ec1f5/pr-8969--DARK_JOIN_BEFORE.png)

After (this PR):

![Readable Join label in dark
mode](https://raw.githubusercontent.com/block/buzz/e12a750a31badc9e2a95a18c277b33968c7ec1f5/pr-8969--DARK_JOIN_AFTER.png)

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
2026-11-02 18:46:36 -08:00

132 lines
4.4 KiB
Dart

import 'package:flutter/services.dart';
import '../hosted/account_identity_store.dart';
import '../hosted/device_identity_source.dart';
import '../utils/string_utils.dart';
import 'nsec_transfer.dart';
/// UIKit presentation with identity operations kept in the Dart service layer.
abstract final class NativeCommunityEntry {
static const _channel = MethodChannel('buzz/community_entry');
static bool _presenting = false;
static int _session = 0;
/// Returns null when native presentation is unavailable.
static Future<bool?> show({
required String name,
required bool dark,
required int primaryArgb,
int? onPrimaryArgb,
required List<DeviceIdentityChoice> choices,
Future<Map<String, dynamic>> Function()? confirmInvite,
required Future<void> Function(
String? pubkey,
String? nsec,
bool create,
bool Function() isCurrent,
)
connect,
}) async {
if (_presenting) return false;
_presenting = true;
final session = ++_session;
var active = true;
var generation = 0;
var busy = false;
String? previewed;
_channel.setMethodCallHandler((call) async {
final args = Map<String, dynamic>.from(call.arguments as Map);
if (!active || args['session'] != session) return {'cancelled': true};
if (call.method == 'cancel') {
generation++;
busy = false;
previewed = null;
return null;
}
if (busy) return {'cancelled': true};
final attempt = ++generation;
bool current() => active && generation == attempt;
busy = true;
try {
switch (call.method) {
case 'preview':
case 'file':
final text = call.method == 'file'
? await NsecTransfer.pickFile()
: args['nsec'] as String?;
if (!current() || text == null) return {'cancelled': true};
previewed = NsecTransfer.preview(text);
return {
'npub': fullNpub(previewed!),
if (call.method == 'file') 'nsec': text,
};
case 'join':
if (confirmInvite != null) return await confirmInvite();
final nsec = args['nsec'] as String?;
final pubkey = args['pubkey'] as String?;
final create = args['create'] == true;
if (nsec != null) {
if (previewed == null ||
NsecTransfer.preview(nsec) != previewed) {
throw AccountIdentityFailure.invalidKey;
}
} else if (create
? choices.isNotEmpty
: !choices.any((choice) => choice.pubkey == pubkey)) {
return {'error': 'Choose an identity to continue.'};
}
await connect(pubkey, nsec, create, current);
return current() ? {'joined': true} : {'cancelled': true};
default:
throw MissingPluginException();
}
} catch (error) {
if (!current()) return {'cancelled': true};
return {
'error': switch (error) {
AccountIdentityFailure.invalidKey =>
'Enter a valid nsec private key.',
AccountIdentityFailure.occupied ||
AccountIdentityFailure.mismatch =>
'This device already has a different identity. It has not been replaced.',
_ => 'Could not finish. Try again. Your saved keys have been kept.',
},
};
} finally {
if (current()) busy = false;
}
});
try {
return await _channel.invokeMethod<bool>('present', {
'session': session,
'name': name,
'dark': dark,
'primaryArgb': primaryArgb,
'onPrimaryArgb': ?onPrimaryArgb,
'invite': confirmInvite != null,
'choices': [
for (final choice in choices)
{
'pubkey': choice.pubkey,
'label': choice.label,
'npub': fullNpub(choice.pubkey) ?? choice.pubkey,
},
],
}) ??
false;
} on MissingPluginException {
return null;
} on PlatformException catch (error) {
// Scene transitions can temporarily leave UIKit without a presenter.
if (error.code == 'presentation_failed') return null;
rethrow;
} finally {
active = false;
generation++;
previewed = null;
_channel.setMethodCallHandler(null);
_presenting = false;
}
}
}