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