ponshu-room-lite/lib/widgets/settings/backup_settings_section.dart

372 lines
14 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/sake_list_provider.dart';
import '../../providers/theme_provider.dart';
import '../../services/backup_service.dart';
import '../../theme/app_colors.dart';
class BackupSettingsSection extends ConsumerStatefulWidget {
final String title;
const BackupSettingsSection({
super.key,
this.title = 'バックアップ・復元',
});
@override
ConsumerState<BackupSettingsSection> createState() => _BackupSettingsSectionState();
}
enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
class _BackupSettingsSectionState extends ConsumerState<BackupSettingsSection> {
final BackupService _backupService = BackupService();
_BackupState _state = _BackupState.idle;
@override
void initState() {
super.initState();
_initBackupService();
}
Future<void> _initBackupService() async {
try {
await _backupService.init();
} catch (e) {
debugPrint('[Backup] Init error (silent sign-in failed): $e');
}
if (mounted) {
setState(() {});
}
}
Future<void> _signIn() async {
final messenger = ScaffoldMessenger.of(context);
setState(() => _state = _BackupState.signingIn);
final account = await _backupService.signIn();
if (mounted) {
setState(() => _state = _BackupState.idle);
if (account != null) {
messenger.showSnackBar(
SnackBar(content: Text('${account.email} で連携しました')),
);
} else {
messenger.showSnackBar(
const SnackBar(content: Text('連携がキャンセルされました')),
);
}
}
}
Future<void> _signOut() async {
final messenger = ScaffoldMessenger.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('連携解除'),
content: const Text('Googleアカウントとの連携を解除しますか\n安全にログアウトできます。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('解除'),
),
],
),
);
if (confirmed == true) {
setState(() => _state = _BackupState.signingOut);
await _backupService.signOut();
if (mounted) {
setState(() => _state = _BackupState.idle);
messenger.showSnackBar(
const SnackBar(content: Text('連携を解除しました')),
);
}
}
}
Future<void> _createBackup() async {
final messenger = ScaffoldMessenger.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
setState(() => _state = _BackupState.backingUp);
final success = await _backupService.createBackup();
if (mounted) {
setState(() => _state = _BackupState.idle);
messenger.showSnackBar(
SnackBar(
content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'),
backgroundColor: success ? appColors.success : appColors.error,
),
);
}
}
Future<void> _confirmBackup() async {
final appColors = Theme.of(context).extension<AppColors>()!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('バックアップ'),
content: const Text('Google Driveに最新データのみ保存過去分は上書きされます。\n本当に続行しますか?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: appColors.brandPrimary,
foregroundColor: appColors.surfaceSubtle,
),
child: const Text('バックアップ'),
),
],
),
);
if (confirmed == true && mounted) {
await _createBackup();
}
}
Future<void> _restoreBackup() async {
final messenger = ScaffoldMessenger.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
final hasBackup = await _backupService.hasBackupOnDrive();
if (!hasBackup) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('バックアップファイルが見つかりません')),
);
}
return;
}
if (!mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(LucideIcons.alertTriangle, color: appColors.warning, size: 24),
const SizedBox(width: 8),
const Text('データ復元'),
],
),
content: const Text('現在のデータは上書きされます。\n削除されたデータは元に戻りません。\n\n本当に続行しますか?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: appColors.warning,
foregroundColor: appColors.surfaceSubtle,
),
child: const Text('復元'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _executeRestore(forceSkipPreBackup: false);
}
/// 実際の復元処理。PreRestoreBackupException を受けた場合はダイアログで続行確認する。
Future<void> _executeRestore({required bool forceSkipPreBackup}) async {
final messenger = ScaffoldMessenger.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
setState(() => _state = _BackupState.restoring);
try {
final success = forceSkipPreBackup
? await _backupService.restoreBackupSkippingPreBackup()
: await _backupService.restoreBackup();
if (mounted) {
setState(() => _state = _BackupState.idle);
if (success) {
ref.invalidate(rawSakeListItemsProvider);
ref.invalidate(sakeSortOrderProvider);
ref.invalidate(userProfileProvider);
}
messenger.showSnackBar(
SnackBar(
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
backgroundColor: success ? appColors.success : appColors.error,
),
);
}
} on PreRestoreBackupException {
if (!mounted) return;
setState(() => _state = _BackupState.idle);
// 事前バックアップ失敗 → ユーザーに警告して続行するか確認
final continueAnyway = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(LucideIcons.alertTriangle, color: appColors.error, size: 24),
const SizedBox(width: 8),
const Text('安全バックアップに失敗'),
],
),
content: const Text(
'復元前の安全コピー作成に失敗しました。\n'
'このまま続行すると、現在のデータが失われた場合に\n'
'元に戻せない可能性があります。\n\n'
'続行しますか?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('中断'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: appColors.error,
foregroundColor: appColors.surfaceSubtle,
),
child: const Text('それでも続行'),
),
],
),
);
if (continueAnyway == true && mounted) {
await _executeRestore(forceSkipPreBackup: true);
}
}
}
@override
Widget build(BuildContext context) {
final currentUser = _backupService.currentUser;
final isAnyProcessing = _state != _BackupState.idle;
final appColors = Theme.of(context).extension<AppColors>()!;
return Column(
children: [
_buildSectionHeader(context, widget.title, LucideIcons.cloud),
// Wi-Fi warning
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: appColors.info.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: appColors.info.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(LucideIcons.wifi, color: appColors.info, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'Wi-Fi環境下での実行を推奨します\nデータ量が100MB以上になる可能性があります',
style: TextStyle(
fontSize: 12,
color: appColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
Card(
color: appColors.surfaceSubtle,
elevation: 0,
margin: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: Icon(
currentUser != null ? LucideIcons.checkCircle2 : LucideIcons.user,
color: currentUser != null ? appColors.success : appColors.iconSubtle,
),
title: Text(currentUser == null ? 'Googleアカウント連携' : currentUser.email,
style: TextStyle(color: appColors.textPrimary)),
subtitle: currentUser == null ? Text('Google Driveにバックアップ',
style: TextStyle(color: appColors.textSecondary)) : null,
trailing: (_state == _BackupState.signingIn || _state == _BackupState.signingOut)
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: currentUser == null ? appColors.brandPrimary : appColors.error,
foregroundColor: appColors.surfaceSubtle,
padding: const EdgeInsets.symmetric(horizontal: 16),
),
onPressed: isAnyProcessing ? null : (currentUser == null ? _signIn : _signOut),
child: Text(
currentUser == null ? '連携' : '解除',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
if (currentUser != null) ...[
Divider(height: 1, color: appColors.divider),
ListTile(
leading: Icon(LucideIcons.uploadCloud, color: appColors.iconDefault),
title: Text('バックアップ', style: TextStyle(color: appColors.textPrimary)),
trailing: _state == _BackupState.backingUp
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: null,
onTap: isAnyProcessing ? null : _confirmBackup,
),
Divider(height: 1, color: appColors.divider),
ListTile(
leading: Icon(LucideIcons.downloadCloud, color: appColors.iconDefault),
title: Text('データ復元', style: TextStyle(color: appColors.textPrimary)),
trailing: _state == _BackupState.restoring
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: null,
onTap: isAnyProcessing ? null : _restoreBackup,
),
],
],
),
),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, IconData icon) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Row(
children: [
Icon(icon, size: 20, color: appColors.iconDefault),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.textPrimary,
),
),
],
),
);
}
}