455 lines
14 KiB
Dart
455 lines
14 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../core/data/remote/api_client.dart';
|
|
import '../../core/data/services/auth_controller.dart';
|
|
import '../../core/platform/haptics.dart';
|
|
import '../../core/platform/keyboard_inset.dart';
|
|
import '../../core/theme/app_colors.dart';
|
|
import '../../core/widgets/centered_error_dialog.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
|
|
const double _fieldHeight = 52;
|
|
const double _fieldRadius = 14;
|
|
const double _sheetRadius = 28;
|
|
const double _buttonHeight = 52;
|
|
const int _resendSeconds = 60;
|
|
const int _phoneMinLength = 8;
|
|
const int _codeMinLength = 4;
|
|
|
|
/// 打开手机号 OTP 登录/注册表单;成功登录后返回 true。
|
|
Future<bool?> showPhoneAuthSheet(BuildContext context, {bool signUp = false}) {
|
|
return showModalBottomSheet<bool>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
barrierColor: Colors.black.withValues(alpha: 0.62),
|
|
builder: (_) => PhoneAuthSheet(initialSignUp: signUp),
|
|
);
|
|
}
|
|
|
|
class PhoneAuthSheet extends StatefulWidget {
|
|
const PhoneAuthSheet({super.key, this.initialSignUp = false});
|
|
|
|
final bool initialSignUp;
|
|
|
|
@override
|
|
State<PhoneAuthSheet> createState() => _PhoneAuthSheetState();
|
|
}
|
|
|
|
class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|
late final TextEditingController _phone;
|
|
late final TextEditingController _code;
|
|
late final FocusNode _phoneFocus;
|
|
late final FocusNode _codeFocus;
|
|
late final ScrollController _scrollController;
|
|
final _keyboardInset = KeyboardInsetNotifier.instance;
|
|
Timer? _countdown;
|
|
double _lastKeyboardInset = 0;
|
|
bool _signUp = false;
|
|
bool _sending = false;
|
|
bool _submitting = false;
|
|
int _remaining = 0;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_signUp = widget.initialSignUp;
|
|
_phone = TextEditingController();
|
|
_code = TextEditingController();
|
|
_phoneFocus = FocusNode(debugLabel: 'phone-auth-number');
|
|
_codeFocus = FocusNode(debugLabel: 'phone-auth-code');
|
|
_scrollController = ScrollController();
|
|
_phoneFocus.addListener(_onFocusChanged);
|
|
_codeFocus.addListener(_onFocusChanged);
|
|
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
|
unawaited(_keyboardInset.start());
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final inset = effectiveKeyboardInset(context, _keyboardInset);
|
|
if ((inset - _lastKeyboardInset).abs() >= 1) {
|
|
_lastKeyboardInset = inset;
|
|
_scheduleRevealFocusedField();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
|
_phoneFocus.removeListener(_onFocusChanged);
|
|
_codeFocus.removeListener(_onFocusChanged);
|
|
_countdown?.cancel();
|
|
_phone.dispose();
|
|
_code.dispose();
|
|
_phoneFocus.dispose();
|
|
_codeFocus.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onKeyboardInsetChanged() {
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
_scheduleRevealFocusedField();
|
|
}
|
|
|
|
void _onFocusChanged() {
|
|
if (_phoneFocus.hasFocus || _codeFocus.hasFocus) {
|
|
_scheduleRevealFocusedField();
|
|
}
|
|
}
|
|
|
|
void _scheduleRevealFocusedField() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _revealFocusedField();
|
|
});
|
|
}
|
|
|
|
void _revealFocusedField() {
|
|
if (!_scrollController.hasClients) return;
|
|
final focus = _codeFocus.hasFocus
|
|
? _codeFocus
|
|
: _phoneFocus.hasFocus
|
|
? _phoneFocus
|
|
: null;
|
|
final fieldContext = focus?.context;
|
|
final renderObject = fieldContext?.findRenderObject();
|
|
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
|
final keyboardInset = effectiveKeyboardInset(context, _keyboardInset);
|
|
if (keyboardInset <= 0) return;
|
|
final fieldBottom = renderObject
|
|
.localToGlobal(Offset(0, renderObject.size.height))
|
|
.dy;
|
|
final visibleBottom =
|
|
MediaQuery.sizeOf(context).height - keyboardInset - 24;
|
|
final overlap = fieldBottom - visibleBottom;
|
|
if (overlap <= 0) return;
|
|
final position = _scrollController.position;
|
|
final target = (position.pixels + overlap)
|
|
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
|
.toDouble();
|
|
if (target <= position.pixels + 0.5) return;
|
|
unawaited(
|
|
_scrollController.animateTo(
|
|
target,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOutCubic,
|
|
),
|
|
);
|
|
}
|
|
|
|
String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), '');
|
|
|
|
bool get _phoneValid => _target.length >= _phoneMinLength;
|
|
|
|
Future<void> _sendCode() async {
|
|
if (_sending || _remaining > 0) return;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
if (!_phoneValid) {
|
|
setState(() => _error = l10n.authPhoneInvalid);
|
|
return;
|
|
}
|
|
setState(() {
|
|
_sending = true;
|
|
_error = null;
|
|
});
|
|
final haptics = context.read<Haptics>();
|
|
final auth = context.read<AuthController>();
|
|
String? failureMessage;
|
|
await haptics.lightImpact();
|
|
try {
|
|
await auth.sendOtp(
|
|
target: _target,
|
|
scene: _signUp ? 'REGISTER' : 'LOGIN',
|
|
);
|
|
if (!mounted) return;
|
|
_startCountdown();
|
|
setState(() => _error = null);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.authCodeSent)));
|
|
_codeFocus.requestFocus();
|
|
} on ApiException catch (error) {
|
|
failureMessage = error.message;
|
|
} catch (_) {
|
|
failureMessage = l10n.authNetworkError;
|
|
} finally {
|
|
if (mounted) setState(() => _sending = false);
|
|
}
|
|
if (mounted && failureMessage != null) {
|
|
await _showFailure(failureMessage);
|
|
}
|
|
}
|
|
|
|
void _startCountdown() {
|
|
_countdown?.cancel();
|
|
_remaining = _resendSeconds;
|
|
_countdown = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) return timer.cancel();
|
|
setState(() => _remaining--);
|
|
if (_remaining <= 0) timer.cancel();
|
|
});
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (_submitting) return;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
if (!_phoneValid) {
|
|
setState(() => _error = l10n.authPhoneInvalid);
|
|
return;
|
|
}
|
|
if (_code.text.trim().length < _codeMinLength) {
|
|
setState(() => _error = l10n.authCodeRequired);
|
|
return;
|
|
}
|
|
setState(() {
|
|
_submitting = true;
|
|
_error = null;
|
|
});
|
|
final haptics = context.read<Haptics>();
|
|
final auth = context.read<AuthController>();
|
|
String? failureMessage;
|
|
await haptics.lightImpact();
|
|
try {
|
|
if (_signUp) {
|
|
await auth.registerWithPhone(target: _target, code: _code.text.trim());
|
|
} else {
|
|
await auth.signInWithPhone(target: _target, code: _code.text.trim());
|
|
}
|
|
if (mounted) Navigator.of(context).pop(true);
|
|
} on ApiException catch (error) {
|
|
failureMessage = error.message;
|
|
} catch (_) {
|
|
failureMessage = l10n.authNetworkError;
|
|
} finally {
|
|
if (mounted) setState(() => _submitting = false);
|
|
}
|
|
if (mounted && failureMessage != null) {
|
|
await _showFailure(failureMessage);
|
|
}
|
|
}
|
|
|
|
Future<void> _showFailure(String message) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return showCenteredErrorDialog(
|
|
context,
|
|
title: _signUp ? l10n.authRegisterFailed : l10n.authLoginFailed,
|
|
message: message,
|
|
);
|
|
}
|
|
|
|
void _switchMode() {
|
|
setState(() {
|
|
_signUp = !_signUp;
|
|
_error = null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final bottom = effectiveKeyboardInset(context, _keyboardInset);
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: bottom),
|
|
child: Container(
|
|
key: const Key('phone-auth-sheet'),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.card,
|
|
borderRadius: BorderRadius.vertical(
|
|
top: Radius.circular(_sheetRadius),
|
|
),
|
|
),
|
|
child: SingleChildScrollView(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.fromLTRB(22, 10, 22, 26),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(child: _grabber()),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_signUp ? l10n.authPhoneSignUp : l10n.authPhoneTitle,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
l10n.authSubtitle,
|
|
style: const TextStyle(color: AppColors.subtle, fontSize: 12),
|
|
),
|
|
const SizedBox(height: 20),
|
|
_phoneField(l10n),
|
|
const SizedBox(height: 12),
|
|
_codeField(l10n),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
_error!,
|
|
key: const Key('auth-phone-error'),
|
|
style: const TextStyle(color: AppColors.accent, fontSize: 12),
|
|
),
|
|
],
|
|
const SizedBox(height: 18),
|
|
_submitButton(l10n),
|
|
const SizedBox(height: 8),
|
|
Center(
|
|
child: TextButton(
|
|
key: const Key('auth-phone-switch'),
|
|
onPressed: _submitting ? null : _switchMode,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.blueSoft,
|
|
minimumSize: const Size(48, 48),
|
|
),
|
|
child: Text(
|
|
_signUp ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _grabber() => Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.subtle.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
);
|
|
|
|
Widget _phoneField(AppLocalizations l10n) => SizedBox(
|
|
height: _fieldHeight,
|
|
child: TextField(
|
|
key: const Key('auth-phone-number'),
|
|
controller: _phone,
|
|
focusNode: _phoneFocus,
|
|
enabled: !_submitting,
|
|
keyboardType: TextInputType.phone,
|
|
textInputAction: TextInputAction.next,
|
|
autofillHints: const [AutofillHints.telephoneNumber],
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9+\s-]')),
|
|
LengthLimitingTextInputFormatter(20),
|
|
],
|
|
onSubmitted: (_) => _codeFocus.requestFocus(),
|
|
onChanged: (_) => setState(() => _error = null),
|
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
|
|
decoration: _decoration(l10n.authPhoneLabel, l10n.authPhoneHint),
|
|
),
|
|
);
|
|
|
|
Widget _codeField(AppLocalizations l10n) => SizedBox(
|
|
height: _fieldHeight,
|
|
child: TextField(
|
|
key: const Key('auth-phone-code'),
|
|
controller: _code,
|
|
focusNode: _codeFocus,
|
|
enabled: !_submitting,
|
|
keyboardType: TextInputType.number,
|
|
textInputAction: TextInputAction.done,
|
|
autofillHints: const [AutofillHints.oneTimeCode],
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
LengthLimitingTextInputFormatter(6),
|
|
],
|
|
onSubmitted: (_) => _submit(),
|
|
onChanged: (_) => setState(() => _error = null),
|
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
|
|
decoration: _decoration(
|
|
l10n.authCodeLabel,
|
|
l10n.authCodeHint,
|
|
).copyWith(suffixIcon: _sendButton(l10n)),
|
|
),
|
|
);
|
|
|
|
Widget _sendButton(AppLocalizations l10n) => Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: TextButton(
|
|
key: const Key('auth-send-code'),
|
|
onPressed: _sending || _remaining > 0 ? null : _sendCode,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.blueSoft,
|
|
minimumSize: const Size(48, 48),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
),
|
|
child: _sending
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(
|
|
_remaining > 0
|
|
? l10n.authResendCode(_remaining)
|
|
: l10n.authSendCode,
|
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _submitButton(AppLocalizations l10n) => SizedBox(
|
|
width: double.infinity,
|
|
height: _buttonHeight,
|
|
child: FilledButton(
|
|
key: const Key('auth-phone-submit'),
|
|
onPressed: _submitting ? null : _submit,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.blue,
|
|
foregroundColor: AppColors.onPrimary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(_buttonHeight / 2),
|
|
),
|
|
),
|
|
child: _submitting
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppColors.onPrimary,
|
|
),
|
|
)
|
|
: Text(
|
|
_signUp ? l10n.authPhoneSignUpAction : l10n.authPhoneSignInAction,
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w800),
|
|
),
|
|
),
|
|
);
|
|
|
|
InputDecoration _decoration(String label, String hint) => InputDecoration(
|
|
labelText: label,
|
|
hintText: hint,
|
|
labelStyle: const TextStyle(color: AppColors.subtle),
|
|
hintStyle: const TextStyle(color: AppColors.subtle),
|
|
filled: true,
|
|
fillColor: AppColors.input,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(_fieldRadius),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(_fieldRadius),
|
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
|
),
|
|
);
|
|
}
|