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: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/image_compression_service.dart'; // Phase 4 Added import '../services/gamification_service.dart'; // Badge check import '../services/network_service.dart'; // Phase 1: Offline check import '../services/draft_service.dart'; // Phase 1: Draft save import '../widgets/analyzing_dialog.dart'; import '../models/sake_item.dart'; import '../theme/app_colors.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:image_picker/image_picker.dart'; // Gallery & Camera import '../providers/theme_provider.dart'; // userProfileProvider import '../providers/sakenowa_providers.dart'; // sakenowa auto-matching 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 { 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(); // Save image locally (App Sandbox) final directory = await getApplicationDocumentsDirectory(); // Phase 4 Improvement: Apply Compression (Target Max 1MB) // 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) - 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) { 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) { // Phase D3: Compress and persist gallery image 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; } // Phase D3: Compress and persist all gallery images to Documents directory 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 setState(() { _capturedImages.add(compressedPath); }); debugPrint('✅ Gallery image compressed & persisted: $compressedPath'); } catch (e) { debugPrint('⚠️ Gallery image compression error: $e'); // Fallback: Use original path (legacy behavior) 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('さらに追加'), ), ], ), ); } Future _analyzeImages() async { if (_capturedImages.isEmpty) return; // Phase 1: オフライン検知 - Draft保存処理 final isOnline = await NetworkService.isOnline(); if (!isOnline) { // オフライン時: Draft として保存 debugPrint('📴 Offline detected: Saving as draft...'); try { // 🔧 FIX: 複数画像をすべて保存 await DraftService.saveDraft(_capturedImages); if (!mounted) return; // ユーザーに通知 ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(LucideIcons.wifiOff, color: Colors.orange, size: 16), SizedBox(width: 8), Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)), ], ), SizedBox(height: 4), Text('写真を「解析待ち」として保存しました。'), Text('オンライン復帰後、ホーム画面から解析できます。'), ], ), duration: Duration(seconds: 5), backgroundColor: Colors.orange, ), ); // カメラ画面を閉じる Navigator.of(context).pop(); return; } catch (e) { debugPrint('⚠️ Draft save error: $e'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Draft保存エラー: $e')), ); return; } } // オンライン時: 通常の解析フロー // Show AnalyzingDialog if (!mounted) return; showDialog( context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog(), ); try { // Direct Gemini Vision Analysis (OCR removed for app size reduction) debugPrint('📸 Starting Gemini Vision Direct Analysis for ${_capturedImages.length} images'); final geminiService = GeminiService(); final result = await geminiService.analyzeSakeLabel(_capturedImages); // 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, type: result.type, alcoholContent: result.alcoholContent, polishingRatio: result.polishingRatio, sakeMeterValue: result.sakeMeterValue, riceVariety: result.riceVariety, yeast: result.yeast, manufacturingYearMonth: result.manufacturingYearMonth, ), metadata: Metadata( createdAt: DateTime.now(), aiConfidence: result.confidenceScore, ), ); // Save to Hive final box = Hive.box('sake_items'); await box.add(sakeItem); // ✅ さけのわ自動マッチング(非同期・バックグラウンド) // エラーが発生しても登録フローを中断しない _performSakenowaMatching(sakeItem).catchError((error) { debugPrint('⚠️ Sakenowa auto-matching failed (non-critical): $error'); }); // 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); // Check and unlock badges final newBadges = await GamificationService.checkAndUnlockBadges(ref); // 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.displayName} (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/Badge info) final isDark = Theme.of(context).brightness == Brightness.dark; final List messageWidgets = [ Text('${sakeItem.displayData.displayName} を登録しました!'), const SizedBox(height: 4), Row( children: [ Icon(LucideIcons.sparkles, color: isDark ? Colors.yellow.shade300 : Colors.yellow, size: 16), const SizedBox(width: 8), Text( '経験値 +10 GET! ${isLevelUp ? " (Level UP!)" : ""}', style: TextStyle( fontWeight: FontWeight.bold, color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent, ), ), ], ), ]; // Add badge notifications if (newBadges.isNotEmpty) { messageWidgets.add(const SizedBox(height: 8)); for (var badge in newBadges) { messageWidgets.add( Row( children: [ Text(badge.icon, style: const TextStyle(fontSize: 16)), const SizedBox(width: 8), Text( 'バッジ獲得: ${badge.name}', style: TextStyle( fontWeight: FontWeight.bold, color: isDark ? Colors.green.shade300 : Colors.greenAccent, ), ), ], ), ); } } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: messageWidgets, ), duration: Duration(seconds: newBadges.isNotEmpty ? 6 : 4), // Longer for badges ), ); } 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)); }); } final appColors = Theme.of(context).extension()!; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('解析エラー: $e'), duration: const Duration(seconds: 5), backgroundColor: appColors.error, ), ); } } } @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 (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), // Force repaint on value change 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, ), ), ), ); } /// ✅ さけのわ自動マッチング処理 /// /// 登録後にバックグラウンドで実行 /// エラーが発生しても登録フローを中断しない Future _performSakenowaMatching(SakeItem sakeItem) async { try { debugPrint('🔍 Starting sakenowa auto-matching for: ${sakeItem.displayData.displayName}'); final matchingService = ref.read(sakenowaAutoMatchingServiceProvider); // マッチング実行(最小スコア: 0.7、自動適用: true) final result = await matchingService.matchSake( sakeItem: sakeItem, minScore: 0.7, autoApply: true, // マッチング成功時に自動で適用 ); if (result.hasMatch) { debugPrint('✅ Sakenowa matching successful!'); debugPrint(' Brand: ${result.brand?.name}'); debugPrint(' Brewery: ${result.brewery?.name}'); debugPrint(' Score: ${result.score.toStringAsFixed(2)}'); debugPrint(' Confident: ${result.isConfident}'); } else { debugPrint('ℹ️ No sakenowa match found (score: ${result.score.toStringAsFixed(2)})'); } } catch (e, stackTrace) { // エラーログのみ、ユーザーには通知しない(登録は成功しているため) debugPrint('💥 Sakenowa auto-matching error: $e'); debugPrint('Stack trace: $stackTrace'); } } } // Custom Painter for Exposure Slider class _ExposureSliderPainter extends CustomPainter { final double currentValue; final double minValue; final double maxValue; _ExposureSliderPainter({ required this.currentValue, required this.minValue, required this.maxValue, }); @override void paint(Canvas canvas, Size size) { final trackPaint = Paint() ..color = Colors.white.withValues(alpha: 0.3) ..strokeWidth = 4 ..strokeCap = StrokeCap.round; final centerLinePaint = Paint() ..color = Colors.white54 ..strokeWidth = 2; final knobPaint = Paint() ..color = Colors.white ..style = PaintingStyle.fill; final knobShadowPaint = Paint() ..color = Colors.black26 ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); // Draw vertical track (centered) final trackX = size.width / 2; canvas.drawLine( Offset(trackX, 10), Offset(trackX, size.height - 10), trackPaint, ); // Draw center marker canvas.drawLine( Offset(trackX - 6, size.height / 2), Offset(trackX + 6, size.height / 2), centerLinePaint, ); // Calculate knob position final range = maxValue - minValue; if (range > 0) { // Normalize currentValue to 0.0-1.0 range // minValue (e.g., -4.0) -> 0.0 (bottom) // 0.0 (center) -> 0.5 (middle) // maxValue (e.g., +4.0) -> 1.0 (top) final normalized = (currentValue - minValue) / range; // Debug debugPrint('CustomPainter: value=$currentValue, min=$minValue, max=$maxValue, range=$range, normalized=${normalized.toStringAsFixed(3)}'); // Map to Y coordinate: 0.0 (normalized) -> bottom, 1.0 (normalized) -> top final knobY = (size.height - 20) * (1.0 - normalized) + 10; debugPrint(' -> knobY=${knobY.toStringAsFixed(1)} (height=${size.height})'); // Draw knob shadow canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint); // Draw knob canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint); } } @override bool shouldRepaint(_ExposureSliderPainter oldDelegate) { return oldDelegate.currentValue != currentValue || oldDelegate.minValue != minValue || oldDelegate.maxValue != maxValue; } }