651 lines
23 KiB
Dart
651 lines
23 KiB
Dart
import 'dart:async'; // Timer
|
||
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/ocr_service.dart';
|
||
import '../widgets/analyzing_dialog.dart';
|
||
import '../models/sake_item.dart';
|
||
import '../theme/app_theme.dart';
|
||
import 'package:lucide_icons/lucide_icons.dart';
|
||
|
||
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 {
|
||
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 {
|
||
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();
|
||
final String imagePath = join(directory.path, '${const Uuid().v4()}.jpg');
|
||
await image.saveTo(imagePath);
|
||
|
||
// 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;
|
||
|
||
// IF RETURN PATH Mode
|
||
if (widget.mode == CameraMode.returnPath) {
|
||
Navigator.of(context).pop(imagePath);
|
||
return;
|
||
}
|
||
|
||
_capturedImages.add(imagePath);
|
||
|
||
// Show Confirmation Dialog
|
||
await showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('写真を保存しました'),
|
||
content: const Text('さらに別の面も撮影すると、\nAI解析の精度が大幅にアップします!'),
|
||
actions: [
|
||
OutlinedButton(
|
||
onPressed: () {
|
||
// Start Analysis
|
||
Navigator.of(context).pop();
|
||
_analyzeImages();
|
||
},
|
||
child: const Text('解析開始'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () {
|
||
// Take another photo (Dismiss dialog)
|
||
Navigator.of(context).pop();
|
||
},
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: AppTheme.posimaiBlue,
|
||
foregroundColor: Colors.white,
|
||
),
|
||
child: const Text('さらに撮影'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
} catch (e) {
|
||
debugPrint('Capture Error: $e');
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isTakingPicture = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _analyzeImages() async {
|
||
if (_capturedImages.isEmpty) return;
|
||
|
||
// Show AnalyzingDialog
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) => const AnalyzingDialog(),
|
||
);
|
||
|
||
try {
|
||
// [Phase 3-C Revised] Hybrid Analysis Implementation
|
||
final ocrService = OcrService();
|
||
final StringBuffer extractedBuffer = StringBuffer();
|
||
|
||
try {
|
||
for (final path in _capturedImages) {
|
||
final text = await ocrService.extractText(path);
|
||
if (text.isNotEmpty) {
|
||
extractedBuffer.writeln(text);
|
||
}
|
||
}
|
||
} finally {
|
||
ocrService.dispose(); // Ensure resources are released
|
||
}
|
||
|
||
final extractedText = extractedBuffer.toString().trim();
|
||
debugPrint('OCR Extracted Text (${extractedText.length} chars):');
|
||
if (extractedText.isNotEmpty) {
|
||
debugPrint('${extractedText.substring(0, extractedText.length > 100 ? 100 : extractedText.length)}...');
|
||
}
|
||
|
||
// Hybrid Decision Logic (Threshold: 30 chars)
|
||
SakeAnalysisResult result;
|
||
final geminiService = GeminiService();
|
||
|
||
if (extractedText.length > 30) {
|
||
debugPrint('✅ OCR SUCCESS: Using Hybrid Analysis (Text + Images)');
|
||
// Send both text and images (images allow AI to correct OCR errors)
|
||
result = await geminiService.analyzeSakeHybrid(extractedText, _capturedImages);
|
||
} else {
|
||
debugPrint('⚠️ OCR INSUFFICIENT (${extractedText.length} chars): Fallback to Image Analysis');
|
||
result = await geminiService.analyzeSakeLabel(_capturedImages);
|
||
}
|
||
|
||
// Create SakeItem
|
||
// 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,
|
||
),
|
||
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);
|
||
|
||
// 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)');
|
||
|
||
if (!mounted) return;
|
||
|
||
// Close Dialog
|
||
Navigator.of(context).pop();
|
||
|
||
// Close Camera Screen (Return to Home)
|
||
Navigator.of(context).pop();
|
||
|
||
// Success Message
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('${sakeItem.displayData.name} を登録しました!'),
|
||
duration: const Duration(seconds: 2),
|
||
),
|
||
);
|
||
|
||
} 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),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Instagram-style Exposure Slider
|
||
Positioned(
|
||
right: 16,
|
||
top: MediaQuery.of(context).size.height * 0.25,
|
||
child: GestureDetector(
|
||
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.03;
|
||
final delta = -details.delta.dy * sensitivity;
|
||
|
||
if (_controller == null || !_controller!.value.isInitialized) return;
|
||
|
||
final newValue = (_currentExposureOffset + delta).clamp(_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: 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
|
||
Container(
|
||
height: 180,
|
||
width: 4, // Thin track
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withValues(alpha: 0.3),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
child: Stack(
|
||
alignment: Alignment.bottomCenter,
|
||
children: [
|
||
// Center Marker
|
||
Align(alignment: Alignment.center, child: Container(height: 2, width: 12, color: Colors.white54)),
|
||
|
||
// Knob
|
||
LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final range = _maxExposure - _minExposure;
|
||
if (range == 0) return const SizedBox();
|
||
|
||
final normalized = (_currentExposureOffset - _minExposure) / range;
|
||
// 1.0 is top (max), 0.0 is bottom (min)
|
||
final topPos = constraints.maxHeight * (1 - normalized) - 10; // -HalfKnob
|
||
|
||
return Positioned(
|
||
top: topPos.clamp(0, constraints.maxHeight - 20),
|
||
child: Container(
|
||
width: 18,
|
||
height: 18,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
shape: BoxShape.circle,
|
||
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Shutter Button
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 32.0),
|
||
child: 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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|