mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 12:54:07 +00:00
876 lines
29 KiB
Dart
876 lines
29 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:cryptography_plus/cryptography_plus.dart'
|
|
show Hkdf, Hmac, Mac, SecretBox, SecretKey, Xchacha20;
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:hashlib/random.dart';
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:twonly/core/bridge/wrapper.dart' show RustUtils;
|
|
import 'package:twonly/locator.dart';
|
|
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
|
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
|
import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery.pb.dart';
|
|
import 'package:twonly/src/services/backup.service.dart';
|
|
import 'package:twonly/src/services/passwordless_recovery.service.dart';
|
|
import 'package:twonly/src/utils/keyvalue.dart';
|
|
import 'package:twonly/src/utils/log.dart';
|
|
import 'package:twonly/src/utils/misc.dart';
|
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
|
import 'package:twonly/src/visual/elements/contact_chip.element.dart';
|
|
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
|
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
|
import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart';
|
|
import 'package:twonly/src/visual/views/onboarding/recovery_progress.view.dart';
|
|
|
|
class RecoverPasswordless extends StatefulWidget {
|
|
const RecoverPasswordless({this.initialEmailToken, super.key});
|
|
|
|
final String? initialEmailToken;
|
|
|
|
@override
|
|
State<RecoverPasswordless> createState() => _RecoverPasswordlessState();
|
|
}
|
|
|
|
class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
|
bool _isLoading = true;
|
|
bool _notificationsEnabled = false;
|
|
String _shareUrl = '';
|
|
|
|
OnboardingState? _onboardingState;
|
|
Timer? _pollTimer;
|
|
|
|
SharedSecretData? _reconstructedSecret;
|
|
bool _isReconstructing = false;
|
|
String? _reconstructionError;
|
|
|
|
bool _isRecovering = false;
|
|
final TextEditingController _secondFactorController = TextEditingController();
|
|
|
|
StreamSubscription<String>? _emailTokenSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final token =
|
|
widget.initialEmailToken ?? PasswordlessRecoveryService.lastEmailToken;
|
|
_secondFactorController.text = token ?? '';
|
|
|
|
_emailTokenSubscription = PasswordlessRecoveryService
|
|
.onEmailTokenReceived
|
|
.stream
|
|
.listen((token) async {
|
|
if (mounted) {
|
|
setState(() {
|
|
_secondFactorController.text = token;
|
|
});
|
|
}
|
|
});
|
|
_initAsync(token);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_emailTokenSubscription?.cancel();
|
|
_pollTimer?.cancel();
|
|
_secondFactorController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _initAsync(String? initialToken) async {
|
|
try {
|
|
// 1. Load OnboardingState
|
|
final state = await KeyValueStore.getModel<OnboardingState>(
|
|
KeyValueKeys.onboardingState,
|
|
);
|
|
|
|
if (initialToken != null && !state.emailRecoveryRequested) {
|
|
state.emailRecoveryRequested = true;
|
|
await KeyValueStore.update<OnboardingState>(
|
|
key: KeyValueKeys.onboardingState,
|
|
update: (s) => s.emailRecoveryRequested = true,
|
|
);
|
|
}
|
|
|
|
// 2. Generate fields if they are missing
|
|
if (state.notificationId == null) {
|
|
state
|
|
..notificationId = uuid.v4()
|
|
..downloadAuthToken = getRandomUint8List(32)
|
|
..encryptionKey = getRandomUint8List(32);
|
|
await KeyValueStore.update<OnboardingState>(
|
|
key: KeyValueKeys.onboardingState,
|
|
update: (s) {
|
|
s
|
|
..notificationId = state.notificationId
|
|
..downloadAuthToken = state.downloadAuthToken
|
|
..encryptionKey = state.encryptionKey;
|
|
},
|
|
);
|
|
}
|
|
|
|
// 3. Construct URL
|
|
final keyBytes = state.encryptionKey!;
|
|
final base64Key = base64Url.encode(keyBytes).replaceAll('=', '');
|
|
_shareUrl = 'https://me.twonly.eu/r/#${state.notificationId}/$base64Key';
|
|
if (!kReleaseMode) Log.info(_shareUrl);
|
|
|
|
// 4. Check FCM Notification Settings
|
|
final settings = await FirebaseMessaging.instance
|
|
.getNotificationSettings();
|
|
_notificationsEnabled =
|
|
settings.authorizationStatus == AuthorizationStatus.authorized ||
|
|
settings.authorizationStatus == AuthorizationStatus.provisional;
|
|
|
|
// 5. Register with the server if not registered yet
|
|
if (!state.serverRegistered) {
|
|
await _registerPasswordlessNotification(state);
|
|
}
|
|
|
|
_onboardingState = state;
|
|
|
|
// 6. If already registered, do an immediate check and start the poll timer
|
|
if (state.serverRegistered) _startPollTimer();
|
|
|
|
_checkAndReconstruct();
|
|
} catch (e) {
|
|
Log.warn('Error during passwordless recovery initialization: $e');
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
void _startPollTimer() {
|
|
if (_pollTimer != null) return;
|
|
_pollTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
|
_pollForMessages();
|
|
});
|
|
unawaited(_pollForMessages());
|
|
}
|
|
|
|
Future<void> _pollForMessages() async {
|
|
if (_onboardingState == null) return;
|
|
final didUpdate =
|
|
await PasswordlessRecoveryService.checkAndStorePasswordlessMessages(
|
|
_onboardingState!,
|
|
);
|
|
// _onboardingState was changed in checkAndStorePasswordlessMessages
|
|
if (didUpdate && mounted) {
|
|
setState(() => ());
|
|
_checkAndReconstruct();
|
|
}
|
|
}
|
|
|
|
void _checkAndReconstruct() {
|
|
final state = _onboardingState;
|
|
if (state != null) {
|
|
final shares = state.receivedShares;
|
|
if (shares.isNotEmpty) {
|
|
final threshold = shares.first.threshold;
|
|
if (shares.length >= threshold &&
|
|
_reconstructedSecret == null &&
|
|
!_isReconstructing) {
|
|
unawaited(_reconstructSecret());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _reconstructSecret() async {
|
|
final state = _onboardingState;
|
|
if (state == null) return;
|
|
final shares = state.receivedShares;
|
|
if (shares.isEmpty) return;
|
|
final threshold = shares.first.threshold;
|
|
if (shares.length < threshold) return;
|
|
|
|
setState(() {
|
|
_isReconstructing = true;
|
|
_reconstructionError = null;
|
|
});
|
|
|
|
try {
|
|
final shareBytesList = shares
|
|
.map((s) => Uint8List.fromList(s.sharedSecretDataBytes))
|
|
.toList();
|
|
final secretBytes = await RustUtils.recoverSecret(
|
|
shares: shareBytesList,
|
|
threshold: threshold,
|
|
);
|
|
final sharedSecretData = SharedSecretData.fromBuffer(secretBytes);
|
|
if (mounted) {
|
|
setState(() {
|
|
_reconstructedSecret = sharedSecretData;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
Log.error('Failed to reconstruct secret: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_reconstructionError = e.toString();
|
|
});
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isReconstructing = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _recoverNow(List<ReceivedRecoveryShare> shares) async {
|
|
final reconstructed = _reconstructedSecret;
|
|
if (reconstructed == null) return;
|
|
|
|
setState(() {
|
|
_isRecovering = true;
|
|
});
|
|
|
|
try {
|
|
final userId = shares.first.myUserId;
|
|
Uint8List? serverKey;
|
|
|
|
if (!reconstructed.hasEmailHint() &&
|
|
reconstructed.hasServerKeyProtection()) {
|
|
final pin = _secondFactorController.text.trim();
|
|
if (pin.isEmpty) {
|
|
showSnackbar(
|
|
context,
|
|
context.lang.passwordlessRecoveryEnterPin,
|
|
);
|
|
setState(() {
|
|
_isRecovering = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Calculate pinProtectionKey via Hkdf
|
|
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
|
final pinKey = await hkdf.deriveKey(
|
|
secretKey: SecretKey(reconstructed.serverKeyProtection),
|
|
nonce: utf8.encode(pin),
|
|
);
|
|
|
|
// Fetch serverKey
|
|
final res = await rustApiResult(
|
|
RustApi.getServerKeyForPasswordlessRecovery(
|
|
userId: userId,
|
|
serverKeyProtection: const [],
|
|
pinUnlockToken: Uint8List.fromList(reconstructed.pinUnlockToken),
|
|
pinProtectionKey: Uint8List.fromList(await pinKey.extractBytes()),
|
|
),
|
|
);
|
|
|
|
if (res.isError) {
|
|
if (mounted) {
|
|
showSnackbar(
|
|
context,
|
|
context.lang.passwordlessRecoveryTestPinIncorrect,
|
|
);
|
|
}
|
|
setState(() {
|
|
_isRecovering = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
serverKey = Uint8List.fromList(res.value!);
|
|
} else if (reconstructed.hasEmailHint()) {
|
|
final state = _onboardingState;
|
|
if (state == null) return;
|
|
|
|
if (!state.emailRecoveryRequested) {
|
|
// Stage 1: Send Email
|
|
final email = _secondFactorController.text.trim();
|
|
if (email.isEmpty) {
|
|
showSnackbar(
|
|
context,
|
|
context.lang.passwordlessRecoveryEnterEmail,
|
|
);
|
|
setState(() {
|
|
_isRecovering = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Fetch serverKey (sends recovery email)
|
|
final res = await rustApiResult(
|
|
RustApi.getServerKeyForPasswordlessRecovery(
|
|
userId: userId,
|
|
email: email,
|
|
serverKeyProtection: reconstructed.serverKeyProtection,
|
|
),
|
|
);
|
|
|
|
if (res.isError) {
|
|
if (mounted) {
|
|
final isInternalError = res.error == ErrorCode.InternalError;
|
|
showSnackbar(
|
|
context,
|
|
isInternalError
|
|
? context.lang.passwordlessRecoveryNetworkError
|
|
: context.lang.passwordlessRecoveryInvalidEmail,
|
|
);
|
|
}
|
|
setState(() {
|
|
_isRecovering = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Success - server sent recovery email. Store in state
|
|
state.emailRecoveryRequested = true;
|
|
await KeyValueStore.update<OnboardingState>(
|
|
key: KeyValueKeys.onboardingState,
|
|
update: (s) => s.emailRecoveryRequested = true,
|
|
);
|
|
|
|
_secondFactorController.clear();
|
|
if (mounted) {
|
|
showSnackbar(
|
|
context,
|
|
context.lang.passwordlessRecoveryEmailSent,
|
|
level: SnackbarLevel.success,
|
|
);
|
|
}
|
|
setState(() {
|
|
_isRecovering = false;
|
|
});
|
|
return;
|
|
} else {
|
|
// Stage 2: Token verification
|
|
final token = _secondFactorController.text.trim();
|
|
if (token.isEmpty) {
|
|
if (mounted) {
|
|
showSnackbar(
|
|
context,
|
|
'Please enter the recovery token from your email.',
|
|
);
|
|
}
|
|
setState(() {
|
|
_isRecovering = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
serverKey = base64Url.decode(base64Url.normalize(token));
|
|
} catch (e) {
|
|
if (mounted) {
|
|
showSnackbar(
|
|
context,
|
|
'Invalid verification token format.',
|
|
);
|
|
}
|
|
setState(() {
|
|
_isRecovering = false;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Decrypt recoveryData using serverKey if present
|
|
List<int> recoveryDataBytes;
|
|
if (serverKey != null) {
|
|
final envelope = EncryptedEnvelope.fromBuffer(
|
|
reconstructed.recoveryData,
|
|
);
|
|
final secretBox = SecretBox(
|
|
envelope.encryptedData,
|
|
nonce: envelope.iv,
|
|
mac: Mac(envelope.mac),
|
|
);
|
|
final xchacha20 = Xchacha20.poly1305Aead();
|
|
recoveryDataBytes = await xchacha20.decrypt(
|
|
secretBox,
|
|
secretKey: SecretKey(serverKey),
|
|
);
|
|
} else {
|
|
recoveryDataBytes = reconstructed.recoveryData;
|
|
}
|
|
|
|
final recoveryData = RecoveryData.fromBuffer(recoveryDataBytes);
|
|
|
|
if (!mounted) return;
|
|
|
|
// Start full passwordless recovery. The progress view reports every
|
|
// step and restarts the app once the recovery succeeded.
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => RecoveryProgressView(
|
|
username: shares.first.myDisplayName,
|
|
steps: const [
|
|
RecoveryProgress.restoringIdentity,
|
|
RecoveryProgress.downloadingArchive,
|
|
RecoveryProgress.extractingData,
|
|
],
|
|
runRecovery: (onProgress) =>
|
|
BackupService.startPasswordlessBackupRecovery(
|
|
recoveryData.userId.toInt(),
|
|
shares.first.myDisplayName,
|
|
Uint8List.fromList(recoveryData.keyManager),
|
|
onProgress: onProgress,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
Log.error('Failed to recover passwordless: $e');
|
|
if (mounted) {
|
|
showSnackbar(
|
|
context,
|
|
'${context.lang.recoverErrorUnknown}: $e',
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isRecovering = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _registerPasswordlessNotification(OnboardingState state) async {
|
|
final fcmToken = await FirebaseMessaging.instance.getToken();
|
|
if (!mounted) return;
|
|
final res = await rustApiResult(
|
|
RustApi.registerPasswordlessNotification(
|
|
notificationId: state.notificationId!,
|
|
downloadAuthToken: state.downloadAuthToken!,
|
|
langCode: Localizations.localeOf(context).languageCode,
|
|
googleFcm: fcmToken,
|
|
),
|
|
);
|
|
if (res.isSuccess) {
|
|
state.serverRegistered = true;
|
|
await KeyValueStore.update<OnboardingState>(
|
|
key: KeyValueKeys.onboardingState,
|
|
update: (s) => s.serverRegistered = true,
|
|
);
|
|
_startPollTimer();
|
|
}
|
|
}
|
|
|
|
Future<void> _requestNotificationPermission() async {
|
|
try {
|
|
final settings = await FirebaseMessaging.instance.requestPermission();
|
|
if (settings.authorizationStatus == AuthorizationStatus.authorized ||
|
|
settings.authorizationStatus == AuthorizationStatus.provisional) {
|
|
if (mounted) setState(() => _notificationsEnabled = true);
|
|
final state = _onboardingState;
|
|
if (state != null) {
|
|
await _registerPasswordlessNotification(state);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
Log.error('Error requesting notification permission: $e');
|
|
}
|
|
}
|
|
|
|
Widget _buildProgressSection(
|
|
BuildContext context,
|
|
bool isDark,
|
|
List<ReceivedRecoveryShare> shares,
|
|
) {
|
|
final first = shares.first;
|
|
final threshold = first.threshold;
|
|
final thresholdReached = shares.length >= threshold;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(
|
|
child: Column(
|
|
children: [
|
|
AvatarIcon(
|
|
svg: first.myAvatarSvg != null
|
|
? RustApi.decodeAvatarSvg(
|
|
avatarSvgCompressed: first.myAvatarSvg!,
|
|
)
|
|
: null,
|
|
fontSize: 60,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
first.myDisplayName,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
context.lang.recoverPasswordlessSharesReceived(
|
|
shares.length,
|
|
threshold,
|
|
),
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: LinearProgressIndicator(
|
|
value: shares.length / threshold,
|
|
minHeight: 12,
|
|
backgroundColor: isDark
|
|
? Colors.white.withValues(alpha: 0.1)
|
|
: Colors.black.withValues(alpha: 0.08),
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
thresholdReached ? Colors.green : context.color.primary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.center,
|
|
children: shares.map((share) {
|
|
return ContactChip(
|
|
username: share.trustedFriendDisplayName,
|
|
avatarSvg: share.trustedFriendAvatarSvg,
|
|
);
|
|
}).toList(),
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
if (thresholdReached) ...[
|
|
if (_isReconstructing)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
)
|
|
else if (_reconstructionError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Text(
|
|
'Reconstruction failed: $_reconstructionError',
|
|
style: const TextStyle(color: Colors.red),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
)
|
|
else if (_reconstructedSecret != null) ...[
|
|
if (_reconstructedSecret!.hasServerKeyProtection() &&
|
|
!_reconstructedSecret!.hasEmailHint()) ...[
|
|
MyInput(
|
|
controller: _secondFactorController,
|
|
hintText: context.lang.passwordlessRecoveryMethodPinHint,
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
const SizedBox(height: 16),
|
|
] else if (_reconstructedSecret!.hasEmailHint()) ...[
|
|
if (_onboardingState != null &&
|
|
!_onboardingState!.emailRecoveryRequested) ...[
|
|
MyInput(
|
|
controller: _secondFactorController,
|
|
hintText: _reconstructedSecret!.emailHint,
|
|
prefixIcon: const Icon(Icons.email_rounded),
|
|
keyboardType: TextInputType.emailAddress,
|
|
),
|
|
const SizedBox(height: 16),
|
|
] else ...[
|
|
MyInput(
|
|
controller: _secondFactorController,
|
|
hintText: 'Enter recovery token',
|
|
prefixIcon: const Icon(Icons.key_rounded),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
],
|
|
MyButton(
|
|
onPressed: _isRecovering ? null : () => _recoverNow(shares),
|
|
child: _isRecovering
|
|
? const SizedBox(
|
|
height: 24,
|
|
width: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5,
|
|
color: Colors.black87,
|
|
),
|
|
)
|
|
: Text(
|
|
_reconstructedSecret!.hasEmailHint() &&
|
|
_onboardingState != null &&
|
|
!_onboardingState!.emailRecoveryRequested
|
|
? 'Send recovery email'
|
|
: context.lang.recoverPasswordlessRecoverNowBtn,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (_reconstructedSecret!.hasEmailHint() &&
|
|
_onboardingState != null &&
|
|
_onboardingState!.emailRecoveryRequested) ...[
|
|
MyButton(
|
|
onPressed: _isRecovering
|
|
? null
|
|
: () async {
|
|
final state = _onboardingState;
|
|
if (state == null) return;
|
|
setState(() {
|
|
state.emailRecoveryRequested = false;
|
|
_secondFactorController.clear();
|
|
});
|
|
await KeyValueStore.update<OnboardingState>(
|
|
key: KeyValueKeys.onboardingState,
|
|
update: (s) => s.emailRecoveryRequested = false,
|
|
);
|
|
},
|
|
variant: MyButtonVariant.secondary,
|
|
child: Text(context.lang.passwordlessRecoveryResendEmail),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
],
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDark = isDarkMode(context);
|
|
final shares = _onboardingState?.receivedShares ?? [];
|
|
final hasShares = shares.isNotEmpty;
|
|
final threshold = hasShares ? shares.first.threshold : 0;
|
|
final thresholdReached = hasShares && shares.length >= threshold;
|
|
|
|
if (_isLoading) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(context.lang.passwordlessRecoveryRecoverBtn),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
|
color: isDark ? Colors.white70 : Colors.black54,
|
|
iconSize: 20,
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
body: const Center(
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(context.lang.passwordlessRecoveryRecoverBtn),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
|
color: isDark ? Colors.white70 : Colors.black54,
|
|
iconSize: 20,
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (!hasShares) ...[
|
|
Text(
|
|
context.lang.recoverPasswordlessExplanation,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
|
|
if (hasShares) _buildProgressSection(context, isDark, shares),
|
|
|
|
if (!_notificationsEnabled && !hasShares) ...[
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.05)
|
|
: Colors.black.withValues(alpha: 0.04),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.08)
|
|
: Colors.black.withValues(alpha: 0.06),
|
|
),
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const AnimatedBellIcon(),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context
|
|
.lang
|
|
.recoverPasswordlessNotificationCardTitle,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
context
|
|
.lang
|
|
.recoverPasswordlessNotificationCardSubtitle,
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
const SizedBox(height: 12),
|
|
MyButton(
|
|
variant: MyButtonVariant.secondaryDense,
|
|
onPressed: _requestNotificationPermission,
|
|
child: Text(
|
|
context
|
|
.lang
|
|
.recoverPasswordlessNotificationCardBtn,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
],
|
|
|
|
if (!thresholdReached) ...[
|
|
if (!hasShares)
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.05)
|
|
: Colors.black.withValues(alpha: 0.04),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.08)
|
|
: Colors.black.withValues(alpha: 0.06),
|
|
),
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline_rounded,
|
|
color: isDark ? Colors.white70 : Colors.black54,
|
|
size: 22,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
context.lang.recoverPasswordlessQrInstructions,
|
|
style: const TextStyle(fontSize: 14, height: 1.4),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
if (!hasShares) const SizedBox(height: 20),
|
|
|
|
Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(24),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(
|
|
alpha: isDark ? 0.3 : 0.08,
|
|
),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
child: QrImageView.withQr(
|
|
qr: QrCode.fromData(
|
|
data: _shareUrl,
|
|
errorCorrectLevel: QrErrorCorrectLevel.M,
|
|
),
|
|
eyeStyle: const QrEyeStyle(
|
|
color: Colors.black,
|
|
borderRadius: 4,
|
|
),
|
|
dataModuleStyle: const QrDataModuleStyle(
|
|
color: Colors.black,
|
|
borderRadius: 4,
|
|
),
|
|
gapless: false,
|
|
size: 200,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
MyInput(
|
|
controller: TextEditingController(text: _shareUrl),
|
|
readOnly: true,
|
|
hintText: '',
|
|
prefixIcon: const Icon(Icons.link_rounded),
|
|
suffixIcon: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.copy_rounded),
|
|
tooltip: context.lang.recoverPasswordlessCopyBtn,
|
|
onPressed: () {
|
|
Clipboard.setData(
|
|
ClipboardData(text: _shareUrl),
|
|
);
|
|
showSnackbar(
|
|
context,
|
|
context.lang.recoverPasswordlessCopiedSnackbar,
|
|
level: SnackbarLevel.success,
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.share_rounded),
|
|
tooltip: context.lang.recoverPasswordlessShareBtn,
|
|
onPressed: () {
|
|
final params = ShareParams(
|
|
text: _shareUrl,
|
|
);
|
|
SharePlus.instance.share(params);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|