ponshu-room-lite/lib/screens/camera_screen.dart

967 lines
35 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
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');
}
}
final List<String> _capturedImages = [];
Future<void> _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<AppColors>()!;
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<AppColors>()!;
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<void> _pickFromGallery() async {
final picker = ImagePicker();
// Use standard image_picker (Updated to 1.1.2 for Android 13+)
final List<XFile> 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<void> _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<AppColors>()!;
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<void> _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<SakeItem>('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<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);
// --- 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<Widget> 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<AppColors>()!;
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<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),
),
),
),
// 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<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');
}
}
}
// 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;
}
}