feat:参照研听的基础工程

This commit is contained in:
jingyun
2026-06-18 15:02:28 +08:00
commit 364fc837b3
367 changed files with 16495 additions and 0 deletions
@@ -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,
),
),
],
),
),
),
);
}
}