Files
ReelShort/lib/pages/auth/phone_auth_sheet.dart
T

359 lines
11 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/theme/app_colors.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;
Timer? _countdown;
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');
}
@override
void dispose() {
_countdown?.cancel();
_phone.dispose();
_code.dispose();
_phoneFocus.dispose();
_codeFocus.dispose();
super.dispose();
}
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>();
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) {
if (mounted) setState(() => _error = error.message);
} catch (_) {
if (mounted) setState(() => _error = l10n.authNetworkError);
} finally {
if (mounted) setState(() => _sending = false);
}
}
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>();
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) {
if (mounted) setState(() => _error = error.message);
} catch (_) {
if (mounted) setState(() => _error = l10n.authNetworkError);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _switchMode() {
setState(() {
_signUp = !_signUp;
_error = null;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final bottom = MediaQuery.viewInsetsOf(context).bottom;
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(
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),
),
);
}