import 'dart:async'; // Timer import 'dart:io'; // For File class import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 'package:lucide_icons/lucide_icons.dart'; import 'package:image_picker/image_picker.dart'; // Gallery & Camera import '../services/image_compression_service.dart'; import '../theme/app_colors.dart'; import 'camera_analysis_mixin.dart'; import 'camera_exposure_painter.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, WidgetsBindingObserver, CameraAnalysisMixin { CameraController? _controller; Future? _initializeControllerFuture; bool _isTakingPicture = false; String? _cameraError; double _minZoom = 1.0; double _maxZoom = 1.0; double _currentZoom = 1.0; double _baseScale = 1.0; 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(); if (cameras.isEmpty) { if (mounted) { setState(() => _cameraError = 'カメラが見つかりません。カメラのアクセス権限を確認してください。'); } return; } final firstCamera = cameras.first; _controller = CameraController( firstCamera, ResolutionPreset.high, enableAudio: false, imageFormatGroup: ImageFormatGroup.jpeg, ); _initializeControllerFuture = _controller!.initialize().then((_) async { if (!mounted) return; _minZoom = await _controller!.getMinZoomLevel(); _maxZoom = await _controller!.getMaxZoomLevel(); _minExposure = await _controller!.getMinExposureOffset(); _maxExposure = await _controller!.getMaxExposureOffset(); await _controller!.setFocusMode(FocusMode.auto); setState(() {}); }); } @override void dispose() { _controller?.dispose(); _focusRingTimer?.cancel(); super.dispose(); } 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'); } } Future _takePicture() async { // Check Quota Lockout if (quotaLockoutTime != null) { final remaining = quotaLockoutTime!.difference(DateTime.now()); if (remaining.isNegative) { setState(() => quotaLockoutTime = null); // Reset } else { final appColors = Theme.of(context).extension()!; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。'), duration: const Duration(seconds: 5), backgroundColor: appColors.error, ), ); return; } } if (_isTakingPicture || _controller == null || !_controller!.value.isInitialized) { return; } setState(() { _isTakingPicture = true; }); try { await _initializeControllerFuture; final image = await _controller!.takePicture(); final directory = await getApplicationDocumentsDirectory(); // 1. Save temp raw file final tempPath = join(directory.path, '${const Uuid().v4()}_temp.jpg'); await image.saveTo(tempPath); // 2. Compress to final path final finalPath = join(directory.path, '${const Uuid().v4()}.jpg'); final compressedPath = await ImageCompressionService.compressForGemini(tempPath, targetPath: finalPath); // 3. Clean up temp file try { await File(tempPath).delete(); } catch (e) { debugPrint('Warning: Failed to delete temp file: $e'); } final imagePath = compressedPath; // Save to Gallery (Public) 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) { final appColors = Theme.of(context).extension()!; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('ギャラリー保存に失敗しました: $e'), duration: const Duration(seconds: 4), backgroundColor: appColors.warning, ), ); } } 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) { final directory = await getApplicationDocumentsDirectory(); final finalPath = join(directory.path, '${const Uuid().v4()}.jpg'); final compressedPath = await ImageCompressionService.compressForGemini( images.first.path, targetPath: finalPath, ); if (!mounted) return; Navigator.of(context).pop(compressedPath); return; } final directory = await getApplicationDocumentsDirectory(); for (var img in images) { try { // 1. Generate permanent path final finalPath = join(directory.path, '${const Uuid().v4()}.jpg'); // 2. Compress temporary cache image to permanent location final compressedPath = await ImageCompressionService.compressForGemini( img.path, targetPath: finalPath, ); // 3. Add compressed permanent path to capture list if (!mounted) return; setState(() { capturedImages.add(compressedPath); }); debugPrint('Gallery image compressed & persisted: $compressedPath'); } catch (e) { debugPrint('Gallery image compression error: $e'); // Fallback: Use original path (legacy behavior) if (!mounted) return; setState(() { 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 final appColors = Theme.of(context).extension()!; 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(); }, style: OutlinedButton.styleFrom( foregroundColor: appColors.brandPrimary, side: BorderSide(color: appColors.brandPrimary), ), child: const Text('解析開始'), ), FilledButton( onPressed: () { // Return to capture (Dismiss dialog) Navigator.of(context).pop(); }, style: FilledButton.styleFrom( backgroundColor: appColors.brandPrimary, foregroundColor: Colors.white, ), child: const Text('さらに追加'), ), ], ), ); } @override Widget build(BuildContext context) { if (_cameraError != null) { return Scaffold( backgroundColor: Colors.black, appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white), body: Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( _cameraError!, style: const TextStyle(color: Colors.white70), textAlign: TextAlign.center, ), ), ), ); } 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 (REBUILT - No LayoutBuilder+Positioned) Positioned( right: 0, top: MediaQuery.of(context).size.height * 0.25, child: GestureDetector( behavior: HitTestBehavior.opaque, 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.12; // Perfect finger tracking! final delta = -details.delta.dy * sensitivity; if (_controller == null || !_controller!.value.isInitialized) return; final newValue = (_currentExposureOffset + delta).clamp(_minExposure, _maxExposure); // Debug: Check actual value changes debugPrint('Exposure Update: delta=${delta.toStringAsFixed(3)}, old=${_currentExposureOffset.toStringAsFixed(2)}, new=${newValue.toStringAsFixed(2)}, range=$_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: Container( width: 80, // Wide touch area padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), 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 with Knob (NO LayoutBuilder) SizedBox( height: 180, width: 48, // Wider for easier tapping child: CustomPaint( key: ValueKey(_currentExposureOffset), painter: ExposureSliderPainter( currentValue: _currentExposureOffset, minValue: _minExposure, maxValue: _maxExposure, ), ), ), 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, ), ), ), ); } }