feat:参照研听的基础工程
This commit is contained in:
@@ -0,0 +1,916 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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 '../../../common/network/api_error_message.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/theme/app_text.dart';
|
||||
import '../../../common/theme/jinzhi_theme.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/widgets/circle_back_button.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../data/auth_models.dart';
|
||||
|
||||
class DeleteAccountPage extends ConsumerWidget {
|
||||
const DeleteAccountPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authAsync = ref.watch(authProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
|
||||
return authAsync.when(
|
||||
loading: () => Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: Center(child: Text('加载失败:$e')),
|
||||
),
|
||||
data: (authState) {
|
||||
if (authState is! LoggedInAuthState) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
context.go(
|
||||
'${AppRoutes.login}?next=${Uri.encodeComponent(AppRoutes.deleteAccount)}',
|
||||
);
|
||||
});
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.background,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
'注销账号',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
leading: CircleBackButton(
|
||||
onTap: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(AppRoutes.settings);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.page,
|
||||
8,
|
||||
Spacing.page,
|
||||
28,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _WarnCard(),
|
||||
const SizedBox(height: 28),
|
||||
Text(
|
||||
'注销前请确认',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const _DeleteCheckList(),
|
||||
const SizedBox(height: 28),
|
||||
_DangerButton(
|
||||
label: '开始注销',
|
||||
onPressed: () =>
|
||||
_showVerifyDialog(context, ref, authState.user.phone),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'注销后,账号资料与历史内容将无法恢复。',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 11.5,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showVerifyDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String phone,
|
||||
) async {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final completed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
backgroundColor: cs.card,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.5),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(Spacing.radiusCard),
|
||||
),
|
||||
),
|
||||
builder: (_) => _VerifySheet(phone: phone),
|
||||
);
|
||||
if (!context.mounted || completed != true) return;
|
||||
context.go(AppRoutes.deleteAccountCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
String _maskPhone(String phone) {
|
||||
if (phone.length < 7) return phone;
|
||||
return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
|
||||
}
|
||||
|
||||
/// 危险主按钮(demo `.da-btn`:destructive 实底 pill、52 高、16/600)。
|
||||
class _DangerButton extends StatelessWidget {
|
||||
const _DangerButton({required this.label, required this.onPressed});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onPressed,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.destructive,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.destructiveForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// sheet 内操作按钮(demo `.lo-btn`:52 高、radius 12、16/600;
|
||||
/// primary=lime / danger=destructive / cancel=secondary+描边)。
|
||||
enum _SheetBtnStyle { primary, danger, cancel }
|
||||
|
||||
class _SheetActionButton extends StatelessWidget {
|
||||
const _SheetActionButton({
|
||||
required this.label,
|
||||
required this.style,
|
||||
required this.onTap,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final _SheetBtnStyle style;
|
||||
final VoidCallback onTap;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
Border? border;
|
||||
switch (style) {
|
||||
case _SheetBtnStyle.primary:
|
||||
bg = cs.primary;
|
||||
fg = cs.primaryForeground;
|
||||
case _SheetBtnStyle.danger:
|
||||
bg = cs.destructive;
|
||||
fg = cs.destructiveForeground;
|
||||
case _SheetBtnStyle.cancel:
|
||||
bg = cs.secondary;
|
||||
fg = cs.foreground;
|
||||
border = Border.all(color: cs.border);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1 : 0.55,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: border,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinInput extends StatefulWidget {
|
||||
const _PinInput({
|
||||
required this.controller,
|
||||
required this.onCompleted,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onCompleted;
|
||||
final bool enabled;
|
||||
|
||||
static const length = 6;
|
||||
|
||||
@override
|
||||
State<_PinInput> createState() => _PinInputState();
|
||||
}
|
||||
|
||||
class _PinInputState extends State<_PinInput> {
|
||||
final _focusNode = FocusNode();
|
||||
String _lastText = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _PinInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.enabled && !oldWidget.enabled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onChanged);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() {
|
||||
final value = widget.controller.text;
|
||||
if (value == _lastText) return;
|
||||
_lastText = value;
|
||||
setState(() {});
|
||||
if (value.length == _PinInput.length) {
|
||||
FocusScope.of(context).unfocus();
|
||||
widget.onCompleted(value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final filled = widget.controller.text;
|
||||
return GestureDetector(
|
||||
onTap: widget.enabled ? () => _focusNode.requestFocus() : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: List.generate(_PinInput.length, (i) {
|
||||
final hasChar = i < filled.length;
|
||||
final isFocus = i == filled.length && widget.enabled;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i == _PinInput.length - 1 ? 0 : 12,
|
||||
),
|
||||
child: _PinCell(
|
||||
char: hasChar ? filled[i] : '',
|
||||
focused: isFocus,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(_PinInput.length),
|
||||
],
|
||||
showCursor: false,
|
||||
autofocus: false,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinCell extends StatelessWidget {
|
||||
const _PinCell({required this.char, required this.focused, required this.cs});
|
||||
|
||||
final String char;
|
||||
final bool focused;
|
||||
final ShadColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
|
||||
border: Border.all(
|
||||
color: focused ? cs.primary : cs.border,
|
||||
width: focused ? 1.4 : 1,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
char,
|
||||
style: AppText.sectionTitle.copyWith(
|
||||
fontSize: 19,
|
||||
color: cs.foreground,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WarnCard extends StatelessWidget {
|
||||
const _WarnCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final b = ShadTheme.of(context).brightness;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(Spacing.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: destructiveSoft(b),
|
||||
border: Border.all(color: destructiveSoftBorder(b)),
|
||||
borderRadius: BorderRadius.circular(Spacing.radiusCard),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.destructive,
|
||||
),
|
||||
child: Icon(
|
||||
FlutterRemix.alert_fill,
|
||||
size: 21,
|
||||
color: cs.destructiveForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.3,
|
||||
color: cs.foreground,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '此操作'),
|
||||
TextSpan(
|
||||
text: '不可恢复',
|
||||
style: TextStyle(color: cs.destructive),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'注销后,账号、阅读历史、个人数据将永久失效且无法找回。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.6,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteCheckList extends StatelessWidget {
|
||||
const _DeleteCheckList();
|
||||
|
||||
static const _items = [
|
||||
('账户处于安全状态', '您的账户处于安全状态,无异常登录。'),
|
||||
('账户信息将被清空', '您的个人账户相关信息将被清空且无法恢复。'),
|
||||
('浏览记录将被清空', '您最近阅读的文章将被清空且无法恢复。'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.card,
|
||||
border: Border.all(color: cs.border),
|
||||
borderRadius: BorderRadius.circular(Spacing.radiusCard),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < _items.length; i++) ...[
|
||||
if (i > 0) Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_DeleteCheckItem(
|
||||
number: i + 1,
|
||||
title: _items[i].$1,
|
||||
desc: _items[i].$2,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteCheckItem extends StatelessWidget {
|
||||
const _DeleteCheckItem({
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
});
|
||||
|
||||
final int number;
|
||||
final String title;
|
||||
final String desc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.accent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
'$number',
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.accentForeground,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.foreground,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
desc,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销短信验证底部 sheet(对齐 demo `deleteVerifySheet`):
|
||||
/// 未发送(说明 + 发送验证码[primary]/取消)→ 已发送(OTP 6 格 + 重发倒计时 +
|
||||
/// 确认注销[danger]/取消)→ 提交中(spinner)。
|
||||
class _VerifySheet extends ConsumerStatefulWidget {
|
||||
const _VerifySheet({required this.phone});
|
||||
|
||||
final String phone;
|
||||
|
||||
@override
|
||||
ConsumerState<_VerifySheet> createState() => _VerifySheetState();
|
||||
}
|
||||
|
||||
class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
final _otpController = TextEditingController();
|
||||
Timer? _timer;
|
||||
int _countdown = 0;
|
||||
bool _codeSent = false;
|
||||
bool _sending = false;
|
||||
bool _submitting = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_otpController.dispose();
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_timer?.cancel();
|
||||
setState(() => _countdown = 60);
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (!mounted) {
|
||||
t.cancel();
|
||||
return;
|
||||
}
|
||||
if (_countdown <= 1) {
|
||||
t.cancel();
|
||||
setState(() => _countdown = 0);
|
||||
} else {
|
||||
setState(() => _countdown -= 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendCode() async {
|
||||
if (_sending || _countdown > 0) return;
|
||||
setState(() {
|
||||
_sending = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(authProvider.notifier).sendCode(widget.phone);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_codeSent = true;
|
||||
_otpController.clear();
|
||||
});
|
||||
_startCountdown();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试');
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirm() async {
|
||||
final code = _otpController.text.trim().replaceAll(' ', '');
|
||||
if (code.length != 6) {
|
||||
setState(() => _error = '请输入6位验证码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(authProvider.notifier).deleteAccount(code);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = kratosDisplayMessage(e, fallback: '注销失败,请重试');
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Padding(
|
||||
// 键盘弹出时 sheet 跟随上移
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// grip
|
||||
Center(
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.border,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'短信验证',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_submitting)
|
||||
..._buildSubmitting(cs)
|
||||
else if (!_codeSent)
|
||||
..._buildSendStep(cs)
|
||||
else
|
||||
..._buildCodeStep(cs),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildSubmitting(ShadColorScheme cs) {
|
||||
return [
|
||||
Text(
|
||||
'正在清除账户数据,请稍候',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildSendStep(ShadColorScheme cs) {
|
||||
return [
|
||||
Text(
|
||||
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(_error!, style: TextStyle(fontSize: 13, color: cs.destructive)),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
_SheetActionButton(
|
||||
label: _sending ? '发送中…' : '发送验证码',
|
||||
style: _SheetBtnStyle.primary,
|
||||
enabled: !_sending,
|
||||
onTap: _sendCode,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SheetActionButton(
|
||||
label: '取消',
|
||||
style: _SheetBtnStyle.cancel,
|
||||
enabled: !_sending,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildCodeStep(ShadColorScheme cs) {
|
||||
return [
|
||||
Text(
|
||||
'验证码已发送至 ${_maskPhone(widget.phone)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_PinInput(
|
||||
controller: _otpController,
|
||||
enabled: !_submitting,
|
||||
onCompleted: (_) => _confirm(),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(_error!, style: TextStyle(fontSize: 13, color: cs.destructive)),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _countdown > 0 || _sending ? null : _sendCode,
|
||||
child: Text(
|
||||
_countdown > 0 ? '$_countdown s 后可重新发送' : '重新发送',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: _countdown > 0 ? cs.mutedForeground : cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_SheetActionButton(
|
||||
label: '确认注销',
|
||||
style: _SheetBtnStyle.danger,
|
||||
enabled: !_submitting,
|
||||
onTap: _confirm,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SheetActionButton(
|
||||
label: '取消',
|
||||
style: _SheetBtnStyle.cancel,
|
||||
enabled: !_submitting,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteAccountSuccessPage extends ConsumerWidget {
|
||||
const DeleteAccountSuccessPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.page,
|
||||
72,
|
||||
Spacing.page,
|
||||
28,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.primary,
|
||||
),
|
||||
child: Icon(
|
||||
FlutterRemix.check_line,
|
||||
size: 34,
|
||||
color: cs.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'账户已注销',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.4,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'您的账号及相关数据已清除完毕\n感谢您曾使用研听',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) context.go(AppRoutes.profile);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Text(
|
||||
'返回首页',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'如未来需再次使用,可重新注册账号\n但原历史数据无法迁移恢复',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 11,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user