feat: 添加账号删除功能

This commit is contained in:
qzl
2026-04-10 10:40:44 +08:00
parent 17a1303f00
commit 46513829cd
30 changed files with 1510 additions and 664 deletions
+27 -1
View File
@@ -1,3 +1,6 @@
import 'package:dio/dio.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/session_response.dart';
@@ -25,9 +28,32 @@ class AuthApi {
}
Future<void> deleteSession({required String refreshToken}) async {
await _apiClient.deleteNoContent(
final response = await _apiClient.rawDio.delete<Map<String, dynamic>>(
'/api/v1/auth/sessions',
data: {'refresh_token': refreshToken},
options: Options(
validateStatus: (status) => status != null && status < 500,
),
);
final status = response.statusCode ?? 500;
if (status == 204 || status == 401) {
return;
}
final data = response.data;
if (data is Map<String, dynamic>) {
throw ApiProblem(
status: status,
title: (data['title'] as String?) ?? 'Request failed',
detail: (data['detail'] as String?) ?? '',
code: data['code'] as String?,
);
}
throw ApiProblem(
status: status,
title: 'Request failed',
detail: 'Failed to delete session',
);
}
@@ -1,4 +1,5 @@
import '../../../../core/auth/session_store.dart';
import '../../../../core/network/api_problem.dart';
import '../apis/auth_api.dart';
import '../models/auth_user.dart';
@@ -70,7 +71,14 @@ class AuthRepositoryImpl implements AuthRepository {
try {
final refreshToken = await _sessionStore.getRefreshToken();
if (refreshToken != null && refreshToken.isNotEmpty) {
await _authApi.deleteSession(refreshToken: refreshToken);
try {
await _authApi.deleteSession(refreshToken: refreshToken);
} on ApiProblem catch (problem) {
if (problem.status != 401 ||
problem.code != 'AUTH_REFRESH_TOKEN_INVALID') {
rethrow;
}
}
}
} finally {
await clearLocalSession();
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../../../core/logging/logger.dart';
@@ -56,18 +58,19 @@ class AuthBloc extends ChangeNotifier {
}
Future<void> logout() async {
try {
await _repository.logout();
} catch (error, stackTrace) {
_logger.error(
message: 'User logout failed: ${error.runtimeType}',
error: error.runtimeType.toString(),
stackTrace: stackTrace,
);
}
_logger.info(message: 'User logged out');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
unawaited(
_repository.logout().catchError((Object error, StackTrace stackTrace) {
_logger.error(
message: 'User logout failed: ${error.runtimeType}',
error: error.runtimeType.toString(),
stackTrace: stackTrace,
);
}),
);
}
Future<void> handleUnauthorized401() async {
@@ -35,6 +35,7 @@ class HomeScreen extends StatefulWidget {
required this.onDivinationCompleted,
required this.onDeleteHistorySession,
required this.onLogout,
required this.onDeleteAccount,
});
final String account;
@@ -54,6 +55,7 @@ class HomeScreen extends StatefulWidget {
onDivinationCompleted;
final Future<void> Function(String threadId) onDeleteHistorySession;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
@override
State<HomeScreen> createState() => _HomeScreenState();
@@ -116,6 +118,7 @@ class _HomeScreenState extends State<HomeScreen> {
onSaveProfile: widget.onSaveProfile,
onUploadAvatar: widget.onUploadAvatar,
onLogout: widget.onLogout,
onDeleteAccount: widget.onDeleteAccount,
),
],
),
@@ -505,6 +508,7 @@ class _ProfileTab extends StatelessWidget {
required this.onSaveProfile,
required this.onUploadAvatar,
required this.onLogout,
required this.onDeleteAccount,
});
final String account;
@@ -516,6 +520,7 @@ class _ProfileTab extends StatelessWidget {
onSaveProfile;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
@override
Widget build(BuildContext context) {
@@ -528,6 +533,7 @@ class _ProfileTab extends StatelessWidget {
onSaveProfile: onSaveProfile,
onUploadAvatar: onUploadAvatar,
onLogout: onLogout,
onDeleteAccount: onDeleteAccount,
);
}
}
@@ -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)