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

372 lines
14 KiB
Dart
Raw Normal View History

import 'package:flutter/material.dart';
2026-04-23 13:32:46 +00:00
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
2026-04-23 13:32:46 +00:00
import '../../providers/sake_list_provider.dart';
import '../../providers/theme_provider.dart';
import '../../services/backup_service.dart';
import '../../theme/app_colors.dart';
2026-04-23 13:32:46 +00:00
class BackupSettingsSection extends ConsumerStatefulWidget {
final String title;
const BackupSettingsSection({
super.key,
this.title = 'バックアップ・復元',
});
@override
2026-04-23 13:32:46 +00:00
ConsumerState<BackupSettingsSection> createState() => _BackupSettingsSectionState();
}
enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
2026-04-23 13:32:46 +00:00
class _BackupSettingsSectionState extends ConsumerState<BackupSettingsSection> {
final BackupService _backupService = BackupService();
_BackupState _state = _BackupState.idle;
@override
void initState() {
super.initState();
_initBackupService();
}
Future<void> _initBackupService() async {
2026-04-23 13:32:46 +00:00
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>()!;
2026-04-23 13:32:46 +00:00
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('復元'),
),
],
),
);
2026-04-23 13:32:46 +00:00
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);
2026-04-23 13:32:46 +00:00
if (success) {
ref.invalidate(rawSakeListItemsProvider);
ref.invalidate(sakeSortOrderProvider);
ref.invalidate(userProfileProvider);
}
messenger.showSnackBar(
SnackBar(
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
backgroundColor: success ? appColors.success : appColors.error,
),
);
}
2026-04-23 13:32:46 +00:00
} 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,
),
),
],
),
);
}
}