ponshu-room-lite/lib/screens/pending_analysis_screen.dart

435 lines
17 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 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart';
import '../services/draft_service.dart';
import '../services/network_service.dart';
import '../theme/app_colors.dart';
/// 未解析Draft一覧・一括解析画面
///
/// Phase 1: オフライン対応機能
/// オフライン時に撮影した写真Draftの一覧を表示し、
/// オンライン復帰後に一括解析できます。
class PendingAnalysisScreen extends ConsumerStatefulWidget {
const PendingAnalysisScreen({super.key});
@override
ConsumerState<PendingAnalysisScreen> createState() => _PendingAnalysisScreenState();
}
class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
List<SakeItem> _pendingDrafts = [];
bool _isLoading = true;
bool _isAnalyzing = false;
int _analyzedCount = 0;
int _totalCount = 0;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadPendingDrafts();
}
Future<void> _loadPendingDrafts() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final drafts = await DraftService.getPendingDrafts();
setState(() {
_pendingDrafts = drafts;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Draft読み込みエラー: $e';
_isLoading = false;
});
}
}
Future<void> _analyzeAllDrafts() async {
final appColors = Theme.of(context).extension<AppColors>()!;
// オンライン確認
final isOnline = await NetworkService.isOnline();
if (!isOnline) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('オフライン状態です。インターネット接続を確認してください。'),
backgroundColor: appColors.error,
duration: const Duration(seconds: 4),
),
);
return;
}
setState(() {
_isAnalyzing = true;
_analyzedCount = 0;
_totalCount = _pendingDrafts.length;
_errorMessage = null;
});
try {
final result = await DraftService.analyzeAllDrafts(
onProgress: (progress, total) {
if (mounted) {
setState(() {
_analyzedCount = progress;
});
}
},
);
if (!mounted) return;
// 解析完了後、リスト再読み込み
await _loadPendingDrafts();
// 結果通知
final successCount = result['success'] as int;
final failedCount = result['failed'] as int;
final errors = result['errors'] as List<String>;
if (failedCount > 0) {
// 一部失敗
setState(() {
_errorMessage = '解析完了: $successCount成功, $failedCount失敗\n\n失敗詳細:\n${errors.join('\n')}';
});
} else {
// 全成功
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('すべてのDraft$successCount件)を解析しました'),
backgroundColor: appColors.success,
duration: const Duration(seconds: 3),
),
);
// 全件解析完了なら画面を閉じる
if (_pendingDrafts.isEmpty) {
if (!mounted) return;
Navigator.of(context).pop();
}
}
} catch (e) {
setState(() {
_errorMessage = '一括解析エラー: $e';
});
} finally {
setState(() {
_isAnalyzing = false;
});
}
}
Future<void> _deleteDraft(SakeItem draft) async {
final appColors = Theme.of(context).extension<AppColors>()!;
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Draft削除確認'),
content: const Text('この写真を削除しますか?\n(ギャラリーには残ります)'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('キャンセル'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: appColors.error),
child: const Text('削除'),
),
],
),
);
if (confirm != true) return;
try {
await DraftService.deleteDraft(draft.key);
await _loadPendingDrafts();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Draftを削除しました'),
duration: Duration(seconds: 3),
),
);
// 全件削除された場合は画面を閉じる
if (_pendingDrafts.isEmpty) {
Navigator.of(context).pop();
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('削除エラー: $e'),
duration: const Duration(seconds: 5),
),
);
}
}
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Scaffold(
appBar: AppBar(
title: const Text('未解析の写真', style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
if (_pendingDrafts.isNotEmpty && !_isAnalyzing)
IconButton(
icon: const Icon(LucideIcons.trash2),
tooltip: 'すべて削除',
onPressed: () async {
// BuildContextキャプチャasync gapの前
if (!mounted) return;
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('全件削除確認'),
content: Text('${_pendingDrafts.length}件のDraftをすべて削除しますか'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('キャンセル'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: appColors.error),
child: const Text('すべて削除'),
),
],
),
);
if (!mounted) return;
if (confirm != true) return;
final count = await DraftService.deleteAllDrafts();
await _loadPendingDrafts();
// async gap後はキャプチャした変数を使用
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('$count件のDraftを削除しました'),
duration: const Duration(seconds: 3),
),
);
if (!mounted) return;
navigator.pop();
},
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.alertCircle, size: 60, color: appColors.error),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: appColors.error),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loadPendingDrafts,
child: const Text('再読み込み'),
),
],
),
),
)
: _pendingDrafts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.checkCircle, size: 60, color: appColors.success),
const SizedBox(height: 16),
const Text(
'未解析の写真はありません',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'すべての写真の解析が完了しています',
style: TextStyle(color: appColors.textSecondary),
),
],
),
)
: Column(
children: [
// 進捗表示(解析中のみ)
if (_isAnalyzing)
Container(
padding: const EdgeInsets.all(16),
color: appColors.brandPrimary.withValues(alpha: 0.1),
child: Column(
children: [
Row(
children: [
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 3),
),
const SizedBox(width: 12),
Text(
'解析中... $_analyzedCount / $_totalCount',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: _totalCount > 0 ? _analyzedCount / _totalCount : 0,
),
],
),
),
// Draft一覧
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _pendingDrafts.length,
itemBuilder: (context, index) {
final draft = _pendingDrafts[index];
final photoPath = draft.draftPhotoPath;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
contentPadding: const EdgeInsets.all(12),
leading: photoPath != null && File(photoPath).existsSync()
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(photoPath),
width: 60,
height: 60,
fit: BoxFit.cover,
),
)
: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(LucideIcons.image, color: Colors.grey),
),
title: const Text(
'解析待ち',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}',
style: TextStyle(fontSize: 12, color: appColors.textSecondary),
),
trailing: IconButton(
icon: Icon(LucideIcons.trash2, color: appColors.error),
onPressed: () => _deleteDraft(draft),
),
),
);
},
),
),
// 一括解析ボタン
if (_pendingDrafts.isNotEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isAnalyzing ? null : _analyzeAllDrafts,
style: ElevatedButton.styleFrom(
backgroundColor: appColors.brandPrimary,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.shade400,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isAnalyzing
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
SizedBox(width: 12),
Text('解析中...', style: TextStyle(fontSize: 16)),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.sparkles, size: 20),
const SizedBox(width: 8),
Text(
'すべて解析(${_pendingDrafts.length}件)',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
),
],
),
);
}
}