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

967 lines
35 KiB
Dart
Raw Permalink Normal View History

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';
2026-01-15 15:53:44 +00:00
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();
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-15 15:53:44 +00:00
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);
2026-01-15 15:53:44 +00:00
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);
});
}
}
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,
),
),
);
}
}
}
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,
),
),
),
),
),
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),
],
),
),
],
),
),
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;
}
}