import 'dart:async'; // Timer import 'dart:io'; 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 '../providers/sake_list_provider.dart'; import '../theme/app_theme.dart'; import 'package:lucide_icons/lucide_icons.dart'; 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 { 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'); } } 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; // IF RETURN PATH Mode if (widget.mode == CameraMode.returnPath) { Navigator.of(context).pop(imagePath); return; } _capturedImages.add(imagePath); // Show Confirmation Dialog await showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: const Text('写真を保存しました'), content: const Text('さらに別の面も撮影すると、\nAI解析の精度が大幅にアップします!'), actions: [ OutlinedButton( onPressed: () { // Start Analysis Navigator.of(context).pop(); _analyzeImages(); }, child: const Text('解析開始'), ), FilledButton( onPressed: () { // Take another photo (Dismiss dialog) Navigator.of(context).pop(); }, style: FilledButton.styleFrom( backgroundColor: AppTheme.posimaiBlue, foregroundColor: Colors.white, ), child: const Text('さらに撮影'), ), ], ), ); } catch (e) { debugPrint('Capture Error: $e'); } finally { if (mounted) { setState(() { _isTakingPicture = false; }); } } } 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); // 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)'); if (!mounted) return; // Close Dialog Navigator.of(context).pop(); // Close Camera Screen (Return to Home) Navigator.of(context).pop(); // Success Message ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${sakeItem.displayData.name} を登録しました!'), duration: const Duration(seconds: 2), ), ); } 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.withOpacity(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.withOpacity(0.6), borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white.withOpacity(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), ], ), ), ], ), ), // Shutter Button Padding( padding: const EdgeInsets.only(bottom: 32.0), child: 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, ), ), ), ), ), ), ], ), ), 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, ), ), ), ); } }