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 _showVerifyDialog( BuildContext context, WidgetRef ref, String phone, ) async { final cs = ShadTheme.of(context).colorScheme; final completed = await showModalBottomSheet( 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 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 _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 _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 _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 _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 _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, ), ), ], ), ), ), ); } }