feat: 添加账号删除功能
This commit is contained in:
@@ -87,6 +87,10 @@ class ProfileApi {
|
||||
return _toSettings(data);
|
||||
}
|
||||
|
||||
Future<void> deleteAccount() async {
|
||||
await _apiClient.deleteNoContent('/api/v1/users/me');
|
||||
}
|
||||
|
||||
ProfileSettingsV1 _toSettings(Map<String, dynamic> json) {
|
||||
final settingsRaw = json['settings'];
|
||||
final preferencesRaw = settingsRaw is Map<String, dynamic>
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem.dart';
|
||||
import '../../../../core/network/api_problem_mapper.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
|
||||
class AccountDeleteScreen extends StatefulWidget {
|
||||
const AccountDeleteScreen({super.key, required this.onDeleteAccount});
|
||||
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
|
||||
@override
|
||||
State<AccountDeleteScreen> createState() => _AccountDeleteScreenState();
|
||||
}
|
||||
|
||||
class _AccountDeleteScreenState extends State<AccountDeleteScreen> {
|
||||
final Logger _logger = getLogger('features.settings.account_delete');
|
||||
bool _isDeleting = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsAccountAndDataTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
title: l10n.settingsDeleteAccountTitle,
|
||||
tint: colors.error,
|
||||
background: colors.surfaceContainerHighest,
|
||||
titleColor: colors.error,
|
||||
showDivider: false,
|
||||
onTap: _isDeleting ? () {} : _confirmDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return const _DeleteConfirmDialog();
|
||||
},
|
||||
);
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
await _deleteAccount();
|
||||
}
|
||||
|
||||
Future<void> _deleteAccount() async {
|
||||
if (_isDeleting) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isDeleting = true;
|
||||
});
|
||||
try {
|
||||
await widget.onDeleteAccount();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Delete account request failed',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final message = error is ApiProblem
|
||||
? mapApiProblemToMessage(error, l10n)
|
||||
: l10n.errorRequestGeneric;
|
||||
Toast.show(context, message, type: ToastType.error);
|
||||
setState(() {
|
||||
_isDeleting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteConfirmDialog extends StatefulWidget {
|
||||
const _DeleteConfirmDialog();
|
||||
|
||||
@override
|
||||
State<_DeleteConfirmDialog> createState() => _DeleteConfirmDialogState();
|
||||
}
|
||||
|
||||
class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
|
||||
static const int _coolDownSeconds = 5;
|
||||
int _secondsLeft = _coolDownSeconds;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
if (_secondsLeft <= 1) {
|
||||
setState(() {
|
||||
_secondsLeft = 0;
|
||||
});
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_secondsLeft -= 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.warning_rounded,
|
||||
color: colors.error,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.settingsDeleteAccountDialogTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
l10n.settingsDeleteAccountWarningBody,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
_secondsLeft > 0
|
||||
? l10n.settingsDeleteAccountWaitAction(_secondsLeft)
|
||||
: l10n.settingsDeleteAccountDialogBody,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colors.onSurface,
|
||||
side: BorderSide(color: colors.outline),
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.settingsCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _secondsLeft <= 0
|
||||
? () => Navigator.of(context).pop(true)
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
disabledBackgroundColor: colors.error.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
disabledForegroundColor: colors.onError.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.settingsDeleteAccountAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../data/models/profile_settings.dart';
|
||||
import 'account_delete_screen.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
import 'coin_center_screen.dart';
|
||||
import 'general_settings_screen.dart';
|
||||
@@ -22,6 +23,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
required this.onSettingsChanged,
|
||||
required this.onUploadAvatar,
|
||||
required this.onLogout,
|
||||
required this.onDeleteAccount,
|
||||
required this.onSaveProfile,
|
||||
});
|
||||
|
||||
@@ -32,6 +34,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
|
||||
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
||||
final Future<void> Function() onLogout;
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
onSaveProfile;
|
||||
|
||||
@@ -119,6 +122,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.person_rounded,
|
||||
title: l10n.settingsAccountAndDataTitle,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: _openAccountDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
FilledButton(
|
||||
onPressed: _confirmLogout,
|
||||
@@ -194,6 +209,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAccountDelete() async {
|
||||
final deleted = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) =>
|
||||
AccountDeleteScreen(onDeleteAccount: widget.onDeleteAccount),
|
||||
),
|
||||
);
|
||||
if (deleted != true) {
|
||||
return;
|
||||
}
|
||||
await widget.onLogout();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final confirmed = await showDialog<bool>(
|
||||
|
||||
@@ -62,6 +62,8 @@ class SettingsMenuTile extends StatelessWidget {
|
||||
this.showChevron = true,
|
||||
this.trailing,
|
||||
this.subtitle,
|
||||
this.titleColor,
|
||||
this.subtitleColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -73,6 +75,8 @@ class SettingsMenuTile extends StatelessWidget {
|
||||
final bool showDivider;
|
||||
final bool showChevron;
|
||||
final Widget? trailing;
|
||||
final Color? titleColor;
|
||||
final Color? subtitleColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -94,12 +98,27 @@ class SettingsMenuTile extends StatelessWidget {
|
||||
),
|
||||
child: Icon(icon, color: tint),
|
||||
),
|
||||
title: Text(title),
|
||||
title: Text(
|
||||
title,
|
||||
style: titleColor == null
|
||||
? null
|
||||
: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: titleColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: subtitle == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||
child: Text(subtitle!),
|
||||
child: Text(
|
||||
subtitle!,
|
||||
style: subtitleColor == null
|
||||
? null
|
||||
: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: subtitleColor),
|
||||
),
|
||||
),
|
||||
trailing:
|
||||
trailing ??
|
||||
@@ -162,7 +181,7 @@ class SettingsSwitchTile extends StatelessWidget {
|
||||
trailing: Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: colors.primary,
|
||||
activeThumbColor: colors.primary,
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
|
||||
Reference in New Issue
Block a user