435 lines
17 KiB
Dart
435 lines
17 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|