2026-01-11 08:17:29 +00:00
|
|
|
|
import 'dart:async'; // Timer
|
2026-01-29 15:54:22 +00:00
|
|
|
|
import 'dart:io'; // For File class
|
2026-01-11 08:17:29 +00:00
|
|
|
|
import 'package:camera/camera.dart';
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
2026-01-29 15:54:22 +00:00
|
|
|
|
import 'package:hive_flutter/hive_flutter.dart';
|
2026-01-11 08:17:29 +00:00
|
|
|
|
import 'package:path/path.dart' show join;
|
|
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
|
|
import 'package:uuid/uuid.dart';
|
2026-01-29 15:54:22 +00:00
|
|
|
|
import 'package:gal/gal.dart';
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
import '../services/gemini_service.dart';
|
2026-01-29 15:54:22 +00:00
|
|
|
|
import '../services/image_compression_service.dart'; // Phase 4 Added
|
2026-01-11 08:17:29 +00:00
|
|
|
|
import '../widgets/analyzing_dialog.dart';
|
|
|
|
|
|
import '../models/sake_item.dart';
|
|
|
|
|
|
import '../theme/app_theme.dart';
|
|
|
|
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
2026-01-15 15:53:44 +00:00
|
|
|
|
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
|
2026-01-13 09:13:23 +00:00
|
|
|
|
import '../providers/theme_provider.dart'; // userProfileProvider
|
2026-01-13 00:57:18 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
enum CameraMode {
|
|
|
|
|
|
createItem,
|
|
|
|
|
|
returnPath,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class CameraScreen extends ConsumerStatefulWidget {
|
|
|
|
|
|
final CameraMode mode;
|
|
|
|
|
|
const CameraScreen({super.key, this.mode = CameraMode.createItem});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
ConsumerState<CameraScreen> createState() => _CameraScreenState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 09:13:23 +00:00
|
|
|
|
class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
2026-01-11 08:17:29 +00:00
|
|
|
|
CameraController? _controller;
|
|
|
|
|
|
Future<void>? _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<void> _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<void> _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<void> _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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 08:40:26 +00:00
|
|
|
|
final List<String> _capturedImages = [];
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
Future<void> _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();
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// 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;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-01-13 00:57:18 +00:00
|
|
|
|
_handleCapturedImage(imagePath);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
debugPrint('Capture Error: $e');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_isTakingPicture = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _pickFromGallery() async {
|
|
|
|
|
|
final picker = ImagePicker();
|
2026-01-15 15:53:44 +00:00
|
|
|
|
// Use standard image_picker (Updated to 1.1.2 for Android 13+)
|
|
|
|
|
|
final List<XFile> images = await picker.pickMultiImage();
|
2026-01-13 00:57:18 +00:00
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-01-13 00:57:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _handleCapturedImage(String imagePath, {bool fromGallery = false}) async {
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// IF RETURN PATH Mode
|
|
|
|
|
|
if (widget.mode == CameraMode.returnPath) {
|
|
|
|
|
|
Navigator.of(context).pop(imagePath);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 00:57:18 +00:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_capturedImages.add(imagePath);
|
|
|
|
|
|
});
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
// Show Confirmation Dialog
|
|
|
|
|
|
await showDialog(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
barrierDismissible: false,
|
|
|
|
|
|
builder: (ctx) => AlertDialog(
|
2026-01-13 00:57:18 +00:00
|
|
|
|
title: Text(fromGallery ? '画像を読み込みました' : '写真を保存しました'),
|
|
|
|
|
|
content: const Text('さらに別の面も撮影・追加すると、\nAI解析の精度がアップします!'),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
actions: [
|
|
|
|
|
|
OutlinedButton(
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
// Start Analysis
|
|
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
|
_analyzeImages();
|
|
|
|
|
|
},
|
|
|
|
|
|
child: const Text('解析開始'),
|
|
|
|
|
|
),
|
|
|
|
|
|
FilledButton(
|
|
|
|
|
|
onPressed: () {
|
2026-01-13 00:57:18 +00:00
|
|
|
|
// Return to capture (Dismiss dialog)
|
2026-01-11 08:17:29 +00:00
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
|
},
|
|
|
|
|
|
style: FilledButton.styleFrom(
|
|
|
|
|
|
backgroundColor: AppTheme.posimaiBlue,
|
|
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
child: const Text('さらに追加'),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _analyzeImages() async {
|
|
|
|
|
|
if (_capturedImages.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Show AnalyzingDialog
|
|
|
|
|
|
showDialog(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
barrierDismissible: false,
|
|
|
|
|
|
builder: (context) => const AnalyzingDialog(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// Direct Gemini Vision Analysis (OCR removed for app size reduction)
|
|
|
|
|
|
debugPrint('📸 Starting Gemini Vision Direct Analysis for ${_capturedImages.length} images');
|
2026-01-11 08:17:29 +00:00
|
|
|
|
final geminiService = GeminiService();
|
2026-01-29 15:54:22 +00:00
|
|
|
|
final result = await geminiService.analyzeSakeLabel(_capturedImages);
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
// 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,
|
fix: Add missing detail fields to HiddenSpecs initialization
Fixed bug where AI analysis detail fields (type, alcoholContent, polishingRatio,
sakeMeterValue, riceVariety, yeast, manufacturingYearMonth) were not being saved
to HiddenSpecs, causing them to display as "-" in the UI.
Root cause: camera_screen.dart was only passing 3 fields (description, tasteStats,
flavorTags) to HiddenSpecs constructor, missing 7 detail fields that Gemini API
was successfully returning.
Verified: All detail fields now display correctly after AI analysis.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-30 15:06:48 +00:00
|
|
|
|
type: result.type,
|
|
|
|
|
|
alcoholContent: result.alcoholContent,
|
|
|
|
|
|
polishingRatio: result.polishingRatio,
|
|
|
|
|
|
sakeMeterValue: result.sakeMeterValue,
|
|
|
|
|
|
riceVariety: result.riceVariety,
|
|
|
|
|
|
yeast: result.yeast,
|
|
|
|
|
|
manufacturingYearMonth: result.manufacturingYearMonth,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
metadata: Metadata(
|
|
|
|
|
|
createdAt: DateTime.now(),
|
|
|
|
|
|
aiConfidence: result.confidenceScore,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Save to Hive
|
|
|
|
|
|
final box = Hive.box<SakeItem>('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<String> currentOrder = (settingsBox.get('sake_sort_order') as List<dynamic>?)
|
|
|
|
|
|
?.cast<String>() ?? [];
|
|
|
|
|
|
currentOrder.insert(0, sakeItem.id); // Insert at beginning
|
|
|
|
|
|
await settingsBox.put('sake_sort_order', currentOrder);
|
|
|
|
|
|
|
2026-01-13 09:13:23 +00:00
|
|
|
|
// --- 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;
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// 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)');
|
2026-01-13 09:13:23 +00:00
|
|
|
|
debugPrint('🎮 Gamification: EXP +10 (Total: ${updatedProfile.totalExp}) Level: $prevLevel -> $newLevel');
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Close Dialog
|
|
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
|
|
|
|
|
|
|
// Close Camera Screen (Return to Home)
|
|
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
|
|
2026-01-13 09:13:23 +00:00
|
|
|
|
// Success Message (with EXP/Level Up info)
|
2026-01-11 08:17:29 +00:00
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
SnackBar(
|
2026-01-13 09:13:23 +00:00
|
|
|
|
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
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
} 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<void>(
|
|
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// Instagram-style Exposure Slider (REBUILT - No LayoutBuilder+Positioned)
|
2026-01-11 08:17:29 +00:00
|
|
|
|
Positioned(
|
2026-01-29 15:54:22 +00:00
|
|
|
|
right: 0,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
top: MediaQuery.of(context).size.height * 0.25,
|
|
|
|
|
|
child: GestureDetector(
|
2026-01-29 15:54:22 +00:00
|
|
|
|
behavior: HitTestBehavior.opaque,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
onVerticalDragUpdate: (details) async {
|
|
|
|
|
|
// Throttling (30ms)
|
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
|
if (_lastExposureUpdate != null && now.difference(_lastExposureUpdate!).inMilliseconds < 30) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// Drag Up = Brighter (+), Down = Darker (-)
|
2026-01-29 15:54:22 +00:00
|
|
|
|
final sensitivity = 0.12; // Perfect finger tracking!
|
|
|
|
|
|
final delta = -details.delta.dy * sensitivity;
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
if (_controller == null || !_controller!.value.isInitialized) return;
|
|
|
|
|
|
|
|
|
|
|
|
final newValue = (_currentExposureOffset + delta).clamp(_minExposure, _maxExposure);
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
|
|
|
|
|
// Debug: Check actual value changes
|
|
|
|
|
|
debugPrint('Exposure Update: delta=${delta.toStringAsFixed(3)}, old=${_currentExposureOffset.toStringAsFixed(2)}, new=${newValue.toStringAsFixed(2)}, range=$_minExposure~$_maxExposure');
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// UI immediate update
|
|
|
|
|
|
setState(() => _currentExposureOffset = newValue);
|
|
|
|
|
|
// Async camera update
|
|
|
|
|
|
_setExposureSafe(newValue);
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
_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);
|
|
|
|
|
|
},
|
2026-01-29 15:54:22 +00:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-01-29 15:54:22 +00:00
|
|
|
|
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)]),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
|
|
// 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(
|
2026-01-11 15:42:39 +00:00
|
|
|
|
color: Colors.black.withValues(alpha: 0.6),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
2026-01-11 15:42:39 +00:00
|
|
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
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),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
// Bottom Control Area
|
2026-01-11 08:17:29 +00:00
|
|
|
|
Padding(
|
2026-01-13 00:57:18 +00:00
|
|
|
|
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: 'ギャラリーから選択',
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
// 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),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
],
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
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;
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
return GestureDetector(
|
|
|
|
|
|
onTap: () async {
|
|
|
|
|
|
if (_controller == null || !_controller!.value.isInitialized) return;
|
|
|
|
|
|
final targetZoom = zoom.clamp(_minZoom, _maxZoom);
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-01-30 08:34:39 +00:00
|
|
|
|
}
|