Files
jinzhi/lib/features/auth/presentation/delete_account_page.dart
T
2026-06-18 18:08:29 +08:00

907 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
],
),
),
),
);
}
}