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

665 lines
24 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 'dart:io'; // For File class
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
import '../providers/quota_lockout_provider.dart';
import '../services/image_compression_service.dart';
import '../theme/app_colors.dart';
import 'camera_analysis_mixin.dart';
import 'camera_exposure_painter.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, WidgetsBindingObserver, CameraAnalysisMixin<CameraScreen> {
CameraController? _controller;
Future<void>? _initializeControllerFuture;
bool _isTakingPicture = false;
String? _cameraError;
double _minZoom = 1.0;
double _maxZoom = 1.0;
double _currentZoom = 1.0;
double _baseScale = 1.0;
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();
if (cameras.isEmpty) {
if (mounted) {
setState(() => _cameraError = 'カメラが見つかりません。カメラのアクセス権限を確認してください。');
}
return;
}
final firstCamera = cameras.first;
_controller = CameraController(
firstCamera,
ResolutionPreset.high,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg,
);
_initializeControllerFuture = _controller!.initialize().then((_) async {
if (!mounted) return;
_minZoom = await _controller!.getMinZoomLevel();
_maxZoom = await _controller!.getMaxZoomLevel();
_minExposure = await _controller!.getMinExposureOffset();
_maxExposure = await _controller!.getMaxExposureOffset();
await _controller!.setFocusMode(FocusMode.auto);
setState(() {});
});
}
@override
void dispose() {
_controller?.dispose();
_focusRingTimer?.cancel();
super.dispose();
}
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');
}
}
Future<void> _takePicture() async {
// Check Quota Lockout
final quotaLockout = ref.read(quotaLockoutProvider);
if (quotaLockout != null) {
final remaining = quotaLockout.difference(DateTime.now());
if (remaining.isNegative) {
ref.read(quotaLockoutProvider.notifier).set(null);
} 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();
final directory = await getApplicationDocumentsDirectory();
// 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)
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: const Text('ギャラリーへの保存に失敗しました'),
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) {
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;
}
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
if (!mounted) return;
setState(() {
capturedImages.add(compressedPath);
});
debugPrint('Gallery image compressed & persisted: $compressedPath');
} catch (e) {
debugPrint('Gallery image compression error: $e');
// Fallback: Use original path (legacy behavior)
if (!mounted) return;
setState(() {
capturedImages.add(img.path);
});
}
}
// Batch handle - Notification only
if (mounted) {
final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: '解析する',
onPressed: analyzeImages,
textColor: appColors.brandAccent,
),
),
);
}
}
}
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('さらに追加'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final quotaLockout = ref.watch(quotaLockoutProvider);
final appColors = Theme.of(context).extension<AppColors>()!;
if (_cameraError != null) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_cameraError!,
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
),
),
);
}
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),
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: quotaLockout != null ? appColors.error : Colors.white,
width: 4
),
color: _isTakingPicture
? Colors.white.withValues(alpha: 0.5)
: (quotaLockout != null ? appColors.error.withValues(alpha: 0.2) : Colors.transparent),
),
child: Center(
child: quotaLockout != null
? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
: Container(
height: 60,
width: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: quotaLockout != null ? appColors.textTertiary : Colors.white,
),
),
),
),
),
// Right Spacer -> Analyze Button if images exist
if (capturedImages.isNotEmpty)
IconButton(
icon: Badge(
label: Text('${capturedImages.length}'),
child: Icon(LucideIcons.playCircle, color: appColors.success, 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,
),
),
),
);
}
}