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-30 23:22:23 +00:00
|
|
|
|
import '../services/gamification_service.dart'; // Badge check
|
2026-02-15 15:13:12 +00:00
|
|
|
|
import '../services/network_service.dart'; // Phase 1: Offline check
|
|
|
|
|
|
import '../services/draft_service.dart'; // Phase 1: Draft save
|
2026-01-11 08:17:29 +00:00
|
|
|
|
import '../widgets/analyzing_dialog.dart';
|
|
|
|
|
|
import '../models/sake_item.dart';
|
2026-02-15 15:13:12 +00:00
|
|
|
|
import '../theme/app_colors.dart';
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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-02-15 15:13:12 +00:00
|
|
|
|
import '../providers/sakenowa_providers.dart'; // sakenowa auto-matching
|
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 {
|
2026-02-15 15:13:12 +00:00
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-02-15 15:13:12 +00:00
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。'),
|
|
|
|
|
|
duration: const Duration(seconds: 5),
|
|
|
|
|
|
backgroundColor: appColors.error,
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
);
|
|
|
|
|
|
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) {
|
2026-02-15 15:13:12 +00:00
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-02-15 15:13:12 +00:00
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text('ギャラリー保存に失敗しました: $e'),
|
|
|
|
|
|
duration: const Duration(seconds: 4),
|
|
|
|
|
|
backgroundColor: appColors.warning,
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-02-15 15:13:12 +00:00
|
|
|
|
// 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);
|
2026-01-15 15:53:44 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 15:13:12 +00:00
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
// 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
|
2026-02-15 15:13:12 +00:00
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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();
|
|
|
|
|
|
},
|
2026-02-15 15:13:12 +00:00
|
|
|
|
style: OutlinedButton.styleFrom(
|
|
|
|
|
|
foregroundColor: appColors.brandPrimary,
|
|
|
|
|
|
side: BorderSide(color: appColors.brandPrimary),
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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(
|
2026-02-15 15:13:12 +00:00
|
|
|
|
backgroundColor: appColors.brandPrimary,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-15 15:13:12 +00:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// オンライン時: 通常の解析フロー
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// Show AnalyzingDialog
|
2026-02-15 15:13:12 +00:00
|
|
|
|
if (!mounted) return;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-02-15 15:13:12 +00:00
|
|
|
|
// ✅ さけのわ自動マッチング(非同期・バックグラウンド)
|
|
|
|
|
|
// エラーが発生しても登録フローを中断しない
|
|
|
|
|
|
_performSakenowaMatching(sakeItem).catchError((error) {
|
|
|
|
|
|
debugPrint('⚠️ Sakenowa auto-matching failed (non-critical): $error');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// 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;
|
2026-01-30 23:22:23 +00:00
|
|
|
|
|
2026-01-13 09:13:23 +00:00
|
|
|
|
await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + 10);
|
2026-01-30 23:22:23 +00:00
|
|
|
|
|
|
|
|
|
|
// Check and unlock badges
|
|
|
|
|
|
final newBadges = await GamificationService.checkAndUnlockBadges(ref);
|
|
|
|
|
|
|
2026-01-13 09:13:23 +00:00
|
|
|
|
// 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
|
2026-02-15 15:13:12 +00:00
|
|
|
|
debugPrint('✅ Saved to Hive: ${sakeItem.displayData.displayName} (ID: ${sakeItem.id})');
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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-30 23:22:23 +00:00
|
|
|
|
// Success Message (with EXP/Level Up/Badge info)
|
2026-02-15 15:13:12 +00:00
|
|
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
2026-01-30 23:22:23 +00:00
|
|
|
|
final List<Widget> messageWidgets = [
|
2026-02-15 15:13:12 +00:00
|
|
|
|
Text('${sakeItem.displayData.displayName} を登録しました!'),
|
2026-01-30 23:22:23 +00:00
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
2026-02-15 15:13:12 +00:00
|
|
|
|
Icon(LucideIcons.sparkles,
|
|
|
|
|
|
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
|
|
|
|
|
|
size: 16),
|
2026-01-30 23:22:23 +00:00
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
'経験値 +10 GET! ${isLevelUp ? " (Level UP!)" : ""}',
|
2026-02-15 15:13:12 +00:00
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
|
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
|
|
|
|
|
|
),
|
2026-01-30 23:22:23 +00:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 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}',
|
2026-02-15 15:13:12 +00:00
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
|
color: isDark ? Colors.green.shade300 : Colors.greenAccent,
|
|
|
|
|
|
),
|
2026-01-30 23:22:23 +00:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
2026-01-30 23:22:23 +00:00
|
|
|
|
children: messageWidgets,
|
2026-01-13 09:13:23 +00:00
|
|
|
|
),
|
2026-01-30 23:22:23 +00:00
|
|
|
|
duration: Duration(seconds: newBadges.isNotEmpty ? 6 : 4), // Longer for badges
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 15:13:12 +00:00
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-02-15 15:13:12 +00:00
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text('解析エラー: $e'),
|
|
|
|
|
|
duration: const Duration(seconds: 5),
|
|
|
|
|
|
backgroundColor: appColors.error,
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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-02-15 15:13:12 +00:00
|
|
|
|
|
|
|
|
|
|
/// ✅ さけのわ自動マッチング処理
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 登録後にバックグラウンドで実行
|
|
|
|
|
|
/// エラーが発生しても登録フローを中断しない
|
|
|
|
|
|
Future<void> _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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
2026-02-15 15:13:12 +00:00
|
|
|
|
..style = PaintingStyle.fill; final knobShadowPaint = Paint()
|
2026-01-29 15:54:22 +00:00
|
|
|
|
..color = Colors.black26
|
2026-02-15 15:13:12 +00:00
|
|
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); // Draw vertical track (centered)
|
2026-01-29 15:54:22 +00:00
|
|
|
|
final trackX = size.width / 2;
|
|
|
|
|
|
canvas.drawLine(
|
|
|
|
|
|
Offset(trackX, 10),
|
|
|
|
|
|
Offset(trackX, size.height - 10),
|
|
|
|
|
|
trackPaint,
|
2026-02-15 15:13:12 +00:00
|
|
|
|
); // Draw center marker
|
2026-01-29 15:54:22 +00:00
|
|
|
|
canvas.drawLine(
|
|
|
|
|
|
Offset(trackX - 6, size.height / 2),
|
|
|
|
|
|
Offset(trackX + 6, size.height / 2),
|
|
|
|
|
|
centerLinePaint,
|
2026-02-15 15:13:12 +00:00
|
|
|
|
); // Calculate knob position
|
2026-01-29 15:54:22 +00:00
|
|
|
|
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)
|
2026-02-15 15:13:12 +00:00
|
|
|
|
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
|
2026-01-29 15:54:22 +00:00
|
|
|
|
canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint);
|
|
|
|
|
|
// Draw knob
|
|
|
|
|
|
canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint);
|
|
|
|
|
|
}
|
2026-01-30 23:22:23 +00:00
|
|
|
|
} @override
|
2026-01-29 15:54:22 +00:00
|
|
|
|
bool shouldRepaint(_ExposureSliderPainter oldDelegate) {
|
|
|
|
|
|
return oldDelegate.currentValue != currentValue ||
|
|
|
|
|
|
oldDelegate.minValue != minValue ||
|
|
|
|
|
|
oldDelegate.maxValue != maxValue;
|
|
|
|
|
|
}
|
2026-01-30 23:22:23 +00:00
|
|
|
|
}
|