mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Polish mobile onboarding and profile setup (#8438)
Originally stacked on #8375, now merged. Rebased onto main. Polishes the onboarding landing page, community-link entry, and recovery sheets. Profile setup uses a consistent sheet with a larger avatar, inline display name, and draft photo/emoji/animated editing; iOS uses native controls and coordinated avatar/sheet transitions. Validation: mobile analysis, full tests, and normal pre-push checks passed after rebase. Prior iOS build and device walkthrough passed; native validation was not repeated after this rebase. Snapshots omitted as requested. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../shared/identity_transfer/native_community_entry.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||
|
||||
@@ -8,7 +10,36 @@ import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../pairing/pairing_page.dart';
|
||||
import 'invite_join_provider.dart';
|
||||
|
||||
Future<bool?> showInviteJoinSheet(BuildContext context) {
|
||||
Future<bool?> showInviteJoinSheet(BuildContext context) async {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
final state = container.read(inviteJoinProvider);
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
state.status == InviteJoinStatus.confirming &&
|
||||
!state.isStarterSetupRecovery) {
|
||||
final joined = await NativeCommunityEntry.show(
|
||||
name: state.host ?? 'Unknown community',
|
||||
dark: Theme.of(context).brightness == Brightness.dark,
|
||||
primaryArgb: Theme.of(context).colorScheme.primary.toARGB32(),
|
||||
choices: const [],
|
||||
connect: (_, _, _, _) async {},
|
||||
confirmInvite: () async {
|
||||
final before = container.read(inviteJoinProvider);
|
||||
if (before.requiresFreshInvite) {
|
||||
return {
|
||||
'error': before.errorMessage ?? 'Open a fresh invite to continue.',
|
||||
};
|
||||
}
|
||||
await container.read(inviteJoinProvider.notifier).confirmJoin();
|
||||
final result = container.read(inviteJoinProvider);
|
||||
return result.status == InviteJoinStatus.success
|
||||
? {'joined': true}
|
||||
: {'error': result.errorMessage ?? 'Could not join. Try again.'};
|
||||
},
|
||||
);
|
||||
if (joined == false) return false;
|
||||
if (!context.mounted) return joined;
|
||||
// Preserve the existing success screen and starter-channel destination.
|
||||
}
|
||||
return showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
@@ -6,9 +7,11 @@ import '../../shared/auth/auth.dart';
|
||||
import '../../shared/hosted/device_identity_source.dart';
|
||||
import '../../shared/hosted/self_hosted_entry.dart';
|
||||
import '../../shared/identity_transfer/nsec_import_sheet.dart';
|
||||
import '../../shared/identity_transfer/native_community_entry.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/buzz_titled_sheet_layout.dart';
|
||||
|
||||
/// Local preparation seam; the production service validates and retains keys.
|
||||
final selfHostedEntryProvider = Provider<SelfHostedEntry>(
|
||||
@@ -20,16 +23,65 @@ Future<bool> showSelfHostedEntry(
|
||||
BuildContext context, {
|
||||
required String relay,
|
||||
required String name,
|
||||
}) async =>
|
||||
await showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
title: 'Join community',
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => SelfHostedEntrySheet(relay: relay, name: name),
|
||||
) ??
|
||||
false;
|
||||
}) async {
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
final service = container.read(selfHostedEntryProvider);
|
||||
final auth = container.read(authProvider.notifier);
|
||||
final storage = container.read(communityStorageProvider);
|
||||
final dark = context.theme.brightness == Brightness.dark;
|
||||
final primary = context.colors.primary.toARGB32();
|
||||
// Keep the existing Flutter error state if saved identities are unreadable.
|
||||
List<DeviceIdentityChoice>? choices;
|
||||
try {
|
||||
final inventory = await service.choices();
|
||||
if (inventory.complete) choices = inventory.choices;
|
||||
} catch (_) {}
|
||||
if (!context.mounted) return false;
|
||||
if (choices != null) {
|
||||
final result = await NativeCommunityEntry.show(
|
||||
name: name.isEmpty ? Uri.parse(relay).host : name,
|
||||
dark: dark,
|
||||
primaryArgb: primary,
|
||||
choices: choices,
|
||||
connect: (pubkey, nsec, create, current) async {
|
||||
final community = await service.prepare(
|
||||
relay: relay,
|
||||
name: name,
|
||||
savedPubkey: pubkey,
|
||||
nsec: nsec,
|
||||
create: create,
|
||||
isCurrent: current,
|
||||
);
|
||||
if (!current()) return;
|
||||
await auth.authenticateWithCommunity(
|
||||
community,
|
||||
shouldContinue: current,
|
||||
);
|
||||
final saved = await storage.loadAll();
|
||||
if (saved.any(
|
||||
(item) => item.id == community.id && item.nsec == community.nsec,
|
||||
)) {
|
||||
await service.complete(community);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (result != null) return result;
|
||||
}
|
||||
if (!context.mounted) return false;
|
||||
}
|
||||
return await showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
showCloseButton: false,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
useSafeArea: true,
|
||||
// This sheet owns equal content gutters on all three lower edges.
|
||||
padBottom: false,
|
||||
builder: (_) => SelfHostedEntrySheet(relay: relay, name: name),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
const SelfHostedEntrySheet({
|
||||
@@ -45,6 +97,9 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
final service = ref.watch(selfHostedEntryProvider);
|
||||
final choices = useFuture(useMemoized(service.choices));
|
||||
final busy = useState(false);
|
||||
final importing = useState(false);
|
||||
final importFocusReady = useState(false);
|
||||
final generation = useRef(0);
|
||||
final error = useState<String?>(null);
|
||||
bool mounted() => context.mounted;
|
||||
Future<void> connect({
|
||||
@@ -54,6 +109,8 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
bool close = true,
|
||||
}) async {
|
||||
if (busy.value) return;
|
||||
final attempt = ++generation.value;
|
||||
bool current() => mounted() && generation.value == attempt;
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
final storage = ref.read(communityStorageProvider);
|
||||
@@ -64,12 +121,12 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
savedPubkey: pubkey,
|
||||
nsec: nsec,
|
||||
create: create,
|
||||
isCurrent: mounted,
|
||||
isCurrent: current,
|
||||
);
|
||||
if (!mounted()) return;
|
||||
if (!current()) return;
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.authenticateWithCommunity(community, shouldContinue: mounted);
|
||||
.authenticateWithCommunity(community, shouldContinue: current);
|
||||
// Admission can return early after cancellation. Check the durable
|
||||
// record before retiring the only retry copy of a generated key.
|
||||
final saved = await storage.loadAll();
|
||||
@@ -78,15 +135,17 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
)) {
|
||||
await service.complete(community);
|
||||
}
|
||||
if (close && context.mounted) Navigator.of(context).pop(true);
|
||||
if (close && current() && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted()) {
|
||||
if (current()) {
|
||||
error.value =
|
||||
'Could not join. Check this community accepts your identity, then try again. Your saved keys have been kept.';
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
if (mounted()) busy.value = false;
|
||||
if (current()) busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,18 +155,57 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Widget button({
|
||||
required VoidCallback? onPressed,
|
||||
required Widget child,
|
||||
bool primary = false,
|
||||
}) {
|
||||
return primary
|
||||
? FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colors.primary,
|
||||
foregroundColor: context.colors.onPrimary,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: child,
|
||||
)
|
||||
: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: context.colors.onSurface,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
final saved = choices.data?.choices ?? const <DeviceIdentityChoice>[];
|
||||
return Padding(
|
||||
final joinContent = Padding(
|
||||
padding: const EdgeInsets.all(Grid.gutter),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(Uri.parse(relay).host, style: context.textTheme.titleMedium),
|
||||
const SizedBox(height: Grid.sm),
|
||||
const Text(
|
||||
'Choose the identity to use in this community. Your key stays on this device.',
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: context.theme.brightness == Brightness.dark
|
||||
? const Color(0xFF233039)
|
||||
: Colors.white.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
name.isEmpty ? Uri.parse(relay).host : name,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
if (choices.hasError || choices.data?.complete == false)
|
||||
@@ -115,7 +213,7 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
'Some saved identities could not be read. You can use an available identity or import a key.',
|
||||
),
|
||||
for (final identity in saved)
|
||||
OutlinedButton(
|
||||
button(
|
||||
onPressed: busy.value
|
||||
? null
|
||||
: () => select(pubkey: identity.pubkey),
|
||||
@@ -127,22 +225,18 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (choices.connectionState == ConnectionState.done) ...[
|
||||
OutlinedButton(
|
||||
button(
|
||||
onPressed: busy.value
|
||||
? null
|
||||
: () async {
|
||||
final imported = await showNsecImportSheet(
|
||||
context,
|
||||
onImport: (nsec) => connect(nsec: nsec, close: false),
|
||||
);
|
||||
if (imported && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
: () {
|
||||
importFocusReady.value = false;
|
||||
importing.value = true;
|
||||
},
|
||||
child: const Text('Import a Nostr key'),
|
||||
),
|
||||
if (saved.isEmpty && choices.data?.complete == true)
|
||||
FilledButton(
|
||||
button(
|
||||
primary: true,
|
||||
onPressed: busy.value ? null : () => select(create: true),
|
||||
child: const Text('Create an identity and join'),
|
||||
),
|
||||
@@ -162,5 +256,39 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
return AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
onEnd: () {
|
||||
if (context.mounted && importing.value) importFocusReady.value = true;
|
||||
},
|
||||
child: BuzzTitledSheetLayout(
|
||||
title: importing.value ? 'Import identity' : 'Join community',
|
||||
onClose: importing.value
|
||||
? () {
|
||||
generation.value++;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
busy.value = false;
|
||||
error.value = null;
|
||||
importFocusReady.value = false;
|
||||
importing.value = false;
|
||||
}
|
||||
: null,
|
||||
child: importing.value
|
||||
? SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.8,
|
||||
child: NsecImportSheet(
|
||||
autofocus:
|
||||
importFocusReady.value ||
|
||||
MediaQuery.disableAnimationsOf(context),
|
||||
onImport: (nsec) => connect(nsec: nsec, close: false),
|
||||
),
|
||||
)
|
||||
: joinContent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'dart:math' show sqrt2;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart'
|
||||
show CupertinoColors, CupertinoTextField;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -34,6 +36,7 @@ part 'pairing_page/onboarding_colors.dart';
|
||||
part 'pairing_page/onboarding_glass_button.dart';
|
||||
part 'pairing_page/pairing_welcome_view.dart';
|
||||
part 'pairing_page/community_link_form.dart';
|
||||
part 'pairing_page/onboarding_connection_view.dart';
|
||||
part 'pairing_page/sas_verification_view.dart';
|
||||
part 'pairing_page/pairing_code_entry.dart';
|
||||
part 'pairing_page/pairing_error_shake.dart';
|
||||
@@ -46,6 +49,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
final VoidCallback? onAlreadyUseBuzz;
|
||||
final VoidCallback? onWelcomeBack;
|
||||
final bool connectionLanding;
|
||||
final bool showConnectionOptions;
|
||||
final bool existingUser;
|
||||
final bool enteringCommunityLink;
|
||||
final bool creatingCommunity;
|
||||
@@ -56,6 +60,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
final String? accountStatus;
|
||||
final Widget? setupContent;
|
||||
final String builderlabLabel;
|
||||
final bool builderlabBusy;
|
||||
|
||||
const PairingPage({
|
||||
super.key,
|
||||
@@ -64,6 +69,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
this.onAlreadyUseBuzz,
|
||||
this.onWelcomeBack,
|
||||
this.connectionLanding = false,
|
||||
this.showConnectionOptions = true,
|
||||
this.existingUser = false,
|
||||
this.enteringCommunityLink = false,
|
||||
this.creatingCommunity = false,
|
||||
@@ -74,15 +80,20 @@ class PairingPage extends HookConsumerWidget {
|
||||
this.accountStatus,
|
||||
this.setupContent,
|
||||
this.builderlabLabel = 'Connect to Builderlab',
|
||||
this.builderlabBusy = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hostedFlow = useListenable(
|
||||
HostedEnvironment.enabled ? ref.watch(hostedFlowProvider) : null,
|
||||
);
|
||||
final pairingState = ref.watch(pairingProvider);
|
||||
final enrolledBiometrics = ref.watch(enrolledBiometricsProvider);
|
||||
final codeController = useTextEditingController();
|
||||
final fallbackScannerVisible = useState(false);
|
||||
final pairingCodeExpanded = useState(false);
|
||||
final optionsExpanded = useState(false);
|
||||
final isBusy =
|
||||
pairingState.status == PairingStatus.connecting ||
|
||||
pairingState.status == PairingStatus.transferring ||
|
||||
@@ -225,6 +236,8 @@ class PairingPage extends HookConsumerWidget {
|
||||
value: onboardingSystemOverlayStyle,
|
||||
child: _OnboardingBackground(
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset:
|
||||
hostedFlow?.stage != HostedFlowStage.profile,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
@@ -261,13 +274,17 @@ class PairingPage extends HookConsumerWidget {
|
||||
value: onboardingSystemOverlayStyle,
|
||||
child: _OnboardingBackground(
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset:
|
||||
hostedFlow?.stage != HostedFlowStage.profile,
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: pairingAppBar,
|
||||
body: SafeArea(
|
||||
child: _PairingWelcomeView(
|
||||
optionsExpanded: optionsExpanded,
|
||||
onAlreadyUseBuzz: onAlreadyUseBuzz,
|
||||
onWelcomeBack: onWelcomeBack,
|
||||
connectionLanding: connectionLanding,
|
||||
showConnectionOptions: showConnectionOptions,
|
||||
existingUser: existingUser,
|
||||
enteringCommunityLink: enteringCommunityLink,
|
||||
creatingCommunity: creatingCommunity,
|
||||
@@ -282,6 +299,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
accountStatus: accountStatus,
|
||||
setupContent: setupContent,
|
||||
builderlabLabel: builderlabLabel,
|
||||
builderlabBusy: builderlabBusy,
|
||||
codeController: codeController,
|
||||
isBusy: isBusy,
|
||||
pairingCodeExpanded: pairingCodeExpanded.value,
|
||||
|
||||
@@ -5,16 +5,31 @@ class _CommunityLinkForm extends HookConsumerWidget {
|
||||
required this.onOpen,
|
||||
required this.onBack,
|
||||
this.onCreate,
|
||||
this.controller,
|
||||
this.focusNode,
|
||||
this.showBack = true,
|
||||
this.fullPage = false,
|
||||
this.autofocus = true,
|
||||
this.showHeading = true,
|
||||
this.layoutBuilder,
|
||||
});
|
||||
|
||||
final ValueChanged<String>? onCreate;
|
||||
final TextEditingController? controller;
|
||||
final FocusNode? focusNode;
|
||||
final bool showBack;
|
||||
final bool fullPage;
|
||||
final bool autofocus;
|
||||
final bool showHeading;
|
||||
final Widget Function(Widget fields, Widget action)? layoutBuilder;
|
||||
|
||||
final ValueChanged<Uri> onOpen;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useTextEditingController();
|
||||
final localController = useTextEditingController();
|
||||
final controller = this.controller ?? localController;
|
||||
final error = useState<String?>(null);
|
||||
|
||||
final flow = HostedEnvironment.enabled
|
||||
@@ -56,9 +71,62 @@ class _CommunityLinkForm extends HookConsumerWidget {
|
||||
onOpen(link);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
void changed(String _) {
|
||||
error.value = null;
|
||||
if (failed) unawaited(flow.leave());
|
||||
}
|
||||
|
||||
final problem =
|
||||
error.value ?? (failed ? hostedFlowProblemMessage(flow.problem) : null);
|
||||
final iosInput = fullPage && defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
final fields = <Widget>[
|
||||
if (fullPage && showHeading) ...[
|
||||
Text(
|
||||
'Enter a community link',
|
||||
style: context.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
'Paste an invite link or community address.',
|
||||
style: context.textTheme.titleLarge?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.lg),
|
||||
],
|
||||
if (iosInput) ...[
|
||||
CupertinoTextField(
|
||||
key: const Key('community-link-input'),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: fullPage && autofocus,
|
||||
enabled: !busy,
|
||||
placeholder: 'Community link or address',
|
||||
placeholderStyle: context.textTheme.titleLarge?.copyWith(
|
||||
color: CupertinoColors.placeholderText.resolveFrom(context),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.go,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
|
||||
style: context.textTheme.titleLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
cursorColor: context.colors.primary,
|
||||
decoration: null,
|
||||
onChanged: changed,
|
||||
onSubmitted: (_) => submit(),
|
||||
),
|
||||
if (problem != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.xs),
|
||||
child: Text(problem, style: TextStyle(color: context.colors.error)),
|
||||
),
|
||||
] else
|
||||
TextField(
|
||||
key: Key(
|
||||
onCreate != null
|
||||
@@ -66,14 +134,18 @@ class _CommunityLinkForm extends HookConsumerWidget {
|
||||
: 'community-link-input',
|
||||
),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: fullPage && autofocus,
|
||||
enabled: !busy,
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.go,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context._onboardingInk,
|
||||
),
|
||||
style:
|
||||
(fullPage
|
||||
? context.textTheme.titleLarge
|
||||
: context.textTheme.bodyMedium)
|
||||
?.copyWith(color: context._onboardingInk),
|
||||
cursorColor: context._onboardingInk,
|
||||
decoration: InputDecoration(
|
||||
labelText: onCreate != null
|
||||
@@ -98,27 +170,64 @@ class _CommunityLinkForm extends HookConsumerWidget {
|
||||
borderSide: BorderSide(color: context._onboardingInk),
|
||||
),
|
||||
),
|
||||
onChanged: (_) {
|
||||
error.value = null;
|
||||
if (failed) unawaited(flow.leave());
|
||||
},
|
||||
onChanged: changed,
|
||||
onSubmitted: (_) => submit(),
|
||||
),
|
||||
];
|
||||
final action = FilledButton(
|
||||
style: fullPage
|
||||
? FilledButton.styleFrom(
|
||||
backgroundColor: layoutBuilder == null
|
||||
? context.colors.primary
|
||||
: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
animationDuration: Duration.zero,
|
||||
foregroundColor: context.colors.onPrimary,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
)
|
||||
: context._onboardingGlassButtonStyle,
|
||||
onPressed: busy || (failed && !flow.canRetry) ? null : submit,
|
||||
child: Text(busy ? 'Connecting…' : 'Continue'),
|
||||
);
|
||||
if (layoutBuilder != null) {
|
||||
return layoutBuilder!(
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: fields,
|
||||
),
|
||||
),
|
||||
action,
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: fullPage ? MainAxisSize.max : MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (fullPage)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: fields,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...fields,
|
||||
const SizedBox(height: Grid.xs),
|
||||
FilledButton(
|
||||
style: context._onboardingGlassButtonStyle,
|
||||
onPressed: busy || (failed && !flow.canRetry) ? null : submit,
|
||||
child: Text(busy ? 'Connecting…' : 'Continue'),
|
||||
),
|
||||
action,
|
||||
const SizedBox(height: Grid.xxs),
|
||||
TextButton(
|
||||
style: context._onboardingGhostButtonStyle,
|
||||
onPressed: () {
|
||||
unawaited(flow?.leave());
|
||||
onBack?.call();
|
||||
},
|
||||
child: const Text('Back'),
|
||||
),
|
||||
if (showBack)
|
||||
TextButton(
|
||||
style: context._onboardingGhostButtonStyle,
|
||||
onPressed: () {
|
||||
unawaited(flow?.leave());
|
||||
onBack?.call();
|
||||
},
|
||||
child: const Text('Back'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ extension _OnboardingColors on BuildContext {
|
||||
);
|
||||
|
||||
ButtonStyle get _onboardingButtonStyle => FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
minimumSize: const Size(0, 52),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.lg,
|
||||
vertical: Grid.twelve,
|
||||
@@ -33,7 +33,7 @@ extension _OnboardingColors on BuildContext {
|
||||
);
|
||||
|
||||
ButtonStyle get _onboardingSecondaryButtonStyle => TextButton.styleFrom(
|
||||
minimumSize: const Size(0, 44),
|
||||
minimumSize: const Size(0, 52),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.md,
|
||||
vertical: Grid.xxs,
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
part of '../pairing_page.dart';
|
||||
|
||||
/// Keeps the selected community surface in place as it opens into a form.
|
||||
class _OnboardingConnectionView extends HookWidget {
|
||||
const _OnboardingConnectionView({
|
||||
required this.enteringLink,
|
||||
required this.onCommunityLink,
|
||||
required this.onBack,
|
||||
required this.onOpenCommunity,
|
||||
required this.onScan,
|
||||
required this.onPairing,
|
||||
});
|
||||
|
||||
final bool enteringLink;
|
||||
final VoidCallback? onCommunityLink;
|
||||
final VoidCallback? onBack;
|
||||
final ValueChanged<Uri> onOpenCommunity;
|
||||
final VoidCallback? onScan;
|
||||
final VoidCallback? onPairing;
|
||||
|
||||
double _textProgress(double progress, bool opening) =>
|
||||
opening ? ((progress - 0.2) / 0.8).clamp(0.0, 1.0) : progress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final ios = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
final keyboardReady = useState(false);
|
||||
final staggerEntrance = enteringLink && !ios;
|
||||
// Match the iOS return motion, starting after keyboard preparation.
|
||||
final duration = reducedMotion
|
||||
? Duration.zero
|
||||
: Duration(milliseconds: ios ? 200 : 150);
|
||||
final controller = useTextEditingController();
|
||||
final focus = useFocusNode();
|
||||
// Keep the return destination stable while keyboard insets change.
|
||||
// A new viewport (for example rotation) gets a fresh measurement.
|
||||
final viewport = MediaQuery.sizeOf(context);
|
||||
final restingHeight = useMemoized(() => <double?>[null], [viewport]);
|
||||
final enteringLinkRef = useRef(enteringLink)..value = enteringLink;
|
||||
useEffect(() {
|
||||
const channel = MethodChannel('buzz/community_keyboard');
|
||||
Timer? fallback;
|
||||
var disposed = false;
|
||||
void beginOpening() {
|
||||
if (disposed || !enteringLinkRef.value || keyboardReady.value) return;
|
||||
fallback?.cancel();
|
||||
keyboardReady.value = true;
|
||||
}
|
||||
|
||||
if (ios && enteringLink) {
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (call.method != 'willShow') return;
|
||||
beginOpening();
|
||||
});
|
||||
}
|
||||
if (enteringLink) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (disposed || !context.mounted || !enteringLinkRef.value) return;
|
||||
focus.requestFocus();
|
||||
if (!ios ||
|
||||
reducedMotion ||
|
||||
MediaQuery.viewInsetsOf(context).bottom > 0) {
|
||||
beginOpening();
|
||||
} else {
|
||||
// Hardware keyboards and older hosts may send no show notification.
|
||||
fallback = Timer(const Duration(milliseconds: 350), beginOpening);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
focus.unfocus();
|
||||
keyboardReady.value = false;
|
||||
}
|
||||
return () {
|
||||
disposed = true;
|
||||
fallback?.cancel();
|
||||
if (ios && enteringLink) channel.setMethodCallHandler(null);
|
||||
};
|
||||
}, [enteringLink, ios, reducedMotion]);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? IosGlassNavigationButton(
|
||||
key: const Key('community-link-back'),
|
||||
icon: IosGlassNavigationIcon.back,
|
||||
semanticLabel: 'Back',
|
||||
foregroundColor: context.colors.onSurface,
|
||||
onPressed: onBack,
|
||||
)
|
||||
: BackButton(
|
||||
key: const Key('community-link-back'),
|
||||
color: context.colors.onSurface,
|
||||
onPressed: onBack,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (!enteringLink &&
|
||||
MediaQuery.viewInsetsOf(context).bottom == 0) {
|
||||
restingHeight[0] ??= constraints.maxHeight;
|
||||
}
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
end: enteringLink && (!ios || keyboardReady.value) ? 1 : 0,
|
||||
),
|
||||
duration: duration,
|
||||
curve: ios
|
||||
? Curves.easeOutCubic
|
||||
: enteringLink
|
||||
? Curves.easeOutQuad
|
||||
: Curves.easeOutCubic.flipped,
|
||||
builder: (context, progress, _) => _CommunityLinkForm(
|
||||
controller: controller,
|
||||
focusNode: focus,
|
||||
showBack: false,
|
||||
fullPage: true,
|
||||
autofocus: false,
|
||||
showHeading: false,
|
||||
onOpen: onOpenCommunity,
|
||||
onBack: onBack,
|
||||
layoutBuilder: (fields, action) => CustomMultiChildLayout(
|
||||
delegate: _ConnectionLayout(
|
||||
progress: progress,
|
||||
opening: staggerEntrance,
|
||||
restingHeight: restingHeight[0],
|
||||
),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: 'heading',
|
||||
child: SingleChildScrollView(
|
||||
child: Stack(
|
||||
children: [
|
||||
_ConnectionMotionLayer(
|
||||
progress:
|
||||
1 -
|
||||
_textProgress(progress, staggerEntrance),
|
||||
active: !enteringLink,
|
||||
maintainSize: true,
|
||||
child: const _ConnectionHeading(
|
||||
title: 'Connect to a community',
|
||||
subtitle:
|
||||
'Scan a QR code or enter a community link to continue.',
|
||||
),
|
||||
),
|
||||
_ConnectionMotionLayer(
|
||||
progress: _textProgress(
|
||||
progress,
|
||||
staggerEntrance,
|
||||
),
|
||||
active: enteringLink,
|
||||
maintainSize: true,
|
||||
child: const _ConnectionHeading(
|
||||
title: 'Enter a community link',
|
||||
subtitle:
|
||||
'Paste an invite link or community address.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
LayoutId(
|
||||
id: 'scan',
|
||||
child: _ConnectionMotionLayer(
|
||||
key: const Key('connection-options-motion'),
|
||||
progress: 1 - progress,
|
||||
active: !enteringLink,
|
||||
maintainSize: true,
|
||||
child: _OnboardingChoiceContainer(
|
||||
title: 'Scan a QR code',
|
||||
icon: BuzzIcons.qrcode,
|
||||
subtitle:
|
||||
'Scan a community or desktop pairing code.',
|
||||
onTap: onScan,
|
||||
onIconDoubleTap: onPairing,
|
||||
),
|
||||
),
|
||||
),
|
||||
LayoutId(
|
||||
id: 'surface',
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(
|
||||
context.colors.surface,
|
||||
context.colors.primary,
|
||||
progress,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
Radii.container +
|
||||
(26 - Radii.container) * progress,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
LayoutId(
|
||||
id: 'link',
|
||||
child: _ConnectionMotionLayer(
|
||||
progress: 1 - progress,
|
||||
active: !enteringLink,
|
||||
maintainSize: true,
|
||||
child: _OnboardingChoiceContainer(
|
||||
title: 'Enter a community link',
|
||||
transparent: true,
|
||||
icon: BuzzIcons.link,
|
||||
subtitle:
|
||||
'Join a community with its address or invite.',
|
||||
onTap: onCommunityLink,
|
||||
),
|
||||
),
|
||||
),
|
||||
LayoutId(
|
||||
id: 'action',
|
||||
child: _ConnectionMotionLayer(
|
||||
progress: progress,
|
||||
active: enteringLink,
|
||||
maintainSize: true,
|
||||
child: action,
|
||||
),
|
||||
),
|
||||
LayoutId(
|
||||
id: 'form',
|
||||
child: _ConnectionMotionLayer(
|
||||
key: const Key('community-form-motion'),
|
||||
maintainSize: true,
|
||||
progress: staggerEntrance
|
||||
? ((progress - 0.4) / 0.6).clamp(0.0, 1.0)
|
||||
: (progress / 0.8).clamp(0.0, 1.0),
|
||||
active: enteringLink,
|
||||
child: fields,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures the cards so the resting heading sits directly above them,
|
||||
/// independent of text wrapping. Both states share the same moving heading.
|
||||
class _ConnectionLayout extends MultiChildLayoutDelegate {
|
||||
_ConnectionLayout({
|
||||
required this.progress,
|
||||
required this.opening,
|
||||
this.restingHeight,
|
||||
});
|
||||
|
||||
final bool opening;
|
||||
|
||||
final double? restingHeight;
|
||||
|
||||
final double progress;
|
||||
|
||||
@override
|
||||
void performLayout(Size size) {
|
||||
final baselineHeight = restingHeight ?? size.height;
|
||||
final heading = layoutChild(
|
||||
'heading',
|
||||
BoxConstraints(
|
||||
minWidth: size.width,
|
||||
maxWidth: size.width,
|
||||
maxHeight: size.height / 3,
|
||||
),
|
||||
);
|
||||
final cardConstraints = BoxConstraints(
|
||||
minWidth: size.width,
|
||||
maxWidth: size.width,
|
||||
maxHeight: baselineHeight,
|
||||
);
|
||||
final scan = layoutChild('scan', cardConstraints);
|
||||
final link = layoutChild('link', cardConstraints);
|
||||
final restingTop =
|
||||
(baselineHeight -
|
||||
Grid.sm -
|
||||
link.height -
|
||||
Grid.xs -
|
||||
scan.height -
|
||||
Grid.sm -
|
||||
heading.height)
|
||||
.clamp(0.0, baselineHeight);
|
||||
final expandedTop = Grid.sm.toDouble();
|
||||
final movement = opening ? (progress / 0.8).clamp(0.0, 1.0) : progress;
|
||||
final headingTop = restingTop + (expandedTop - restingTop) * movement;
|
||||
positionChild('heading', Offset(0, headingTop));
|
||||
positionChild(
|
||||
'scan',
|
||||
Offset(
|
||||
0,
|
||||
baselineHeight -
|
||||
Grid.sm -
|
||||
link.height -
|
||||
Grid.xs -
|
||||
scan.height +
|
||||
Grid.sm * progress,
|
||||
),
|
||||
);
|
||||
final linkTop = baselineHeight - Grid.sm - link.height;
|
||||
final actionTop = size.height - Grid.sm - 52;
|
||||
final sharedTop = linkTop + (actionTop - linkTop) * progress;
|
||||
positionChild('link', Offset(0, sharedTop));
|
||||
layoutChild(
|
||||
'surface',
|
||||
BoxConstraints.tight(
|
||||
Size(size.width, link.height + (52 - link.height) * progress),
|
||||
),
|
||||
);
|
||||
positionChild('surface', Offset(0, sharedTop));
|
||||
layoutChild(
|
||||
'action',
|
||||
BoxConstraints.tight(
|
||||
Size(size.width, link.height + (52 - link.height) * progress),
|
||||
),
|
||||
);
|
||||
positionChild('action', Offset(0, sharedTop));
|
||||
if (hasChild('form')) {
|
||||
final formTop = expandedTop + heading.height + Grid.sm;
|
||||
layoutChild(
|
||||
'form',
|
||||
BoxConstraints.tight(
|
||||
Size(
|
||||
size.width,
|
||||
(size.height - formTop - Grid.sm - 52 - Grid.xs).clamp(
|
||||
0.0,
|
||||
size.height,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
positionChild('form', Offset(0, headingTop + heading.height + Grid.sm));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_ConnectionLayout oldDelegate) =>
|
||||
oldDelegate.progress != progress ||
|
||||
oldDelegate.opening != opening ||
|
||||
oldDelegate.restingHeight != restingHeight;
|
||||
}
|
||||
|
||||
class _ConnectionHeading extends StatelessWidget {
|
||||
const _ConnectionHeading({required this.title, required this.subtitle});
|
||||
final String title;
|
||||
final String subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: context.textTheme.headlineSmall),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
subtitle,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _ConnectionMotionLayer extends StatelessWidget {
|
||||
const _ConnectionMotionLayer({
|
||||
super.key,
|
||||
required this.progress,
|
||||
required this.active,
|
||||
required this.child,
|
||||
this.maintainSize = false,
|
||||
});
|
||||
final double progress;
|
||||
final bool active;
|
||||
final Widget child;
|
||||
final bool maintainSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Visibility(
|
||||
visible: active || progress > 0,
|
||||
maintainState: true,
|
||||
maintainAnimation: true,
|
||||
maintainSize: maintainSize,
|
||||
child: IgnorePointer(
|
||||
ignoring: !active,
|
||||
child: ExcludeFocus(
|
||||
excluding: !active,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !active,
|
||||
child: Opacity(opacity: progress, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _OnboardingChoiceContainer extends StatelessWidget {
|
||||
const _OnboardingChoiceContainer({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
this.onTap,
|
||||
this.onIconDoubleTap,
|
||||
this.transparent = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final bool transparent;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onIconDoubleTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
button: onTap != null,
|
||||
label: title,
|
||||
hint: subtitle,
|
||||
onTap: onTap,
|
||||
child: ExcludeSemantics(
|
||||
excluding: true,
|
||||
child: Material(
|
||||
// White surface cards over the onboarding artwork.
|
||||
color: transparent ? Colors.transparent : context.colors.surface,
|
||||
borderRadius: BorderRadius.circular(Radii.container),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
excludeFromSemantics: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.gutter),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ExcludeSemantics(
|
||||
child: GestureDetector(
|
||||
key: onIconDoubleTap == null
|
||||
? null
|
||||
: const Key('developer-pairing-trigger'),
|
||||
excludeFromSemantics: true,
|
||||
onDoubleTap: onIconDoubleTap,
|
||||
child: SizedBox(
|
||||
width: Grid.sm,
|
||||
height: Grid.sm,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: Grid.sm,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Align the visible glyphs with the icon, accounting
|
||||
// for the title font's leading above its cap height.
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -2),
|
||||
child: Text(
|
||||
title,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
subtitle,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
part of '../pairing_page.dart';
|
||||
|
||||
class _PairingWelcomeView extends StatelessWidget {
|
||||
class _PairingWelcomeView extends HookWidget {
|
||||
final ValueNotifier<bool> optionsExpanded;
|
||||
final TextEditingController codeController;
|
||||
final bool isBusy;
|
||||
final bool pairingCodeExpanded;
|
||||
@@ -11,6 +12,7 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
final VoidCallback? onAlreadyUseBuzz;
|
||||
final VoidCallback? onWelcomeBack;
|
||||
final bool connectionLanding;
|
||||
final bool showConnectionOptions;
|
||||
final bool existingUser;
|
||||
final bool enteringCommunityLink;
|
||||
final bool creatingCommunity;
|
||||
@@ -21,9 +23,11 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
final String? accountStatus;
|
||||
final Widget? setupContent;
|
||||
final String builderlabLabel;
|
||||
final bool builderlabBusy;
|
||||
final ValueChanged<Uri> onOpenCommunity;
|
||||
|
||||
const _PairingWelcomeView({
|
||||
required this.optionsExpanded,
|
||||
required this.codeController,
|
||||
required this.isBusy,
|
||||
required this.pairingCodeExpanded,
|
||||
@@ -35,6 +39,7 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
this.onAlreadyUseBuzz,
|
||||
this.onWelcomeBack,
|
||||
this.connectionLanding = false,
|
||||
this.showConnectionOptions = true,
|
||||
this.existingUser = false,
|
||||
this.enteringCommunityLink = false,
|
||||
this.creatingCommunity = false,
|
||||
@@ -45,6 +50,7 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
this.accountStatus,
|
||||
this.setupContent,
|
||||
this.builderlabLabel = 'Connect to Builderlab',
|
||||
this.builderlabBusy = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -52,7 +58,7 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final revealDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220);
|
||||
: const Duration(milliseconds: 150);
|
||||
|
||||
final selectedPage = creatingCommunity
|
||||
? 4
|
||||
@@ -61,7 +67,33 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
: existingUser
|
||||
? 2
|
||||
: 1;
|
||||
Widget actions(int page) => page == 3 || page == 4
|
||||
List<Widget> connectionActions() => [
|
||||
const SizedBox(height: Grid.xxs),
|
||||
FilledButton(
|
||||
style: context._onboardingGlassButtonStyle,
|
||||
onPressed: onCommunityLink,
|
||||
child: const Text('Enter a community link'),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
GestureDetector(
|
||||
key: const Key('developer-pairing-trigger'),
|
||||
excludeFromSemantics: true,
|
||||
onDoubleTap: isBusy ? null : onAlreadyUseBuzz,
|
||||
child: FilledButton(
|
||||
style: context._onboardingGlassButtonStyle,
|
||||
onPressed: isBusy ? null : onScan,
|
||||
child: const Text('Scan a QR code'),
|
||||
),
|
||||
),
|
||||
];
|
||||
final showingOptions =
|
||||
optionsExpanded.value &&
|
||||
!existingUser &&
|
||||
setupContent == null &&
|
||||
!showConnectionOptions;
|
||||
Widget actions(int page) => page == 3 && optionsExpanded.value
|
||||
? const SizedBox.shrink()
|
||||
: page == 3 || page == 4
|
||||
? _CommunityLinkForm(
|
||||
onOpen: onOpenCommunity,
|
||||
onBack: onWelcomeBack,
|
||||
@@ -92,39 +124,36 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
const SizedBox(height: Grid.xs),
|
||||
],
|
||||
if (page == 1) ...[
|
||||
if (HostedEnvironment.enabled) ...[
|
||||
if (HostedEnvironment.enabled)
|
||||
FilledButton(
|
||||
style: context._onboardingGlassButtonStyle,
|
||||
onPressed: onBuilderlab,
|
||||
child: Text(builderlabLabel),
|
||||
child: builderlabBusy
|
||||
? BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
color: context._onboardingInk,
|
||||
semanticLabel: builderlabLabel,
|
||||
)
|
||||
: Text(builderlabLabel),
|
||||
),
|
||||
if (!showConnectionOptions) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
TextButton(
|
||||
style: context._onboardingGhostButtonStyle,
|
||||
onPressed: isBusy
|
||||
? null
|
||||
: () => optionsExpanded.value = !optionsExpanded.value,
|
||||
child: const Text('Other options'),
|
||||
),
|
||||
],
|
||||
FilledButton(
|
||||
style: context._onboardingGlassButtonStyle,
|
||||
onPressed: onCommunityLink,
|
||||
child: const Text('Enter a community link'),
|
||||
),
|
||||
if (HostedEnvironment.enabled && onCreateCommunity != null) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
FilledButton(
|
||||
style: context._onboardingGlassButtonStyle,
|
||||
if (showConnectionOptions) ...connectionActions(),
|
||||
if (HostedEnvironment.enabled &&
|
||||
showConnectionOptions &&
|
||||
onCreateCommunity != null)
|
||||
TextButton(
|
||||
onPressed: isBusy ? null : onCreateCommunity,
|
||||
child: const Text('Create a community'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.xxs),
|
||||
FilledButton(
|
||||
style: context._onboardingGlassButtonStyle,
|
||||
onPressed: isBusy ? null : onScan,
|
||||
child: const Text('Scan a QR code'),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
TextButton(
|
||||
style: context._onboardingGhostButtonStyle,
|
||||
onPressed: isBusy ? null : onAlreadyUseBuzz,
|
||||
child: const Text('Use pairing code'),
|
||||
),
|
||||
] else ...[
|
||||
if (!connectionLanding) ...[
|
||||
FilledButton(
|
||||
@@ -155,8 +184,8 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
],
|
||||
AnimatedSwitcher(
|
||||
duration: revealDuration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
switchInCurve: Curves.easeOutQuad,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
transitionBuilder: (child, animation) {
|
||||
return SizeTransition(
|
||||
sizeFactor: animation,
|
||||
@@ -269,7 +298,7 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
final welcome = Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.sm,
|
||||
@@ -343,7 +372,9 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
0,
|
||||
),
|
||||
duration: revealDuration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
curve: page == selectedPage
|
||||
? Curves.easeOutQuad
|
||||
: Curves.easeOutCubic.flipped,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: actions(page),
|
||||
@@ -363,5 +394,79 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
return PopScope(
|
||||
canPop: !showingOptions,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && showingOptions && !enteringCommunityLink) {
|
||||
optionsExpanded.value = false;
|
||||
}
|
||||
},
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: showingOptions ? 1 : 0),
|
||||
duration: reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 150),
|
||||
curve: showingOptions
|
||||
? Curves.easeOutQuad
|
||||
: Curves.easeOutCubic.flipped,
|
||||
child: welcome,
|
||||
builder: (context, progress, child) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ExcludeFocus(
|
||||
excluding: showingOptions,
|
||||
child: ExcludeSemantics(
|
||||
excluding: showingOptions,
|
||||
child: IgnorePointer(
|
||||
ignoring: showingOptions,
|
||||
child: Opacity(
|
||||
opacity: 1 - progress,
|
||||
child: ImageFiltered(
|
||||
enabled: !reducedMotion && progress > 0 && progress < 1,
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: 16 * progress,
|
||||
sigmaY: 16 * progress,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showingOptions || progress > 0) ...[
|
||||
Opacity(
|
||||
opacity: progress,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, reducedMotion ? 0 : 16 * (1 - progress)),
|
||||
child: IgnorePointer(
|
||||
ignoring: !showingOptions,
|
||||
child: ExcludeFocus(
|
||||
excluding: !showingOptions,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !showingOptions,
|
||||
child: _OnboardingConnectionView(
|
||||
enteringLink: enteringCommunityLink,
|
||||
onCommunityLink: onCommunityLink,
|
||||
onBack: () {
|
||||
if (enteringCommunityLink) {
|
||||
onWelcomeBack?.call();
|
||||
} else {
|
||||
optionsExpanded.value = false;
|
||||
}
|
||||
},
|
||||
onOpenCommunity: onOpenCommunity,
|
||||
onScan: isBusy ? null : onScan,
|
||||
onPairing: isBusy ? null : onAlreadyUseBuzz,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ class _SasVerificationView extends HookConsumerWidget {
|
||||
TextButton(
|
||||
style: context._onboardingSecondaryButtonStyle.copyWith(
|
||||
minimumSize: const WidgetStatePropertyAll(
|
||||
Size.fromHeight(48),
|
||||
Size.fromHeight(52),
|
||||
),
|
||||
),
|
||||
onPressed: showProtection
|
||||
|
||||
@@ -46,8 +46,12 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
|
||||
required this.onPrepareChanged,
|
||||
this.initialFrames = const [],
|
||||
this.disposalBarrier,
|
||||
this.previewKey,
|
||||
});
|
||||
|
||||
/// Identifies the visible avatar for the native sheet transition.
|
||||
final GlobalKey? previewKey;
|
||||
|
||||
/// The vertical space available to the capture surface.
|
||||
final double height;
|
||||
|
||||
@@ -427,6 +431,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
|
||||
.toDouble();
|
||||
},
|
||||
child: SizedBox.square(
|
||||
key: previewKey,
|
||||
dimension: _animatedAvatarPreviewSize,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
@@ -585,6 +590,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
|
||||
key: const ValueKey('animated-avatar-capture-preview'),
|
||||
dimension: capturePreviewSize,
|
||||
child: Stack(
|
||||
key: previewKey,
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Padding(
|
||||
|
||||
@@ -130,3 +130,30 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfers only local draft data between the app and its native sheet engine.
|
||||
Map<String, Object?>? encodeProfileAvatarDraft(ProfileAvatarDraft? draft) =>
|
||||
switch (draft) {
|
||||
ProfileImageAvatarDraft(:final bytes) => {
|
||||
'type': 'image',
|
||||
'bytes': bytes,
|
||||
},
|
||||
ProfileAnimatedAvatarDraft(:final animation, :final poster) => {
|
||||
'type': 'animated',
|
||||
'animation': animation,
|
||||
'poster': poster,
|
||||
},
|
||||
ProfileUrlAvatarDraft(:final url) => {'type': 'url', 'url': url},
|
||||
null => null,
|
||||
};
|
||||
|
||||
ProfileAvatarDraft? decodeProfileAvatarDraft(Map<dynamic, dynamic>? value) =>
|
||||
switch (value?['type']) {
|
||||
'image' => ProfileImageAvatarDraft(value!['bytes'] as Uint8List),
|
||||
'animated' => ProfileAnimatedAvatarDraft(
|
||||
animation: value!['animation'] as Uint8List,
|
||||
poster: value['poster'] as Uint8List,
|
||||
),
|
||||
'url' => ProfileUrlAvatarDraft(value!['url'] as String),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -85,11 +85,20 @@ class ProfileAvatarEditor extends HookConsumerWidget {
|
||||
required this.onImageCameraActiveChanged,
|
||||
this.animatedCaptureBuilder,
|
||||
this.imageCaptureBuilder,
|
||||
this.previewKey,
|
||||
this.previewOpacity = 1,
|
||||
this.emptyPreview,
|
||||
this.headerGap = Grid.xs,
|
||||
});
|
||||
|
||||
/// The avatar URL shown until the user selects a new draft.
|
||||
final String? currentAvatarUrl;
|
||||
|
||||
final GlobalKey? previewKey;
|
||||
final double previewOpacity;
|
||||
final Widget? emptyPreview;
|
||||
final double headerGap;
|
||||
|
||||
/// The text initial used when [currentAvatarUrl] has no displayable image.
|
||||
final String fallbackInitial;
|
||||
|
||||
@@ -252,6 +261,8 @@ class ProfileAvatarEditor extends HookConsumerWidget {
|
||||
(draft as ProfileImageAvatarDraft).bytes,
|
||||
),
|
||||
),
|
||||
ProfileAvatarMode.image when previewUrl == null && emptyPreview != null =>
|
||||
SizedBox.square(dimension: _previewSize, child: emptyPreview),
|
||||
ProfileAvatarMode.image => PlayingAvatarImage(
|
||||
key: const ValueKey('avatar-editor-fixed-preview'),
|
||||
imageUrl: previewUrl,
|
||||
@@ -318,7 +329,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
|
||||
reverseCurve: _exitCurve,
|
||||
);
|
||||
final appBarHeight = frostedAppBarHeight(context);
|
||||
final topPadding = appBarHeight + Grid.xs;
|
||||
final topPadding = appBarHeight + headerGap;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -421,6 +432,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
|
||||
onPrepareChanged: onAnimatedPrepareChanged,
|
||||
) ??
|
||||
AnimatedAvatarCapture(
|
||||
previewKey: previewKey,
|
||||
height: modeHeight,
|
||||
onPrepareChanged: onAnimatedPrepareChanged,
|
||||
disposalBarrier: cameraDisposal.value,
|
||||
@@ -506,7 +518,10 @@ class ProfileAvatarEditor extends HookConsumerWidget {
|
||||
child: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: curvedEntrance,
|
||||
child: fixedPreview,
|
||||
child: Opacity(
|
||||
opacity: previewOpacity,
|
||||
child: KeyedSubtree(key: previewKey, child: fixedPreview),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final progress = curvedEntrance.value;
|
||||
return Transform.translate(
|
||||
|
||||
42
mobile/lib/features/profile/profile_avatar_sheet_app.dart
Normal file
42
mobile/lib/features/profile/profile_avatar_sheet_app.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/theme/theme_provider.dart';
|
||||
import 'profile_avatar_draft.dart';
|
||||
import 'profile_setup_avatar_editor.dart';
|
||||
|
||||
/// A local-only editor hosted inside the existing UIKit sheet's navigation stack.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> profileAvatarEditorMain() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('buzz/profile_avatar_sheet');
|
||||
final initial =
|
||||
await channel.invokeMapMethod<String, dynamic>('initial') ?? {};
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: initial['dark'] == true ? ThemeMode.dark : ThemeMode.light,
|
||||
home: ProfileSetupAvatarEditor(
|
||||
initialDraft: decodeProfileAvatarDraft(initial['draft'] as Map?),
|
||||
onPreviewReady: (rect) => channel.invokeMethod<void>('ready', {
|
||||
'x': rect.left,
|
||||
'y': rect.top,
|
||||
'width': rect.width,
|
||||
'height': rect.height,
|
||||
}),
|
||||
onFinished: (draft) => channel.invokeMethod<void>(
|
||||
'finish',
|
||||
encodeProfileAvatarDraft(draft),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -133,6 +133,12 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
|
||||
/// Updates the current user's display name while preserving the other
|
||||
/// metadata fields in their latest kind:0 profile event.
|
||||
Future<void> updateSetup(String displayName, String? avatarUrl) =>
|
||||
_publishProfilePatch({
|
||||
'display_name': displayName.trim(),
|
||||
'picture': ?avatarUrl,
|
||||
});
|
||||
|
||||
Future<void> updateDisplayName(String displayName) =>
|
||||
_publishProfilePatch({'display_name': displayName.trim()});
|
||||
|
||||
|
||||
175
mobile/lib/features/profile/profile_setup_avatar_editor.dart
Normal file
175
mobile/lib/features/profile/profile_setup_avatar_editor.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../shared/widgets/ios_glass_navigation_button.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/ios_glass_navigation_action.dart';
|
||||
import 'profile_setup_empty_avatar.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'profile_avatar_draft.dart';
|
||||
import 'profile_avatar_editor.dart';
|
||||
|
||||
/// Reuses the profile editor while returning a local draft instead of publishing.
|
||||
class ProfileSetupAvatarEditor extends HookConsumerWidget {
|
||||
const ProfileSetupAvatarEditor({
|
||||
super.key,
|
||||
this.initialDraft,
|
||||
this.onFinished,
|
||||
this.onPreviewReady,
|
||||
});
|
||||
final ProfileAvatarDraft? initialDraft;
|
||||
final ValueChanged<ProfileAvatarDraft?>? onFinished;
|
||||
final Future<void> Function(Rect)? onPreviewReady;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final previewVisible = useState(onPreviewReady == null);
|
||||
final previewKey = useMemoized(() => GlobalKey());
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final box = previewKey.currentContext?.findRenderObject();
|
||||
if (box is RenderBox && box.hasSize) {
|
||||
try {
|
||||
await onPreviewReady
|
||||
?.call(box.localToGlobal(Offset.zero) & box.size)
|
||||
.timeout(const Duration(seconds: 2), onTimeout: () {});
|
||||
} finally {
|
||||
if (context.mounted) previewVisible.value = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}, const []);
|
||||
final draft = useState(initialDraft);
|
||||
final mode = useState(switch (initialDraft) {
|
||||
ProfileUrlAvatarDraft() => ProfileAvatarMode.emoji,
|
||||
ProfileAnimatedAvatarDraft() => ProfileAvatarMode.animated,
|
||||
_ => ProfileAvatarMode.image,
|
||||
});
|
||||
final prepare = useState<Future<ProfileAvatarDraft?> Function()?>(null);
|
||||
final busy = useState(false);
|
||||
final error = useState(false);
|
||||
final transition = useAnimationController(initialValue: 1);
|
||||
final VoidCallback? close = busy.value
|
||||
? null
|
||||
: () {
|
||||
if (onFinished != null) {
|
||||
onFinished!(null);
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
};
|
||||
final VoidCallback? done =
|
||||
busy.value || (draft.value == null && prepare.value == null)
|
||||
? null
|
||||
: () async {
|
||||
busy.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
final result =
|
||||
mode.value == ProfileAvatarMode.animated &&
|
||||
prepare.value != null
|
||||
? await prepare.value!()
|
||||
: draft.value;
|
||||
if (context.mounted && result != null) {
|
||||
if (onFinished != null) {
|
||||
onFinished!(result);
|
||||
} else {
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) error.value = true;
|
||||
} finally {
|
||||
if (context.mounted) busy.value = false;
|
||||
}
|
||||
};
|
||||
return PopScope(
|
||||
canPop: !busy.value,
|
||||
child: FrostedScaffold(
|
||||
useUtilitySurfaceTheme: true,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: FrostedAppBar(
|
||||
nativeTitle: 'Profile photo',
|
||||
centerTitle: true,
|
||||
title: const Text('Profile photo'),
|
||||
leading: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? IosGlassNavigationButton(
|
||||
icon: IosGlassNavigationIcon.back,
|
||||
semanticLabel: 'Back to profile',
|
||||
onPressed: close,
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: close,
|
||||
tooltip: 'Back to profile',
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
actions: [
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS)
|
||||
IosGlassNavigationAction(
|
||||
label: 'Done',
|
||||
width: 72,
|
||||
isBusy: busy.value,
|
||||
onPressed: done,
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: done,
|
||||
child: Text(busy.value ? 'Preparing…' : 'Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (error.value)
|
||||
const Text('Could not prepare this photo. Try again.'),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
ignoring: busy.value,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
physics: constraints.maxHeight < 720
|
||||
? const ClampingScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: constraints.maxHeight < 720
|
||||
? 720
|
||||
: constraints.maxHeight,
|
||||
child: ProfileAvatarEditor(
|
||||
previewKey: previewKey,
|
||||
headerGap: Grid.lg,
|
||||
previewOpacity: previewVisible.value ? 1 : 0,
|
||||
currentAvatarUrl: initialDraft is ProfileUrlAvatarDraft
|
||||
? (initialDraft as ProfileUrlAvatarDraft).url
|
||||
: null,
|
||||
fallbackInitial: '+',
|
||||
emptyPreview: const ProfileSetupEmptyAvatar(),
|
||||
draft: draft.value,
|
||||
mode: mode.value,
|
||||
transition: transition,
|
||||
onModeChanged: (value) {
|
||||
mode.value = value;
|
||||
draft.value = null;
|
||||
prepare.value = null;
|
||||
},
|
||||
onDraftChanged: (value) => draft.value = value,
|
||||
onAnimatedPrepareChanged: (value) {
|
||||
prepare.value = value;
|
||||
},
|
||||
onImageCameraActiveChanged: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
mobile/lib/features/profile/profile_setup_empty_avatar.dart
Normal file
62
mobile/lib/features/profile/profile_setup_empty_avatar.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../shared/theme/buzz_icons.dart';
|
||||
|
||||
/// Matches the native setup avatar, scaled from its 96-point source bounds.
|
||||
class ProfileSetupEmptyAvatar extends StatelessWidget {
|
||||
const ProfileSetupEmptyAvatar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FittedBox(
|
||||
child: SizedBox.square(
|
||||
dimension: 96,
|
||||
child: CustomPaint(
|
||||
painter: _RingPainter(
|
||||
CupertinoDynamicColor.resolve(CupertinoColors.tertiaryLabel, context),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
BuzzIcons.plus,
|
||||
size: 28,
|
||||
color: CupertinoDynamicColor.resolve(
|
||||
CupertinoColors.label,
|
||||
context,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RingPainter extends CustomPainter {
|
||||
const _RingPainter(this.color);
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
final bounds = Offset.zero & size;
|
||||
final radius = (size.width - 4) / 2;
|
||||
final circumference = 2 * math.pi * radius;
|
||||
final count = (circumference / 9).round();
|
||||
final step = 2 * math.pi / count;
|
||||
for (var index = 0; index < count; index++) {
|
||||
canvas.drawArc(
|
||||
bounds.deflate(2),
|
||||
index * step,
|
||||
step * 4 / 9,
|
||||
false,
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RingPainter oldDelegate) => oldDelegate.color != color;
|
||||
}
|
||||
311
mobile/lib/features/profile/profile_setup_page.dart
Normal file
311
mobile/lib/features/profile/profile_setup_page.dart
Normal file
@@ -0,0 +1,311 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/hosted/hosted_flow.dart';
|
||||
import '../../shared/emoji/emoji_avatar.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
// Retain the named entry point used by the native sheet's editor engine.
|
||||
// ignore: unused_import
|
||||
import 'profile_avatar_sheet_app.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import 'profile_avatar_draft.dart';
|
||||
import '../../shared/widgets/playing_avatar_image.dart';
|
||||
import 'profile_setup_avatar_editor.dart';
|
||||
|
||||
/// One optional profile sheet for onboarding and existing-community entry.
|
||||
class ProfileSetupPage extends HookConsumerWidget {
|
||||
const ProfileSetupPage({super.key, required this.flow});
|
||||
final HostedFlow flow;
|
||||
static const _channel = MethodChannel('buzz/profile_setup');
|
||||
static int _nextSession = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = utilitySurfaceThemeData(Theme.of(context));
|
||||
useEffect(() {
|
||||
final session = ++_nextSession;
|
||||
var active = true;
|
||||
var name = '';
|
||||
final avatar = ValueNotifier<ProfileAvatarDraft?>(null);
|
||||
ModalRoute<dynamic>? profileRoute;
|
||||
ModalRoute<dynamic>? editorRoute;
|
||||
bool current() =>
|
||||
active && context.mounted && flow.stage == HostedFlowStage.profile;
|
||||
Future<void> save() async {
|
||||
if (!current()) return;
|
||||
final draft = avatar.value;
|
||||
final service = draft == null
|
||||
? null
|
||||
: ref.read(mediaUploadServiceProvider);
|
||||
await flow.saveProfileDraft!(
|
||||
name.trim(),
|
||||
draft == null ? null : () => draft.upload(service!),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> editAvatar() async {
|
||||
if (!current()) return;
|
||||
final result = await showBuzzModalBottomSheet<ProfileAvatarDraft>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showCloseButton: false,
|
||||
padBottom: false,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
builder: (sheetContext) {
|
||||
editorRoute = ModalRoute.of(sheetContext);
|
||||
return SizedBox(
|
||||
height: MediaQuery.sizeOf(sheetContext).height * .94,
|
||||
child: ProfileSetupAvatarEditor(initialDraft: avatar.value),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!current()) return;
|
||||
editorRoute = null;
|
||||
if (result != null) avatar.value = result;
|
||||
}
|
||||
|
||||
Future<void> fallback() async {
|
||||
if (!current()) return;
|
||||
await showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
title: 'Set up your profile',
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
showCloseButton: false,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) {
|
||||
profileRoute = ModalRoute.of(sheetContext);
|
||||
return _ProfileSetupForm(
|
||||
avatar: avatar,
|
||||
onName: (value) => name = value,
|
||||
editAvatar: editAvatar,
|
||||
save: save,
|
||||
skip: flow.finishProfile,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!current()) return;
|
||||
profileRoute = null;
|
||||
if (current()) flow.finishProfile();
|
||||
}
|
||||
|
||||
Future<void> present() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) {
|
||||
await fallback();
|
||||
return;
|
||||
}
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
if (!current() || (call.arguments as Map?)?['session'] != session) {
|
||||
return null;
|
||||
}
|
||||
switch (call.method) {
|
||||
case 'avatarDraft':
|
||||
avatar.value = decodeProfileAvatarDraft(
|
||||
(call.arguments as Map)['draft'] as Map?,
|
||||
);
|
||||
final emoji = avatar.value is ProfileUrlAvatarDraft
|
||||
? parseEmojiAvatarDataUrl(
|
||||
(avatar.value as ProfileUrlAvatarDraft).url,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
'avatar': switch (avatar.value) {
|
||||
ProfileImageAvatarDraft(:final bytes) => bytes,
|
||||
ProfileAnimatedAvatarDraft(:final poster) => poster,
|
||||
_ => null,
|
||||
},
|
||||
'emoji': emoji?.emoji,
|
||||
'emojiColor': emoji?.colorValue,
|
||||
};
|
||||
case 'save':
|
||||
name = ((call.arguments as Map)['name'] as String? ?? '').trim();
|
||||
if (name.isEmpty || name.characters.length > 100) {
|
||||
return {'saved': false};
|
||||
}
|
||||
try {
|
||||
await save();
|
||||
return {'saved': true};
|
||||
} catch (_) {
|
||||
return {'saved': false};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
try {
|
||||
while (current()) {
|
||||
final Uint8List? bytes = switch (avatar.value) {
|
||||
ProfileImageAvatarDraft(:final bytes) => bytes,
|
||||
ProfileAnimatedAvatarDraft(:final poster) => poster,
|
||||
_ => null,
|
||||
};
|
||||
final emoji = avatar.value is ProfileUrlAvatarDraft
|
||||
? parseEmojiAvatarDataUrl(
|
||||
(avatar.value as ProfileUrlAvatarDraft).url,
|
||||
)
|
||||
: null;
|
||||
final action = await _channel.invokeMethod<String>('present', {
|
||||
'session': session,
|
||||
'name': name,
|
||||
'avatar': bytes,
|
||||
'emoji': emoji?.emoji,
|
||||
'emojiColor': emoji?.colorValue,
|
||||
'dark': theme.brightness == Brightness.dark,
|
||||
'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(),
|
||||
'containerBackgroundArgb': theme
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.toARGB32(),
|
||||
'containerCornerRadius': Radii.container,
|
||||
|
||||
'primaryArgb': theme.colorScheme.primary.toARGB32(),
|
||||
});
|
||||
if (!current()) return;
|
||||
if (action != 'saved') flow.finishProfile();
|
||||
return;
|
||||
}
|
||||
} on MissingPluginException {
|
||||
await fallback();
|
||||
} on PlatformException {
|
||||
await fallback();
|
||||
} finally {
|
||||
if (active) _channel.setMethodCallHandler(null);
|
||||
}
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (current()) unawaited(present());
|
||||
});
|
||||
return () {
|
||||
active = false;
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
_channel.setMethodCallHandler(null);
|
||||
unawaited(
|
||||
_channel
|
||||
.invokeMethod<void>('dismiss', {'session': session})
|
||||
.catchError((Object _) {}),
|
||||
);
|
||||
}
|
||||
final routes = [editorRoute, profileRoute];
|
||||
avatar.dispose();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
for (final route in routes) {
|
||||
if (route != null && route.isActive) {
|
||||
route.navigator?.removeRoute(route);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [flow]);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileSetupForm extends HookWidget {
|
||||
const _ProfileSetupForm({
|
||||
required this.avatar,
|
||||
required this.onName,
|
||||
required this.editAvatar,
|
||||
required this.save,
|
||||
required this.skip,
|
||||
});
|
||||
final ValueNotifier<ProfileAvatarDraft?> avatar;
|
||||
final ValueChanged<String> onName;
|
||||
final Future<void> Function() editAvatar;
|
||||
final Future<void> Function() save;
|
||||
final VoidCallback skip;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final draft = useValueListenable(avatar);
|
||||
final name = useTextEditingController();
|
||||
useListenable(name);
|
||||
final busy = useState(false);
|
||||
final error = useState(false);
|
||||
return PopScope(
|
||||
canPop: !busy.value,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
16,
|
||||
20,
|
||||
MediaQuery.viewInsetsOf(context).bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: busy.value ? null : editAvatar,
|
||||
icon: switch (draft) {
|
||||
ProfileImageAvatarDraft(:final bytes) => CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundImage: MemoryImage(bytes),
|
||||
),
|
||||
ProfileAnimatedAvatarDraft(:final poster) => CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundImage: MemoryImage(poster),
|
||||
),
|
||||
ProfileUrlAvatarDraft(:final url) => PlayingAvatarImage(
|
||||
imageUrl: url,
|
||||
radius: 32,
|
||||
fallback: const Icon(Icons.add_a_photo_outlined),
|
||||
),
|
||||
null => const Icon(Icons.add_a_photo_outlined),
|
||||
},
|
||||
iconSize: 64,
|
||||
tooltip: 'Add profile photo',
|
||||
),
|
||||
TextField(
|
||||
controller: name,
|
||||
maxLength: 100,
|
||||
enabled: !busy.value,
|
||||
onChanged: onName,
|
||||
decoration: const InputDecoration(labelText: 'Display name'),
|
||||
),
|
||||
if (error.value)
|
||||
const Text('Could not save your profile. Try again or skip.'),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
onPressed: busy.value || name.text.trim().isEmpty
|
||||
? null
|
||||
: () async {
|
||||
busy.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
await save();
|
||||
} catch (_) {
|
||||
if (context.mounted) error.value = true;
|
||||
} finally {
|
||||
if (context.mounted) busy.value = false;
|
||||
}
|
||||
},
|
||||
child: Text(busy.value ? 'Saving…' : 'Continue'),
|
||||
),
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
onPressed: busy.value ? null : skip,
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'features/channels/deep_link_dispatcher.dart';
|
||||
import 'features/profile/profile_setup_page.dart';
|
||||
import 'features/onboarding/hosted_connect_page.dart';
|
||||
import 'features/onboarding/self_hosted_entry_sheet.dart';
|
||||
import 'shared/auth/auth.dart';
|
||||
@@ -166,12 +167,13 @@ class HostedAppGate extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (flow.stage == HostedFlowStage.profile)
|
||||
ProfileSetupPage(flow: flow),
|
||||
if (visible &&
|
||||
flow.stage != HostedFlowStage.profile &&
|
||||
!openingSignIn &&
|
||||
!inlineAccountStatus &&
|
||||
(!inlineEntry ||
|
||||
(flow.accountOnly &&
|
||||
flow.stage == HostedFlowStage.profile) ||
|
||||
flow.stage == HostedFlowStage.chooseIdentity ||
|
||||
flow.stage == HostedFlowStage.chooseCommunities ||
|
||||
flow.stage == HostedFlowStage.communityConflict ||
|
||||
|
||||
@@ -78,6 +78,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
|
||||
flow.account.account == null) &&
|
||||
flow.stage != HostedFlowStage.idle &&
|
||||
flow.stage != HostedFlowStage.complete &&
|
||||
flow.stage != HostedFlowStage.profile &&
|
||||
flow.stage != HostedFlowStage.chooseIdentity &&
|
||||
flow.stage != HostedFlowStage.chooseCommunities &&
|
||||
flow.stage != HostedFlowStage.communityConflict &&
|
||||
@@ -95,7 +96,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
|
||||
: problem != null
|
||||
? hostedFlowProblemMessage(problem)
|
||||
: busy
|
||||
? 'Connecting to Builderlab…'
|
||||
? null
|
||||
: flow.noCommunitiesToAdd
|
||||
? 'No communities available to add. Scan a QR code or use an invite link.'
|
||||
: connected
|
||||
@@ -117,10 +118,18 @@ class OnboardingAppEntry extends HookConsumerWidget {
|
||||
!creating.value &&
|
||||
!inlineSetup,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) back();
|
||||
if (!didPop &&
|
||||
(existingUser.value ||
|
||||
creating.value ||
|
||||
enteringCommunityLink.value ||
|
||||
inlineSetup)) {
|
||||
back();
|
||||
}
|
||||
},
|
||||
child: PairingPage(
|
||||
connectionLanding: true,
|
||||
showConnectionOptions:
|
||||
!HostedEnvironment.enabled || addingCommunity || connected,
|
||||
addingCommunity: addingCommunity,
|
||||
existingUser: existingUser.value,
|
||||
enteringCommunityLink: enteringCommunityLink.value,
|
||||
@@ -143,6 +152,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
|
||||
? null
|
||||
: () => unawaited(flow.signInAccount()),
|
||||
accountStatus: status,
|
||||
builderlabBusy: busy,
|
||||
setupContent: flow != null && inlineSetup
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -158,7 +168,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
|
||||
? 'Connecting…'
|
||||
: connected
|
||||
? 'Continue with Builderlab'
|
||||
: 'Connect to Builderlab',
|
||||
: 'Sign in with builderlab',
|
||||
onWelcomeBack: back,
|
||||
onAlreadyUseBuzz: () => existingUser.value = true,
|
||||
),
|
||||
|
||||
@@ -124,23 +124,35 @@ class HostedFlow extends ChangeNotifier {
|
||||
Completer<void>? _profileCompletion;
|
||||
Future<void> Function(String)? saveProfile;
|
||||
|
||||
Future<void> requestProfile(Future<void> Function(String) save) async {
|
||||
Future<void> Function(String, Future<String> Function()?)? saveProfileDraft;
|
||||
|
||||
Future<void> requestProfile(
|
||||
Future<void> Function(String) save, {
|
||||
Future<void> Function(String, Future<String> Function()?)? saveDraft,
|
||||
}) async {
|
||||
if (_disposed) return;
|
||||
final completion = Completer<void>();
|
||||
_profileCompletion = completion;
|
||||
saveProfile = (name) async {
|
||||
saveProfileDraft = (name, avatar) async {
|
||||
if (!identical(_profileCompletion, completion) ||
|
||||
completion.isCompleted) {
|
||||
throw AccountFailure.unavailable;
|
||||
}
|
||||
await save(name);
|
||||
if (saveDraft != null) {
|
||||
await saveDraft(name, avatar);
|
||||
} else {
|
||||
await save(name);
|
||||
}
|
||||
if (identical(_profileCompletion, completion)) finishProfile();
|
||||
};
|
||||
final saveCurrentDraft = saveProfileDraft!;
|
||||
saveProfile = (name) => saveCurrentDraft(name, null);
|
||||
_set(HostedFlowStage.profile);
|
||||
await completion.future;
|
||||
if (identical(_profileCompletion, completion)) {
|
||||
_profileCompletion = null;
|
||||
saveProfile = null;
|
||||
saveProfileDraft = null;
|
||||
_set(HostedFlowStage.working);
|
||||
}
|
||||
}
|
||||
@@ -154,6 +166,7 @@ class HostedFlow extends ChangeNotifier {
|
||||
final completion = _profileCompletion;
|
||||
_profileCompletion = null;
|
||||
saveProfile = null;
|
||||
saveProfileDraft = null;
|
||||
if (completion != null && !completion.isCompleted) completion.complete();
|
||||
}
|
||||
|
||||
|
||||
@@ -153,10 +153,23 @@ Future<void> offerHostedProfile(
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
await flow.requestProfile((name) async {
|
||||
if (!current()) throw AccountFailure.unavailable;
|
||||
await ref.read(profileProvider.notifier).updateDisplayName(name);
|
||||
});
|
||||
await flow.requestProfile(
|
||||
(name) async {
|
||||
if (!current()) throw AccountFailure.unavailable;
|
||||
await ref.read(profileProvider.notifier).updateDisplayName(name);
|
||||
},
|
||||
saveDraft: (name, prepareAvatar) async {
|
||||
if (!current()) throw AccountFailure.unavailable;
|
||||
final writer = ref.read(profileProvider.notifier);
|
||||
final avatar = await prepareAvatar?.call();
|
||||
if (!current()) throw AccountFailure.unavailable;
|
||||
if (avatar == null) {
|
||||
await writer.updateDisplayName(name);
|
||||
} else {
|
||||
await writer.updateSetup(name, avatar);
|
||||
}
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
subscription.close();
|
||||
}
|
||||
|
||||
129
mobile/lib/shared/identity_transfer/native_community_entry.dart
Normal file
129
mobile/lib/shared/identity_transfer/native_community_entry.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
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,
|
||||
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,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
@@ -23,14 +25,32 @@ Future<bool> showNsecImportSheet(
|
||||
|
||||
/// Transient secret input; only public preview and safe errors are displayed.
|
||||
class NsecImportSheet extends HookWidget {
|
||||
const NsecImportSheet({super.key, required this.onImport, this.pickFile});
|
||||
const NsecImportSheet({
|
||||
super.key,
|
||||
required this.onImport,
|
||||
this.pickFile,
|
||||
this.autofocus = true,
|
||||
});
|
||||
|
||||
final Future<void> Function(String nsec) onImport;
|
||||
final Future<String?> Function()? pickFile;
|
||||
|
||||
/// Allows a containing sheet to finish expanding before opening the keyboard.
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final input = useTextEditingController();
|
||||
final focus = useFocusNode();
|
||||
final autofocusRef = useRef(autofocus)..value = autofocus;
|
||||
useEffect(() {
|
||||
if (autofocus && defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted && autofocusRef.value) focus.requestFocus();
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [autofocus]);
|
||||
final publicKey = useState<String?>(null);
|
||||
final error = useState<String?>(null);
|
||||
final busy = useState(false);
|
||||
@@ -93,27 +113,51 @@ class NsecImportSheet extends HookWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Your nsec is your private key. Only import a key you trust. It is never copied to the clipboard.',
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
TextField(
|
||||
controller: input,
|
||||
maxLength: 1024,
|
||||
enabled: !busy.value,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Private key (nsec)',
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS)
|
||||
CupertinoTextField(
|
||||
controller: input,
|
||||
placeholder: 'Private key (nsec)',
|
||||
padding: const EdgeInsets.all(Grid.gutter),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.secondarySystemGroupedBackground
|
||||
.resolveFrom(context),
|
||||
borderRadius: BorderRadius.circular(Radii.container),
|
||||
),
|
||||
focusNode: focus,
|
||||
maxLength: 1024,
|
||||
enabled: !busy.value,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
onChanged: (_) {
|
||||
publicKey.value = null;
|
||||
error.value = null;
|
||||
},
|
||||
)
|
||||
else
|
||||
TextField(
|
||||
controller: input,
|
||||
maxLength: 1024,
|
||||
enabled: !busy.value,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Private key (nsec)',
|
||||
),
|
||||
onChanged: (_) {
|
||||
publicKey.value = null;
|
||||
error.value = null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
onChanged: (_) {
|
||||
publicKey.value = null;
|
||||
error.value = null;
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
onPressed: busy.value ? null : chooseFile,
|
||||
child: const Text('Choose nsec file'),
|
||||
),
|
||||
@@ -127,7 +171,12 @@ class NsecImportSheet extends HookWidget {
|
||||
],
|
||||
if (error.value != null)
|
||||
Text(error.value!, style: TextStyle(color: context.colors.error)),
|
||||
const SizedBox(height: Grid.xs),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
onPressed: busy.value ? null : submit,
|
||||
child: Text(
|
||||
busy.value
|
||||
|
||||
@@ -13,6 +13,9 @@ abstract final class BuzzIcons {
|
||||
|
||||
static const edit = IconData(0xea98, fontFamily: 'BuzzTabler');
|
||||
|
||||
/// Tabler `qrcode`.
|
||||
static const qrcode = IconData(0xeb11, fontFamily: 'BuzzTabler');
|
||||
|
||||
/// Tabler `text-size`.
|
||||
static const aLargeSmall = IconData(0xf2b1, fontFamily: 'BuzzTabler');
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class BuzzSheetHeader extends StatelessWidget {
|
||||
this.titleKey,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onClose,
|
||||
this.showDragHandle = false,
|
||||
});
|
||||
|
||||
@@ -23,6 +24,9 @@ class BuzzSheetHeader extends StatelessWidget {
|
||||
final Key? titleKey;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
|
||||
/// Overrides route dismissal while retaining the standard close control.
|
||||
final VoidCallback? onClose;
|
||||
final bool showDragHandle;
|
||||
|
||||
@override
|
||||
@@ -61,7 +65,7 @@ class BuzzSheetHeader extends StatelessWidget {
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: trailing ?? const _SheetCloseButton(),
|
||||
child: trailing ?? _SheetCloseButton(onClose: onClose),
|
||||
),
|
||||
if (leading case final leading?)
|
||||
Align(alignment: Alignment.bottomLeft, child: leading),
|
||||
@@ -73,13 +77,18 @@ class BuzzSheetHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _SheetCloseButton extends StatelessWidget {
|
||||
const _SheetCloseButton();
|
||||
const _SheetCloseButton({this.onClose});
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void closeSheet() {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
Navigator.of(context).pop();
|
||||
if (onClose != null) {
|
||||
onClose!();
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
if (Theme.of(context).platform == TargetPlatform.iOS) {
|
||||
|
||||
@@ -13,6 +13,7 @@ class BuzzTitledSheetLayout extends HookWidget {
|
||||
required this.child,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onClose,
|
||||
this.titleKey,
|
||||
this.showDragHandle = false,
|
||||
this.surfaceColor,
|
||||
@@ -22,6 +23,9 @@ class BuzzTitledSheetLayout extends HookWidget {
|
||||
final Widget child;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
|
||||
/// Overrides the header close action; null dismisses the current route.
|
||||
final VoidCallback? onClose;
|
||||
final Key? titleKey;
|
||||
final bool showDragHandle;
|
||||
final Color? surfaceColor;
|
||||
@@ -79,6 +83,7 @@ class BuzzTitledSheetLayout extends HookWidget {
|
||||
titleKey: titleKey,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
onClose: onClose,
|
||||
showDragHandle: showDragHandle,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,6 +23,7 @@ const buzzModalAnimationStyle = AnimationStyle(
|
||||
/// uses native concentric corners when available and paints a requested drag
|
||||
/// handle inside the shared header so its spacing is consistent on every
|
||||
/// platform.
|
||||
/// Set [padBottom] to false when the content owns its bottom edge spacing.
|
||||
Future<T?> showBuzzModalBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
@@ -42,6 +43,7 @@ Future<T?> showBuzzModalBottomSheet<T>({
|
||||
bool? showDragHandle,
|
||||
bool showCloseButton = true,
|
||||
bool useSafeArea = false,
|
||||
bool padBottom = true,
|
||||
RouteSettings? routeSettings,
|
||||
AnimationController? transitionAnimationController,
|
||||
Offset? anchorPoint,
|
||||
@@ -83,8 +85,9 @@ Future<T?> showBuzzModalBottomSheet<T>({
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: padBottom,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.half),
|
||||
padding: EdgeInsets.only(bottom: padBottom ? Grid.half : 0),
|
||||
child: Builder(builder: builder),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user