import 'dart:async'; // Timer import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:path/path.dart' show join; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import 'package:gal/gal.dart'; import '../services/gemini_service.dart'; import '../services/ocr_service.dart'; import '../widgets/analyzing_dialog.dart'; import '../models/sake_item.dart'; import '../theme/app_theme.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:image_picker/image_picker.dart'; // Gallery & Camera import '../models/user_profile.dart'; import '../providers/theme_provider.dart'; // userProfileProvider enum CameraMode { createItem, returnPath, } class CameraScreen extends ConsumerStatefulWidget { final CameraMode mode; const CameraScreen({super.key, this.mode = CameraMode.createItem}); @override ConsumerState createState() => _CameraScreenState(); } class _CameraScreenState extends ConsumerState with SingleTickerProviderStateMixin, WidgetsBindingObserver { CameraController? _controller; Future? _initializeControllerFuture; bool _isTakingPicture = false; DateTime? _quotaLockoutTime; // Phase 3-B: Focus & Zoom double _minZoom = 1.0; double _maxZoom = 1.0; double _currentZoom = 1.0; double _baseScale = 1.0; // For pinch reference // Phase 3-B2: Exposure double _minExposure = 0.0; double _maxExposure = 0.0; double _currentExposureOffset = 0.0; Offset? _focusPoint; bool _showFocusRing = false; Timer? _focusRingTimer; DateTime? _lastExposureUpdate; // Throttling state @override void initState() { super.initState(); _initializeCamera(); } Future _initializeCamera() async { final cameras = await availableCameras(); final firstCamera = cameras.first; _controller = CameraController( firstCamera, ResolutionPreset.high, enableAudio: false, imageFormatGroup: ImageFormatGroup.jpeg, ); _initializeControllerFuture = _controller!.initialize().then((_) async { if (!mounted) return; // [Phase 3-B] Get Zoom Range _minZoom = await _controller!.getMinZoomLevel(); _maxZoom = await _controller!.getMaxZoomLevel(); // [Phase 3-B2] Get Exposure Range _minExposure = await _controller!.getMinExposureOffset(); _maxExposure = await _controller!.getMaxExposureOffset(); // [Phase 3-B] Set Auto Focus Mode await _controller!.setFocusMode(FocusMode.auto); setState(() {}); }); } @override void dispose() { _controller?.dispose(); _focusRingTimer?.cancel(); super.dispose(); } // ... (Keep existing methods: _capturedImages, _takePicture, _analyzeImages) double? _pendingExposureValue; bool _isUpdatingExposure = false; Future _setExposureSafe(double value) async { _pendingExposureValue = value; if (_isUpdatingExposure) return; _isUpdatingExposure = true; while (_pendingExposureValue != null) { final valueToSet = _pendingExposureValue!; _pendingExposureValue = null; try { if (_controller != null && _controller!.value.isInitialized) { await _controller!.setExposureOffset(valueToSet); } } catch (e) { debugPrint('Exposure update error: $e'); } } _isUpdatingExposure = false; } void _onTapFocus(TapUpDetails details, BoxConstraints constraints) async { if (_controller == null || !_controller!.value.isInitialized) return; // Normalizing coordinates (0.0 - 1.0) final offset = Offset( details.localPosition.dx / constraints.maxWidth, details.localPosition.dy / constraints.maxHeight, ); try { await _controller!.setFocusPoint(offset); await _controller!.setFocusMode(FocusMode.auto); } catch (e) { // Some devices might not support focus point debugPrint('Focus failed: $e'); } if (!mounted) return; setState(() { _focusPoint = details.localPosition; _showFocusRing = true; }); _focusRingTimer?.cancel(); _focusRingTimer = Timer(const Duration(milliseconds: 1000), () { if (mounted) setState(() => _showFocusRing = false); }); } void _onScaleStart(ScaleStartDetails details) { _baseScale = _currentZoom; } Future _onScaleUpdate(ScaleUpdateDetails details) async { if (_controller == null || !_controller!.value.isInitialized) return; // Calculate new zoom level final newZoom = (_baseScale * details.scale).clamp(_minZoom, _maxZoom); // Optimize: 0.1 increments (Prevent jitter) if ((newZoom - _currentZoom).abs() < 0.1) return; try { await _controller!.setZoomLevel(newZoom); setState(() => _currentZoom = newZoom); } catch (e) { debugPrint('Zoom failed: $e'); } } final List _capturedImages = []; Future _takePicture() async { // Check Quota Lockout if (_quotaLockoutTime != null) { final remaining = _quotaLockoutTime!.difference(DateTime.now()); if (remaining.isNegative) { setState(() => _quotaLockoutTime = null); // Reset } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')), ); return; } } if (_isTakingPicture || _controller == null || !_controller!.value.isInitialized) { return; } setState(() { _isTakingPicture = true; }); try { await _initializeControllerFuture; final image = await _controller!.takePicture(); // Save image locally (App Sandbox) final directory = await getApplicationDocumentsDirectory(); final String imagePath = join(directory.path, '${const Uuid().v4()}.jpg'); await image.saveTo(imagePath); // Save to Gallery (Public) - Phase 4: Data Safety try { await Gal.putImage(imagePath); debugPrint('Saved to Gallery: $imagePath'); } catch (e) { debugPrint('Gallery Save Error: $e'); // Don't block flow, but maybe notify? if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('ギャラリー保存に失敗しました: $e'), duration: const Duration(seconds: 1)), ); } } if (!mounted) return; _handleCapturedImage(imagePath); } catch (e) { debugPrint('Capture Error: $e'); } finally { if (mounted) { setState(() { _isTakingPicture = false; }); } } } Future _pickFromGallery() async { final picker = ImagePicker(); // Use standard image_picker (Updated to 1.1.2 for Android 13+) final List images = await picker.pickMultiImage(); if (images.isNotEmpty && mounted) { // IF RETURN PATH Mode (Only supports one) if (widget.mode == CameraMode.returnPath) { Navigator.of(context).pop(images.first.path); return; } setState(() { for (var img in images) { _capturedImages.add(img.path); } }); // Batch handle - Notification only if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'), duration: const Duration(seconds: 3), action: SnackBarAction( label: '解析する', onPressed: _analyzeImages, textColor: Colors.yellow, ), ), ); } } } Future _handleCapturedImage(String imagePath, {bool fromGallery = false}) async { // IF RETURN PATH Mode if (widget.mode == CameraMode.returnPath) { Navigator.of(context).pop(imagePath); return; } setState(() { _capturedImages.add(imagePath); }); // Show Confirmation Dialog await showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: Text(fromGallery ? '画像を読み込みました' : '写真を保存しました'), content: const Text('さらに別の面も撮影・追加すると、\nAI解析の精度がアップします!'), actions: [ OutlinedButton( onPressed: () { // Start Analysis Navigator.of(context).pop(); _analyzeImages(); }, child: const Text('解析開始'), ), FilledButton( onPressed: () { // Return to capture (Dismiss dialog) Navigator.of(context).pop(); }, style: FilledButton.styleFrom( backgroundColor: AppTheme.posimaiBlue, foregroundColor: Colors.white, ), child: const Text('さらに追加'), ), ], ), ); } Future _analyzeImages() async { if (_capturedImages.isEmpty) return; // Show AnalyzingDialog showDialog( context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog(), ); try { // [Phase 3-C Revised] Hybrid Analysis Implementation final ocrService = OcrService(); final StringBuffer extractedBuffer = StringBuffer(); try { for (final path in _capturedImages) { final text = await ocrService.extractText(path); if (text.isNotEmpty) { extractedBuffer.writeln(text); } } } finally { ocrService.dispose(); // Ensure resources are released } final extractedText = extractedBuffer.toString().trim(); debugPrint('OCR Extracted Text (${extractedText.length} chars):'); if (extractedText.isNotEmpty) { debugPrint('${extractedText.substring(0, extractedText.length > 100 ? 100 : extractedText.length)}...'); } // Hybrid Decision Logic (Threshold: 30 chars) SakeAnalysisResult result; final geminiService = GeminiService(); if (extractedText.length > 30) { debugPrint('✅ OCR SUCCESS: Using Hybrid Analysis (Text + Images)'); // Send both text and images (images allow AI to correct OCR errors) result = await geminiService.analyzeSakeHybrid(extractedText, _capturedImages); } else { debugPrint('⚠️ OCR INSUFFICIENT (${extractedText.length} chars): Fallback to Image Analysis'); result = await geminiService.analyzeSakeLabel(_capturedImages); } // Create SakeItem // Create SakeItem (Schema v2.0) final sakeItem = SakeItem( id: const Uuid().v4(), displayData: DisplayData( name: result.name ?? '不明な日本酒', brewery: result.brand ?? '不明', prefecture: result.prefecture ?? '不明', catchCopy: result.catchCopy, imagePaths: List.from(_capturedImages), rating: null, ), hiddenSpecs: HiddenSpecs( description: result.description, tasteStats: result.tasteStats, flavorTags: result.flavorTags, ), metadata: Metadata( createdAt: DateTime.now(), aiConfidence: result.confidenceScore, ), ); // Save to Hive final box = Hive.box('sake_items'); await box.add(sakeItem); // Prepend new item to sort order so it appears at the top final settingsBox = Hive.box('settings'); final List currentOrder = (settingsBox.get('sake_sort_order') as List?) ?.cast() ?? []; currentOrder.insert(0, sakeItem.id); // Insert at beginning await settingsBox.put('sake_sort_order', currentOrder); // --- v1.3 Gamification Hook --- // Award EXP final userProfileState = ref.read(userProfileProvider); final prevLevel = userProfileState.level; await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + 10); // Refetch updated state for level comparison final updatedProfile = ref.read(userProfileProvider); final newLevel = updatedProfile.level; final isLevelUp = newLevel > prevLevel; // Debug: Verify save debugPrint('✅ Saved to Hive: ${sakeItem.displayData.name} (ID: ${sakeItem.id})'); debugPrint('📦 Total items in box: ${box.length}'); debugPrint('📦 Prepended to sort order (now ${currentOrder.length} items)'); debugPrint('🎮 Gamification: EXP +10 (Total: ${updatedProfile.totalExp}) Level: $prevLevel -> $newLevel'); if (!mounted) return; // Close Dialog Navigator.of(context).pop(); // Close Camera Screen (Return to Home) Navigator.of(context).pop(); // Success Message (with EXP/Level Up info) ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${sakeItem.displayData.name} を登録しました!'), const SizedBox(height: 4), Row( children: [ const Icon(LucideIcons.sparkles, color: Colors.yellow, size: 16), const SizedBox(width: 8), Text( '経験値 +10 GET! ${isLevelUp ? " (Level UP!)" : ""}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.yellowAccent), ), ], ), ], ), duration: const Duration(seconds: 4), // Longer display for level up ), ); } catch (e) { if (mounted) { Navigator.of(context).pop(); // Close AnalyzingDialog // Check for Quota Error to set Lockout if (e.toString().contains('Quota') || e.toString().contains('429')) { setState(() { _quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1)); }); } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('解析エラー: $e')), ); } } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: FutureBuilder( future: _initializeControllerFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { // Camera Preview with Focus/Zoom return Stack( fit: StackFit.expand, children: [ LayoutBuilder( builder: (context, constraints) { return GestureDetector( onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, onTapUp: (details) => _onTapFocus(details, constraints), child: CameraPreview(_controller!), ); } ), // Focus Ring Overlay if (_showFocusRing && _focusPoint != null) Positioned( left: _focusPoint!.dx - 40, top: _focusPoint!.dy - 40, child: Container( width: 80, height: 80, decoration: BoxDecoration( border: Border.all(color: Colors.yellow, width: 2), borderRadius: BorderRadius.circular(40), ), ), ), // Instagram-style Exposure Slider Positioned( right: 16, top: MediaQuery.of(context).size.height * 0.25, child: GestureDetector( onVerticalDragUpdate: (details) async { // Throttling (30ms) final now = DateTime.now(); if (_lastExposureUpdate != null && now.difference(_lastExposureUpdate!).inMilliseconds < 30) { return; } // Drag Up = Brighter (+), Down = Darker (-) final sensitivity = 0.03; final delta = -details.delta.dy * sensitivity; if (_controller == null || !_controller!.value.isInitialized) return; final newValue = (_currentExposureOffset + delta).clamp(_minExposure, _maxExposure); // UI immediate update setState(() => _currentExposureOffset = newValue); // Async camera update _setExposureSafe(newValue); _lastExposureUpdate = now; }, onVerticalDragEnd: (details) { // Finalize value on drag end _setExposureSafe(_currentExposureOffset); }, onDoubleTap: () async { // Reset if (_controller == null) return; setState(() => _currentExposureOffset = 0.0); _setExposureSafe(0.0); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ // Sun Icon (Bright) Icon(LucideIcons.sun, color: _currentExposureOffset > 0.5 ? Colors.yellow : Colors.white54, size: 24), const SizedBox(height: 8), // Vertical Track Container( height: 180, width: 4, // Thin track decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), child: Stack( alignment: Alignment.bottomCenter, children: [ // Center Marker Align(alignment: Alignment.center, child: Container(height: 2, width: 12, color: Colors.white54)), // Knob LayoutBuilder( builder: (context, constraints) { final range = _maxExposure - _minExposure; if (range == 0) return const SizedBox(); final normalized = (_currentExposureOffset - _minExposure) / range; // 1.0 is top (max), 0.0 is bottom (min) final topPos = constraints.maxHeight * (1 - normalized) - 10; // -HalfKnob return Positioned( top: topPos.clamp(0, constraints.maxHeight - 20), child: Container( width: 18, height: 18, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)], ), ), ); } ), ], ), ), const SizedBox(height: 8), // Moon Icon (Dark) Icon(LucideIcons.moon, color: _currentExposureOffset < -0.5 ? Colors.blue[200] : Colors.white54, size: 20), // Value Text if (_currentExposureOffset.abs() > 0.1) Padding( padding: const EdgeInsets.only(top: 4), child: Text( _currentExposureOffset > 0 ? '+${_currentExposureOffset.toStringAsFixed(1)}' : _currentExposureOffset.toStringAsFixed(1), style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold, shadows: [Shadow(color: Colors.black, blurRadius: 2)]), ), ), ], ), ), ), // Overlay UI SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Top Bar Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( icon: const Icon(LucideIcons.x, color: Colors.white, size: 32), onPressed: () => Navigator.of(context).pop(), ), // iOS-style Zoom Buttons Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildZoomButton('1.0', 1.0), const SizedBox(width: 8), _buildZoomButton('2.0', 2.0), const SizedBox(width: 8), _buildZoomButton('3.0', 3.0), ], ), ), ], ), ), // Bottom Control Area Padding( padding: const EdgeInsets.only(bottom: 32.0, left: 24, right: 24), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ // Gallery Button (Left) IconButton( icon: const Icon(LucideIcons.image, color: Colors.white, size: 32), onPressed: _pickFromGallery, tooltip: 'ギャラリーから選択', ), // Shutter Button (Center) GestureDetector( onTap: _takePicture, child: Container( height: 80, width: 80, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: _quotaLockoutTime != null ? Colors.red : Colors.white, width: 4 ), color: _isTakingPicture ? Colors.white.withValues(alpha: 0.5) : (_quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent), ), child: Center( child: _quotaLockoutTime != null ? const Icon(LucideIcons.timer, color: Colors.white, size: 30) : Container( height: 60, width: 60, decoration: BoxDecoration( shape: BoxShape.circle, color: _quotaLockoutTime != null ? Colors.grey : Colors.white, ), ), ), ), ), // Right Spacer -> Analyze Button if images exist if (_capturedImages.isNotEmpty) IconButton( icon: Badge( label: Text('${_capturedImages.length}'), child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40), ), onPressed: _analyzeImages, tooltip: '解析を開始', ) else const SizedBox(width: 48), ], ), ), ], ), ), if (_isTakingPicture) Container( color: Colors.black.withValues(alpha: 0.5), child: const Center( child: CircularProgressIndicator(), ), ), ], ); } else { return const Center(child: CircularProgressIndicator()); } }, ), ); } Widget _buildZoomButton(String label, double zoom) { // Current Zoom Logic: Highlight if close final isActive = (_currentZoom - zoom).abs() < 0.3; return GestureDetector( onTap: () async { if (_controller == null || !_controller!.value.isInitialized) return; final targetZoom = zoom.clamp(_minZoom, _maxZoom); try { await _controller!.setZoomLevel(targetZoom); setState(() => _currentZoom = targetZoom); } catch (e) { debugPrint('Zoom error: $e'); } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: isActive ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(16), ), child: Text( label, style: TextStyle( color: isActive ? Colors.black : Colors.white, fontSize: 14, fontWeight: isActive ? FontWeight.bold : FontWeight.normal, ), ), ), ); } }