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:
klopez4212
2026-10-22 10:27:38 -07:00
committed by GitHub
parent 8260b6ef4c
commit f7708d0694
45 changed files with 3749 additions and 172 deletions

View File

@@ -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,

View File

@@ -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,
),
);
}
}

View File

@@ -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,

View File

@@ -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'),
),
],
);
}

View File

@@ -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,

View File

@@ -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,
),
),
],
),
),
],
),
],
),
),
),
),
),
);
}

View File

@@ -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,
),
),
),
),
),
),
],
],
),
),
);
}
}

View File

@@ -236,7 +236,7 @@ class _SasVerificationView extends HookConsumerWidget {
TextButton(
style: context._onboardingSecondaryButtonStyle.copyWith(
minimumSize: const WidgetStatePropertyAll(
Size.fromHeight(48),
Size.fromHeight(52),
),
),
onPressed: showProtection

View File

@@ -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(

View File

@@ -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,
};

View File

@@ -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(

View 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),
),
),
),
),
);
}

View File

@@ -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()});

View 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: (_) {},
),
),
),
),
),
),
],
),
),
);
}
}

View 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;
}

View 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'),
),
],
),
),
);
}
}

View File

@@ -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 ||

View File

@@ -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,
),

View File

@@ -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();
}

View File

@@ -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();
}

View 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;
}
}
}

View File

@@ -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

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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,
),
),

View File

@@ -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),
),
),