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

435 lines
17 KiB
Dart
Raw Normal View History

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,
),
),
],
),
),
),
),
),
],
),
);
}
}