703 lines
20 KiB
Dart
703 lines
20 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:flutter_remix/flutter_remix.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
import 'package:url_launcher/url_launcher_string.dart';
|
|
|
|
import '../../../common/config/app_h5_urls.dart';
|
|
import '../../../common/network/api_error_message.dart';
|
|
import '../../../common/router/app_router.dart';
|
|
import '../../../common/theme/spacing.dart';
|
|
import '../../../common/widgets/circle_back_button.dart';
|
|
import '../application/auth_controller.dart';
|
|
|
|
class LoginPage extends HookConsumerWidget {
|
|
const LoginPage({super.key, this.next});
|
|
final String? next;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
final phone = useTextEditingController();
|
|
final codeCtrl = useTextEditingController();
|
|
final codeFocus = useFocusNode();
|
|
final onCodeStep = useState(false);
|
|
final agreed = useState(false);
|
|
final sending = useState(false);
|
|
final verifying = useState(false);
|
|
final countdown = useState(0);
|
|
final error = useState<String?>(null);
|
|
final timer = useRef<Timer?>(null);
|
|
useListenable(phone);
|
|
useListenable(codeCtrl);
|
|
useEffect(
|
|
() =>
|
|
() => timer.value?.cancel(),
|
|
const [],
|
|
);
|
|
|
|
String phoneDigits() => phone.text.replaceAll(RegExp(r'\D'), '');
|
|
|
|
Future<void> returnAfterLogin() async {
|
|
final target = next?.trim();
|
|
final router = GoRouter.of(context);
|
|
if (target == null || target.isEmpty) {
|
|
router.go(AppRoutes.profile);
|
|
return;
|
|
}
|
|
router.go(target);
|
|
}
|
|
|
|
void startCountdown() {
|
|
countdown.value = 60;
|
|
timer.value?.cancel();
|
|
timer.value = Timer.periodic(const Duration(seconds: 1), (t) {
|
|
if (countdown.value <= 1) {
|
|
t.cancel();
|
|
countdown.value = 0;
|
|
} else {
|
|
countdown.value--;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> doSend() async {
|
|
if (sending.value || countdown.value > 0) return;
|
|
if (phoneDigits().length != 11) {
|
|
error.value = '请输入正确的手机号';
|
|
return;
|
|
}
|
|
sending.value = true;
|
|
error.value = null;
|
|
try {
|
|
await ref.read(authProvider.notifier).sendCode(phoneDigits());
|
|
startCountdown();
|
|
onCodeStep.value = true;
|
|
codeFocus.requestFocus();
|
|
} catch (e) {
|
|
error.value = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试');
|
|
} finally {
|
|
sending.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> doVerify() async {
|
|
if (verifying.value) return;
|
|
final code = codeCtrl.text.trim();
|
|
if (code.length != 6) {
|
|
error.value = '请输入 6 位验证码';
|
|
return;
|
|
}
|
|
verifying.value = true;
|
|
error.value = null;
|
|
try {
|
|
await ref.read(authProvider.notifier).verifyCode(phoneDigits(), code);
|
|
if (!context.mounted) return;
|
|
await returnAfterLogin();
|
|
} catch (e) {
|
|
error.value = kratosDisplayMessage(e, fallback: '验证失败,请重试');
|
|
} finally {
|
|
verifying.value = false;
|
|
}
|
|
}
|
|
|
|
void onBack() {
|
|
if (onCodeStep.value) {
|
|
onCodeStep.value = false;
|
|
error.value = null;
|
|
} else if (context.canPop()) {
|
|
context.pop();
|
|
} else {
|
|
context.go(AppRoutes.profile);
|
|
}
|
|
}
|
|
|
|
void showPrivacySheet(VoidCallback onAgreed) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: cs.card,
|
|
barrierColor: Colors.black.withValues(alpha: 0.6),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(
|
|
top: Radius.circular(Spacing.radiusCard),
|
|
),
|
|
),
|
|
builder: (ctx) => SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(22, 28, 22, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'请阅读并同意以下条款',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.foreground,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text.rich(
|
|
textAlign: TextAlign.center,
|
|
_policySpan(context, cs, fontSize: 12.5),
|
|
),
|
|
const SizedBox(height: 22),
|
|
_PrimaryButton(
|
|
label: '同意并继续',
|
|
enabled: true,
|
|
onTap: () {
|
|
Navigator.of(ctx).pop();
|
|
onAgreed();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void onSendTap() {
|
|
if (sending.value || countdown.value > 0) return;
|
|
if (phoneDigits().length != 11) {
|
|
error.value = '请输入正确的手机号';
|
|
return;
|
|
}
|
|
error.value = null;
|
|
if (!agreed.value) {
|
|
showPrivacySheet(() {
|
|
agreed.value = true;
|
|
doSend();
|
|
});
|
|
return;
|
|
}
|
|
doSend();
|
|
}
|
|
|
|
final phoneReady = phoneDigits().length == 11;
|
|
final codeReady = codeCtrl.text.trim().length == 6;
|
|
|
|
return Scaffold(
|
|
backgroundColor: cs.background,
|
|
appBar: AppBar(
|
|
backgroundColor: cs.background,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
automaticallyImplyLeading: false,
|
|
toolbarHeight: 46,
|
|
titleSpacing: 0,
|
|
title: CircleBackButton(onTap: onBack),
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(22, 8, 22, 28),
|
|
child: onCodeStep.value
|
|
? _CodeStep(
|
|
cs: cs,
|
|
phone: phoneDigits(),
|
|
codeCtrl: codeCtrl,
|
|
codeFocus: codeFocus,
|
|
countdown: countdown.value,
|
|
verifying: verifying.value,
|
|
codeReady: codeReady,
|
|
error: error.value,
|
|
onVerify: doVerify,
|
|
onResend: doSend,
|
|
)
|
|
: _PhoneStep(
|
|
cs: cs,
|
|
phone: phone,
|
|
sending: sending.value,
|
|
countdown: countdown.value,
|
|
phoneReady: phoneReady,
|
|
error: error.value,
|
|
agreed: agreed.value,
|
|
onToggleAgreed: () => agreed.value = !agreed.value,
|
|
onSend: onSendTap,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PhoneStep extends StatelessWidget {
|
|
const _PhoneStep({
|
|
required this.cs,
|
|
required this.phone,
|
|
required this.sending,
|
|
required this.countdown,
|
|
required this.phoneReady,
|
|
required this.agreed,
|
|
required this.onToggleAgreed,
|
|
required this.error,
|
|
required this.onSend,
|
|
});
|
|
|
|
final ShadColorScheme cs;
|
|
final TextEditingController phone;
|
|
final bool sending;
|
|
final int countdown;
|
|
final bool phoneReady;
|
|
final bool agreed;
|
|
final VoidCallback onToggleAgreed;
|
|
final String? error;
|
|
final VoidCallback onSend;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SheetTitle('手机号登录', cs),
|
|
const SizedBox(height: 8),
|
|
_SheetSub('未注册的手机号验证后将自动创建金值账号。', cs),
|
|
const SizedBox(height: 18),
|
|
Row(
|
|
children: [
|
|
SizedBox(
|
|
height: 50,
|
|
child: Center(
|
|
child: Text(
|
|
'+86',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.foreground,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _BoxedField(controller: phone, hint: '请输入手机号', cs: cs),
|
|
),
|
|
],
|
|
),
|
|
if (error != null) ...[
|
|
const SizedBox(height: 10),
|
|
_ErrorText(error!, cs),
|
|
],
|
|
const SizedBox(height: 18),
|
|
_PrimaryButton(
|
|
label: countdown > 0 ? '$countdown s 后可重新发送' : '获取验证码',
|
|
enabled: !sending && countdown == 0,
|
|
loading: sending,
|
|
dim: !phoneReady,
|
|
onTap: onSend,
|
|
),
|
|
const SizedBox(height: 18),
|
|
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CodeStep extends StatelessWidget {
|
|
const _CodeStep({
|
|
required this.cs,
|
|
required this.phone,
|
|
required this.codeCtrl,
|
|
required this.codeFocus,
|
|
required this.countdown,
|
|
required this.verifying,
|
|
required this.codeReady,
|
|
required this.error,
|
|
required this.onVerify,
|
|
required this.onResend,
|
|
});
|
|
|
|
final ShadColorScheme cs;
|
|
final String phone;
|
|
final TextEditingController codeCtrl;
|
|
final FocusNode codeFocus;
|
|
final int countdown;
|
|
final bool verifying;
|
|
final bool codeReady;
|
|
final String? error;
|
|
final VoidCallback onVerify;
|
|
final VoidCallback onResend;
|
|
|
|
String get _masked => phone.length == 11
|
|
? '${phone.substring(0, 3)}****${phone.substring(7)}'
|
|
: phone;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SheetTitle('输入验证码', cs),
|
|
const SizedBox(height: 8),
|
|
_SheetSub('验证码已发送至 +86 $_masked', cs),
|
|
const SizedBox(height: 20),
|
|
_OtpInput(controller: codeCtrl, focusNode: codeFocus, cs: cs),
|
|
if (error != null) ...[
|
|
const SizedBox(height: 12),
|
|
_ErrorText(error!, cs),
|
|
],
|
|
const SizedBox(height: 18),
|
|
_PrimaryButton(
|
|
label: '验证并登录',
|
|
enabled: !verifying,
|
|
loading: verifying,
|
|
dim: !codeReady,
|
|
onTap: onVerify,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Center(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: countdown > 0 ? null : onResend,
|
|
child: Text(
|
|
countdown > 0 ? '$countdown s 后可重新发送' : '重新发送',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: countdown > 0 ? cs.mutedForeground : cs.primary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SheetTitle extends StatelessWidget {
|
|
const _SheetTitle(this.text, this.cs);
|
|
final String text;
|
|
final ShadColorScheme cs;
|
|
@override
|
|
Widget build(BuildContext context) => Text(
|
|
text,
|
|
style: TextStyle(
|
|
fontSize: 19,
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.foreground,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _SheetSub extends StatelessWidget {
|
|
const _SheetSub(this.text, this.cs);
|
|
final String text;
|
|
final ShadColorScheme cs;
|
|
@override
|
|
Widget build(BuildContext context) => Text(
|
|
text,
|
|
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
|
);
|
|
}
|
|
|
|
class _ErrorText extends StatelessWidget {
|
|
const _ErrorText(this.text, this.cs);
|
|
final String text;
|
|
final ShadColorScheme cs;
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
Text(text, style: TextStyle(fontSize: 13, color: cs.destructive));
|
|
}
|
|
|
|
class _BoxedField extends StatelessWidget {
|
|
const _BoxedField({
|
|
required this.controller,
|
|
required this.hint,
|
|
required this.cs,
|
|
});
|
|
final TextEditingController controller;
|
|
final String hint;
|
|
final ShadColorScheme cs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
height: 50,
|
|
alignment: Alignment.center,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
decoration: BoxDecoration(
|
|
color: cs.background,
|
|
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
|
|
border: Border.all(color: cs.input, width: 1),
|
|
),
|
|
child: TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.phone,
|
|
inputFormatters: [_PhoneNumberFormatter()],
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: cs.foreground,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
cursorColor: cs.primary,
|
|
decoration: InputDecoration(
|
|
isCollapsed: true,
|
|
border: InputBorder.none,
|
|
hintText: hint,
|
|
hintStyle: TextStyle(fontSize: 15, color: cs.mutedForeground),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PhoneNumberFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue,
|
|
TextEditingValue newValue,
|
|
) {
|
|
var d = newValue.text.replaceAll(RegExp(r'\D'), '');
|
|
if (d.length > 11) d = d.substring(0, 11);
|
|
final b = StringBuffer();
|
|
for (var i = 0; i < d.length; i++) {
|
|
if (i == 3 || i == 7) b.write(' ');
|
|
b.write(d[i]);
|
|
}
|
|
final s = b.toString();
|
|
return TextEditingValue(
|
|
text: s,
|
|
selection: TextSelection.collapsed(offset: s.length),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OtpInput extends StatelessWidget {
|
|
const _OtpInput({
|
|
required this.controller,
|
|
required this.focusNode,
|
|
required this.cs,
|
|
});
|
|
final TextEditingController controller;
|
|
final FocusNode focusNode;
|
|
final ShadColorScheme cs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final code = controller.text;
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: focusNode.requestFocus,
|
|
child: SizedBox(
|
|
height: 54,
|
|
child: Stack(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
for (var i = 0; i < 6; i++) ...[
|
|
if (i > 0) const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _OtpCell(
|
|
digit: i < code.length ? code[i] : '',
|
|
filled: i < code.length,
|
|
cs: cs,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
Positioned.fill(
|
|
child: TextField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 6,
|
|
showCursor: false,
|
|
autofocus: true,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
style: const TextStyle(color: Colors.transparent),
|
|
decoration: const InputDecoration(
|
|
counterText: '',
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OtpCell extends StatelessWidget {
|
|
const _OtpCell({required this.digit, required this.filled, required this.cs});
|
|
final String digit;
|
|
final bool filled;
|
|
final ShadColorScheme cs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: cs.background,
|
|
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
|
|
border: Border.all(color: filled ? cs.foreground : cs.input, width: 1),
|
|
),
|
|
child: Text(
|
|
digit,
|
|
style: TextStyle(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.foreground,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AgreeRow extends StatelessWidget {
|
|
const _AgreeRow({
|
|
required this.agreed,
|
|
required this.onToggle,
|
|
required this.cs,
|
|
});
|
|
final bool agreed;
|
|
final VoidCallback onToggle;
|
|
final ShadColorScheme cs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onToggle,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 18,
|
|
height: 18,
|
|
margin: const EdgeInsets.only(top: 1),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(Spacing.radiusBadge),
|
|
border: Border.all(
|
|
color: agreed ? cs.primary : cs.border,
|
|
width: 1.2,
|
|
),
|
|
color: agreed ? cs.primary : Colors.transparent,
|
|
),
|
|
child: agreed
|
|
? Icon(
|
|
FlutterRemix.check_line,
|
|
size: 13,
|
|
color: cs.primaryForeground,
|
|
)
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text.rich(_policySpan(context, cs))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
TextSpan _policySpan(
|
|
BuildContext context,
|
|
ShadColorScheme cs, {
|
|
double fontSize = 11.5,
|
|
}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final privacyUrl = AppH5UrlsHelper.buildUrlWithNight(
|
|
AppH5Urls.privacyUrl,
|
|
isDark,
|
|
);
|
|
final protocolUrl = AppH5UrlsHelper.buildUrlWithNight(
|
|
AppH5Urls.userProtocolUrl,
|
|
isDark,
|
|
);
|
|
|
|
TextSpan link(String text, String url) => TextSpan(
|
|
text: text,
|
|
style: TextStyle(
|
|
color: cs.foreground,
|
|
fontSize: fontSize,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: cs.foreground,
|
|
),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () => launchUrlString(
|
|
AppH5UrlsHelper.withCacheBuster(url),
|
|
mode: LaunchMode.inAppBrowserView,
|
|
),
|
|
);
|
|
|
|
return TextSpan(
|
|
style: TextStyle(
|
|
fontSize: fontSize,
|
|
height: 1.6,
|
|
color: cs.mutedForeground,
|
|
),
|
|
children: [
|
|
const TextSpan(text: '已阅读并同意 '),
|
|
link('《用户协议》', protocolUrl),
|
|
const TextSpan(text: ' 与 '),
|
|
link('《隐私政策》', privacyUrl),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _PrimaryButton extends StatelessWidget {
|
|
const _PrimaryButton({
|
|
required this.label,
|
|
required this.enabled,
|
|
required this.onTap,
|
|
this.loading = false,
|
|
this.dim = false,
|
|
});
|
|
final String label;
|
|
final bool enabled;
|
|
final bool loading;
|
|
final bool dim;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = ShadTheme.of(context).colorScheme;
|
|
final active = enabled && !dim;
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: GestureDetector(
|
|
onTap: enabled ? onTap : null,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: active ? cs.primary : cs.muted,
|
|
borderRadius: BorderRadius.circular(9999),
|
|
),
|
|
child: loading
|
|
? SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: active ? cs.primaryForeground : cs.mutedForeground,
|
|
),
|
|
)
|
|
: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: active ? cs.primaryForeground : cs.mutedForeground,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|