2026-01-11 08:17:29 +00:00
|
|
|
|
import 'dart:async'; // Timer
|
2026-01-29 15:54:22 +00:00
|
|
|
|
import 'dart:io'; // For File class
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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';
|
2026-01-29 15:54:22 +00:00
|
|
|
|
import 'package:gal/gal.dart';
|
2026-04-16 04:20:53 +00:00
|
|
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
|
|
|
|
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
2026-04-04 14:02:52 +00:00
|
|
|
|
import '../services/image_compression_service.dart';
|
2026-02-15 15:13:12 +00:00
|
|
|
|
import '../theme/app_colors.dart';
|
2026-04-16 04:20:53 +00:00
|
|
|
|
import 'camera_analysis_mixin.dart';
|
2026-01-13 00:57:18 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
enum CameraMode {
|
|
|
|
|
|
createItem,
|
|
|
|
|
|
returnPath,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class CameraScreen extends ConsumerStatefulWidget {
|
|
|
|
|
|
final CameraMode mode;
|
|
|
|
|
|
const CameraScreen({super.key, this.mode = CameraMode.createItem});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
ConsumerState<CameraScreen> createState() => _CameraScreenState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 04:20:53 +00:00
|
|
|
|
class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerProviderStateMixin, WidgetsBindingObserver, CameraAnalysisMixin<CameraScreen> {
|
2026-01-11 08:17:29 +00:00
|
|
|
|
CameraController? _controller;
|
|
|
|
|
|
Future<void>? _initializeControllerFuture;
|
|
|
|
|
|
bool _isTakingPicture = false;
|
2026-04-15 04:30:00 +00:00
|
|
|
|
String? _cameraError;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
double _minZoom = 1.0;
|
|
|
|
|
|
double _maxZoom = 1.0;
|
|
|
|
|
|
double _currentZoom = 1.0;
|
2026-04-04 14:02:52 +00:00
|
|
|
|
double _baseScale = 1.0;
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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();
|
2026-04-15 04:30:00 +00:00
|
|
|
|
if (cameras.isEmpty) {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() => _cameraError = 'カメラが見つかりません。カメラのアクセス権限を確認してください。');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-11 08:17:29 +00:00
|
|
|
|
final firstCamera = cameras.first;
|
|
|
|
|
|
|
|
|
|
|
|
_controller = CameraController(
|
|
|
|
|
|
firstCamera,
|
2026-04-15 04:30:00 +00:00
|
|
|
|
ResolutionPreset.high,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
enableAudio: false,
|
|
|
|
|
|
imageFormatGroup: ImageFormatGroup.jpeg,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
_initializeControllerFuture = _controller!.initialize().then((_) async {
|
|
|
|
|
|
if (!mounted) return;
|
2026-04-04 14:02:52 +00:00
|
|
|
|
_minZoom = await _controller!.getMinZoomLevel();
|
2026-01-11 08:17:29 +00:00
|
|
|
|
_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
|
2026-04-16 04:20:53 +00:00
|
|
|
|
if (quotaLockoutTime != null) {
|
|
|
|
|
|
final remaining = quotaLockoutTime!.difference(DateTime.now());
|
2026-01-11 08:17:29 +00:00
|
|
|
|
if (remaining.isNegative) {
|
2026-04-16 04:20:53 +00:00
|
|
|
|
setState(() => quotaLockoutTime = null); // Reset
|
2026-01-11 08:17:29 +00:00
|
|
|
|
} else {
|
2026-02-15 15:13:12 +00:00
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-02-15 15:13:12 +00:00
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。'),
|
|
|
|
|
|
duration: const Duration(seconds: 5),
|
|
|
|
|
|
backgroundColor: appColors.error,
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (_isTakingPicture || _controller == null || !_controller!.value.isInitialized) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_isTakingPicture = true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _initializeControllerFuture;
|
|
|
|
|
|
final image = await _controller!.takePicture();
|
|
|
|
|
|
|
|
|
|
|
|
final directory = await getApplicationDocumentsDirectory();
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// 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;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
2026-04-04 18:02:01 +00:00
|
|
|
|
// Save to Gallery (Public)
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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) {
|
2026-02-15 15:13:12 +00:00
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-02-15 15:13:12 +00:00
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text('ギャラリー保存に失敗しました: $e'),
|
|
|
|
|
|
duration: const Duration(seconds: 4),
|
|
|
|
|
|
backgroundColor: appColors.warning,
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
2026-01-13 00:57:18 +00:00
|
|
|
|
_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-13 00:57:18 +00:00
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
if (images.isNotEmpty && mounted) {
|
|
|
|
|
|
// IF RETURN PATH Mode (Only supports one)
|
|
|
|
|
|
if (widget.mode == CameraMode.returnPath) {
|
2026-04-04 14:02:52 +00:00
|
|
|
|
final directory = await getApplicationDocumentsDirectory();
|
2026-02-15 15:13:12 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 15:13:12 +00:00
|
|
|
|
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
|
2026-04-15 04:30:00 +00:00
|
|
|
|
if (!mounted) return;
|
2026-02-15 15:13:12 +00:00
|
|
|
|
setState(() {
|
2026-04-16 04:20:53 +00:00
|
|
|
|
capturedImages.add(compressedPath);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-11 22:25:24 +00:00
|
|
|
|
debugPrint('Gallery image compressed & persisted: $compressedPath');
|
2026-02-15 15:13:12 +00:00
|
|
|
|
} catch (e) {
|
2026-04-11 22:25:24 +00:00
|
|
|
|
debugPrint('Gallery image compression error: $e');
|
2026-02-15 15:13:12 +00:00
|
|
|
|
// Fallback: Use original path (legacy behavior)
|
2026-04-15 04:30:00 +00:00
|
|
|
|
if (!mounted) return;
|
2026-02-15 15:13:12 +00:00
|
|
|
|
setState(() {
|
2026-04-16 04:20:53 +00:00
|
|
|
|
capturedImages.add(img.path);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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: '解析する',
|
2026-04-16 04:20:53 +00:00
|
|
|
|
onPressed: analyzeImages,
|
2026-01-15 15:53:44 +00:00
|
|
|
|
textColor: Colors.yellow,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-01-13 00:57:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _handleCapturedImage(String imagePath, {bool fromGallery = false}) async {
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// IF RETURN PATH Mode
|
|
|
|
|
|
if (widget.mode == CameraMode.returnPath) {
|
|
|
|
|
|
Navigator.of(context).pop(imagePath);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 00:57:18 +00:00
|
|
|
|
setState(() {
|
2026-04-16 04:20:53 +00:00
|
|
|
|
capturedImages.add(imagePath);
|
2026-01-13 00:57:18 +00:00
|
|
|
|
});
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
// Show Confirmation Dialog
|
2026-02-15 15:13:12 +00:00
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
await showDialog(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
barrierDismissible: false,
|
|
|
|
|
|
builder: (ctx) => AlertDialog(
|
2026-01-13 00:57:18 +00:00
|
|
|
|
title: Text(fromGallery ? '画像を読み込みました' : '写真を保存しました'),
|
|
|
|
|
|
content: const Text('さらに別の面も撮影・追加すると、\nAI解析の精度がアップします!'),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
actions: [
|
|
|
|
|
|
OutlinedButton(
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
// Start Analysis
|
|
|
|
|
|
Navigator.of(context).pop();
|
2026-04-16 04:20:53 +00:00
|
|
|
|
analyzeImages();
|
2026-01-11 08:17:29 +00:00
|
|
|
|
},
|
2026-02-15 15:13:12 +00:00
|
|
|
|
style: OutlinedButton.styleFrom(
|
|
|
|
|
|
foregroundColor: appColors.brandPrimary,
|
|
|
|
|
|
side: BorderSide(color: appColors.brandPrimary),
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
child: const Text('解析開始'),
|
|
|
|
|
|
),
|
|
|
|
|
|
FilledButton(
|
|
|
|
|
|
onPressed: () {
|
2026-01-13 00:57:18 +00:00
|
|
|
|
// Return to capture (Dismiss dialog)
|
2026-01-11 08:17:29 +00:00
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
|
},
|
|
|
|
|
|
style: FilledButton.styleFrom(
|
2026-02-15 15:13:12 +00:00
|
|
|
|
backgroundColor: appColors.brandPrimary,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
child: const Text('さらに追加'),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-04-15 04:30:00 +00:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// Instagram-style Exposure Slider (REBUILT - No LayoutBuilder+Positioned)
|
2026-01-11 08:17:29 +00:00
|
|
|
|
Positioned(
|
2026-01-29 15:54:22 +00:00
|
|
|
|
right: 0,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
top: MediaQuery.of(context).size.height * 0.25,
|
|
|
|
|
|
child: GestureDetector(
|
2026-01-29 15:54:22 +00:00
|
|
|
|
behavior: HitTestBehavior.opaque,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
onVerticalDragUpdate: (details) async {
|
|
|
|
|
|
// Throttling (30ms)
|
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
|
if (_lastExposureUpdate != null && now.difference(_lastExposureUpdate!).inMilliseconds < 30) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// Drag Up = Brighter (+), Down = Darker (-)
|
2026-01-29 15:54:22 +00:00
|
|
|
|
final sensitivity = 0.12; // Perfect finger tracking!
|
|
|
|
|
|
final delta = -details.delta.dy * sensitivity;
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
if (_controller == null || !_controller!.value.isInitialized) return;
|
|
|
|
|
|
|
|
|
|
|
|
final newValue = (_currentExposureOffset + delta).clamp(_minExposure, _maxExposure);
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
|
|
|
|
|
// Debug: Check actual value changes
|
|
|
|
|
|
debugPrint('Exposure Update: delta=${delta.toStringAsFixed(3)}, old=${_currentExposureOffset.toStringAsFixed(2)}, new=${newValue.toStringAsFixed(2)}, range=$_minExposure~$_maxExposure');
|
|
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
// UI immediate update
|
|
|
|
|
|
setState(() => _currentExposureOffset = newValue);
|
|
|
|
|
|
// Async camera update
|
|
|
|
|
|
_setExposureSafe(newValue);
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
_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);
|
|
|
|
|
|
},
|
2026-01-29 15:54:22 +00:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-01-29 15:54:22 +00:00
|
|
|
|
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)]),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
|
|
// 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(
|
2026-01-11 15:42:39 +00:00
|
|
|
|
color: Colors.black.withValues(alpha: 0.6),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
2026-01-11 15:42:39 +00:00
|
|
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
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),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
// Bottom Control Area
|
2026-01-11 08:17:29 +00:00
|
|
|
|
Padding(
|
2026-01-13 00:57:18 +00:00
|
|
|
|
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: 'ギャラリーから選択',
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
|
|
|
|
|
|
// Shutter Button (Center)
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: _takePicture,
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
height: 80,
|
|
|
|
|
|
width: 80,
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
|
border: Border.all(
|
2026-04-16 04:20:53 +00:00
|
|
|
|
color: quotaLockoutTime != null ? Colors.red : Colors.white,
|
2026-01-13 00:57:18 +00:00
|
|
|
|
width: 4
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
color: _isTakingPicture
|
|
|
|
|
|
? Colors.white.withValues(alpha: 0.5)
|
2026-04-16 04:20:53 +00:00
|
|
|
|
: (quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
),
|
|
|
|
|
|
child: Center(
|
2026-04-16 04:20:53 +00:00
|
|
|
|
child: quotaLockoutTime != null
|
2026-01-13 00:57:18 +00:00
|
|
|
|
? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
|
|
|
|
|
|
: Container(
|
|
|
|
|
|
height: 60,
|
|
|
|
|
|
width: 60,
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
shape: BoxShape.circle,
|
2026-04-16 04:20:53 +00:00
|
|
|
|
color: quotaLockoutTime != null ? Colors.grey : Colors.white,
|
2026-01-13 00:57:18 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
// Right Spacer -> Analyze Button if images exist
|
2026-04-16 04:20:53 +00:00
|
|
|
|
if (capturedImages.isNotEmpty)
|
2026-01-15 15:53:44 +00:00
|
|
|
|
IconButton(
|
|
|
|
|
|
icon: Badge(
|
2026-04-16 04:20:53 +00:00
|
|
|
|
label: Text('${capturedImages.length}'),
|
2026-01-15 15:53:44 +00:00
|
|
|
|
child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40),
|
|
|
|
|
|
),
|
2026-04-16 04:20:53 +00:00
|
|
|
|
onPressed: analyzeImages,
|
2026-01-15 15:53:44 +00:00
|
|
|
|
tooltip: '解析を開始',
|
|
|
|
|
|
)
|
|
|
|
|
|
else
|
|
|
|
|
|
const SizedBox(width: 48),
|
2026-01-13 00:57:18 +00:00
|
|
|
|
],
|
2026-01-11 08:17:29 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
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;
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
return GestureDetector(
|
|
|
|
|
|
onTap: () async {
|
|
|
|
|
|
if (_controller == null || !_controller!.value.isInitialized) return;
|
|
|
|
|
|
final targetZoom = zoom.clamp(_minZoom, _maxZoom);
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// 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
|
2026-02-15 15:13:12 +00:00
|
|
|
|
..style = PaintingStyle.fill; final knobShadowPaint = Paint()
|
2026-01-29 15:54:22 +00:00
|
|
|
|
..color = Colors.black26
|
2026-02-15 15:13:12 +00:00
|
|
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); // Draw vertical track (centered)
|
2026-01-29 15:54:22 +00:00
|
|
|
|
final trackX = size.width / 2;
|
|
|
|
|
|
canvas.drawLine(
|
|
|
|
|
|
Offset(trackX, 10),
|
|
|
|
|
|
Offset(trackX, size.height - 10),
|
|
|
|
|
|
trackPaint,
|
2026-02-15 15:13:12 +00:00
|
|
|
|
); // Draw center marker
|
2026-01-29 15:54:22 +00:00
|
|
|
|
canvas.drawLine(
|
|
|
|
|
|
Offset(trackX - 6, size.height / 2),
|
|
|
|
|
|
Offset(trackX + 6, size.height / 2),
|
|
|
|
|
|
centerLinePaint,
|
2026-02-15 15:13:12 +00:00
|
|
|
|
); // Calculate knob position
|
2026-01-29 15:54:22 +00:00
|
|
|
|
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)
|
2026-04-04 14:02:52 +00:00
|
|
|
|
final normalized = (currentValue - minValue) / range;
|
|
|
|
|
|
// Map to Y coordinate: 0.0 (normalized) -> bottom, 1.0 (normalized) -> top
|
|
|
|
|
|
final knobY = (size.height - 20) * (1.0 - normalized) + 10;
|
|
|
|
|
|
// Draw knob shadow
|
2026-01-29 15:54:22 +00:00
|
|
|
|
canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint);
|
|
|
|
|
|
// Draw knob
|
|
|
|
|
|
canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint);
|
|
|
|
|
|
}
|
2026-01-30 23:22:23 +00:00
|
|
|
|
} @override
|
2026-01-29 15:54:22 +00:00
|
|
|
|
bool shouldRepaint(_ExposureSliderPainter oldDelegate) {
|
|
|
|
|
|
return oldDelegate.currentValue != currentValue ||
|
|
|
|
|
|
oldDelegate.minValue != minValue ||
|
|
|
|
|
|
oldDelegate.maxValue != maxValue;
|
|
|
|
|
|
}
|
2026-01-30 23:22:23 +00:00
|
|
|
|
}
|