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

756 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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 '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';
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
import '../models/user_profile.dart';
import '../providers/theme_provider.dart'; // userProfileProvider
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 {
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;
_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) {
Navigator.of(context).pop(images.first.path);
return;
}
setState(() {
for (var img in images) {
_capturedImages.add(img.path);
}
});
// Batch handle - Notification only
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: '解析する',
onPressed: _analyzeImages,
textColor: Colors.yellow,
),
),
);
}
}
}
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
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();
},
child: const Text('解析開始'),
),
FilledButton(
onPressed: () {
// Return to capture (Dismiss dialog)
Navigator.of(context).pop();
},
style: FilledButton.styleFrom(
backgroundColor: AppTheme.posimaiBlue,
foregroundColor: Colors.white,
),
child: const Text('さらに追加'),
),
],
),
);
}
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);
// --- v1.3 Gamification Hook ---
// Award EXP
final userProfileState = ref.read(userProfileProvider);
final prevLevel = userProfileState.level;
await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + 10);
// Refetch updated state for level comparison
final updatedProfile = ref.read(userProfileProvider);
final newLevel = updatedProfile.level;
final isLevelUp = newLevel > prevLevel;
// 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)');
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 info)
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${sakeItem.displayData.name} を登録しました!'),
const SizedBox(height: 4),
Row(
children: [
const Icon(LucideIcons.sparkles, color: Colors.yellow, size: 16),
const SizedBox(width: 8),
Text(
'経験値 +10 GET! ${isLevelUp ? " (Level UP!)" : ""}',
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.yellowAccent),
),
],
),
],
),
duration: const Duration(seconds: 4), // Longer display for level up
),
);
} 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),
],
),
),
],
),
),
// 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,
),
),
),
);
}
}