diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d6c4073..5d084aa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + _controller; bool get isInitialized => _isInitialized; String? get errorMessage => _errorMessage; bool get permissionDenied => _permissionDenied; bool get permissionPermanentlyDenied => _permissionPermanentlyDenied; + String? get lastSavedFileName => _lastSavedFileName; + bool get isSaving => _isSaving; - /// カメラを初期化する. + /// カメラを初期化し,画像ストリームを開始する. Future initialize() async { final granted = await _permissionService.requestCamera(); if (!granted) { @@ -44,10 +51,17 @@ frontCamera, ResolutionPreset.max, enableAudio: false, + imageFormatGroup: ImageFormatGroup.yuv420, ); await _controller!.initialize(); await _controller!.setFlashMode(FlashMode.off); + + // 画像ストリームを開始し,最新フレームを保持する + await _controller!.startImageStream((image) { + _latestFrame = image; + }); + _isInitialized = true; _errorMessage = null; } catch (e) { @@ -58,8 +72,41 @@ notifyListeners(); } + /// 最新フレームを PNG 形式で保存する. + /// + /// YUV 生データから直接 PNG に変換するため画質劣化がない. + Future takePicture() async { + if (_latestFrame == null || _isSaving) return; + + _isSaving = true; + notifyListeners(); + + try { + // ストレージ権限の確認 + final storageGranted = await _permissionService.requestStorage(); + if (!storageGranted) { + _errorMessage = 'ストレージの権限が必要です'; + _isSaving = false; + notifyListeners(); + return; + } + + final savedPath = await _fileService.saveImageFromYuv(_latestFrame!); + + _lastSavedFileName = savedPath.split('/').last; + _errorMessage = null; + } catch (e) { + _errorMessage = '撮影に失敗しました: $e'; + _lastSavedFileName = null; + } + + _isSaving = false; + notifyListeners(); + } + /// カメラリソースを解放する. void disposeCamera() { + _latestFrame = null; _controller?.dispose(); _controller = null; _isInitialized = false; diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 99cf2f0..965caeb 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -3,6 +3,7 @@ import 'package:mini_tias/providers/camera_provider.dart'; import 'package:mini_tias/widgets/camera_preview.dart'; +import 'package:mini_tias/widgets/shutter_button.dart'; /// 撮影画面.カメラプレビューとシャッターボタンを表示する. class CaptureScreen extends StatefulWidget { @@ -14,6 +15,8 @@ class _CaptureScreenState extends State with WidgetsBindingObserver { + String? _previousFileName; + @override void initState() { super.initState(); @@ -39,10 +42,45 @@ } } + void _showSaveResult(CameraProvider cameraProvider) { + final fileName = cameraProvider.lastSavedFileName; + if (fileName != null && fileName != _previousFileName) { + _previousFileName = fileName; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存しました: $fileName'), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).size.height / 2, + left: 16, + right: 16, + ), + ), + ); + }); + } + + final error = cameraProvider.errorMessage; + if (error != null && !cameraProvider.isInitialized) return; + if (error != null && !cameraProvider.isSaving) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error), backgroundColor: Colors.red), + ); + }); + } + } + @override Widget build(BuildContext context) { final cameraProvider = context.watch(); + _showSaveResult(cameraProvider); + if (cameraProvider.permissionDenied) { return _buildPermissionDenied(cameraProvider); } @@ -55,7 +93,41 @@ return const Center(child: CircularProgressIndicator()); } - return CameraPreviewWidget(controller: cameraProvider.controller!); + return Stack( + children: [ + Positioned.fill( + child: CameraPreviewWidget(controller: cameraProvider.controller!), + ), + // 保存中インジケーター + if (cameraProvider.isSaving) + const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 12), + Text( + '保存中...', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ], + ), + ), + // シャッターボタン(画面下部中央) + Positioned( + left: 0, + right: 0, + bottom: 24, + child: Center( + child: ShutterButton( + onPressed: cameraProvider.isSaving + ? () {} + : () => cameraProvider.takePicture(), + ), + ), + ), + ], + ); } Widget _buildPermissionDenied(CameraProvider cameraProvider) { diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart new file mode 100644 index 0000000..e782ac1 --- /dev/null +++ b/lib/services/file_service.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +/// 撮影画像の保存・命名・削除を行うサービス. +class FileService { + static const _basePath = '/storage/emulated/0/Pictures/MiniTIAS'; + + /// 保存先ディレクトリのパスを返す. + String get directoryPath => _basePath; + + /// CameraImage(YUV420)を PNG に変換して保存する. + /// + /// JPEG を経由せず,生データから直接 PNG に変換するため画質劣化がない. + /// 保存したファイルのパスを返す. + Future saveImageFromYuv(CameraImage cameraImage) async { + final directory = Directory(_basePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + // YUV → PNG 変換(重い処理なので isolate で実行) + final params = { + 'width': cameraImage.width, + 'height': cameraImage.height, + 'yPlane': Uint8List.fromList(cameraImage.planes[0].bytes), + 'uPlane': Uint8List.fromList(cameraImage.planes[1].bytes), + 'vPlane': Uint8List.fromList(cameraImage.planes[2].bytes), + 'yRowStride': cameraImage.planes[0].bytesPerRow, + 'uvRowStride': cameraImage.planes[1].bytesPerRow, + 'uvPixelStride': cameraImage.planes[1].bytesPerPixel ?? 1, + }; + final pngBytes = await compute(_convertYuvToPng, params); + + final fileName = await generateFileName(); + final filePath = '$_basePath/$fileName'; + final file = File(filePath); + await file.writeAsBytes(pngBytes); + + return filePath; + } + + /// ファイル名を生成する. + /// + /// 形式: MiniTIAS_YYYYMMDD_HHmmss.png + /// 同秒の重複がある場合は _1, _2, ... と連番を付与する. + Future generateFileName() async { + final now = DateTime.now(); + final timestamp = + '${now.year}' + '${now.month.toString().padLeft(2, '0')}' + '${now.day.toString().padLeft(2, '0')}' + '_' + '${now.hour.toString().padLeft(2, '0')}' + '${now.minute.toString().padLeft(2, '0')}' + '${now.second.toString().padLeft(2, '0')}'; + + final baseName = 'MiniTIAS_$timestamp'; + final candidate = '$baseName.png'; + + if (!await File('$_basePath/$candidate').exists()) { + return candidate; + } + + var suffix = 1; + while (await File('$_basePath/${baseName}_$suffix.png').exists()) { + suffix++; + } + return '${baseName}_$suffix.png'; + } + + /// テスト用: タイムスタンプとファイル名一覧からファイル名を生成する. + static String generateFileNameSync( + String timestamp, + List existingFiles, + ) { + final baseName = 'MiniTIAS_$timestamp'; + final candidate = '$baseName.png'; + + if (!existingFiles.contains(candidate)) { + return candidate; + } + + var suffix = 1; + while (existingFiles.contains('${baseName}_$suffix.png')) { + suffix++; + } + return '${baseName}_$suffix.png'; + } + + /// YUV420 を RGB に変換し PNG エンコードする(isolate 内で実行). + static Uint8List _convertYuvToPng(Map params) { + final width = params['width'] as int; + final height = params['height'] as int; + final yPlane = params['yPlane'] as Uint8List; + final uPlane = params['uPlane'] as Uint8List; + final vPlane = params['vPlane'] as Uint8List; + final yRowStride = params['yRowStride'] as int; + final uvRowStride = params['uvRowStride'] as int; + final uvPixelStride = params['uvPixelStride'] as int; + + final image = img.Image(width: width, height: height); + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final yIndex = y * yRowStride + x; + final uvIndex = (y ~/ 2) * uvRowStride + (x ~/ 2) * uvPixelStride; + + final yValue = yPlane[yIndex]; + final uValue = uPlane[uvIndex]; + final vValue = vPlane[uvIndex]; + + final r = (yValue + 1.402 * (vValue - 128)).round().clamp(0, 255); + final g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)) + .round() + .clamp(0, 255); + final b = (yValue + 1.772 * (uValue - 128)).round().clamp(0, 255); + + image.setPixelRgb(x, y, r, g, b); + } + } + + return Uint8List.fromList(img.encodePng(image)); + } +} diff --git a/lib/services/permission_service.dart b/lib/services/permission_service.dart index 54acee7..65a4072 100644 --- a/lib/services/permission_service.dart +++ b/lib/services/permission_service.dart @@ -3,13 +3,17 @@ /// カメラ・ストレージのパーミッション確認・要求を行うサービス. class PermissionService { /// カメラ権限を確認し,未許可なら要求する. - /// - /// 許可済みなら true,拒否なら false を返す. Future requestCamera() async { final status = await Permission.camera.request(); return status.isGranted; } + /// ストレージ権限を確認し,未許可なら要求する. + Future requestStorage() async { + final status = await Permission.storage.request(); + return status.isGranted; + } + /// カメラ権限が永久に拒否されているかを返す. Future isCameraPermanentlyDenied() async { return await Permission.camera.isPermanentlyDenied; diff --git a/lib/widgets/shutter_button.dart b/lib/widgets/shutter_button.dart new file mode 100644 index 0000000..0a7c1a2 --- /dev/null +++ b/lib/widgets/shutter_button.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +/// 撮影用のシャッターボタン. +class ShutterButton extends StatelessWidget { + const ShutterButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 4), + ), + child: Center( + child: Container( + width: 58, + height: 58, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 52f27dd..c6ebf24 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" async: dependency: transitive description: @@ -105,6 +113,14 @@ url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" flutter: dependency: "direct main" description: flutter @@ -136,6 +152,14 @@ description: flutter source: sdk version: "0.0.0" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" leak_tracker: dependency: transitive description: @@ -256,6 +280,14 @@ url: "https://pub.dev" source: hosted version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" plugin_platform_interface: dependency: transitive description: @@ -264,6 +296,14 @@ url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" provider: dependency: "direct main" description: @@ -413,6 +453,14 @@ url: "https://pub.dev" source: hosted version: "1.1.1" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" sdks: dart: ">=3.11.4 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 30d4e92..05d7385 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ screen_brightness: ^2.1.7 camera: ^0.12.0+1 permission_handler: ^12.0.1 + image: ^4.8.0 dev_dependencies: flutter_test: diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart new file mode 100644 index 0000000..4d577a7 --- /dev/null +++ b/test/services/file_service_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mini_tias/services/file_service.dart'; + +void main() { + group('FileService.generateFileNameSync', () { + test('同秒のファイルが存在しない場合,サフィックスなしのファイル名を返す', () { + final result = FileService.generateFileNameSync('20260404_120000', []); + expect(result, 'MiniTIAS_20260404_120000.png'); + }); + + test('同秒のファイルが存在する場合,_1 サフィックスを付与する', () { + final result = FileService.generateFileNameSync('20260404_120000', [ + 'MiniTIAS_20260404_120000.png', + ]); + expect(result, 'MiniTIAS_20260404_120000_1.png'); + }); + + test('_1 も存在する場合,_2 サフィックスを付与する', () { + final result = FileService.generateFileNameSync('20260404_120000', [ + 'MiniTIAS_20260404_120000.png', + 'MiniTIAS_20260404_120000_1.png', + ]); + expect(result, 'MiniTIAS_20260404_120000_2.png'); + }); + + test('異なるタイムスタンプのファイルが存在しても影響しない', () { + final result = FileService.generateFileNameSync('20260404_120000', [ + 'MiniTIAS_20260404_120001.png', + ]); + expect(result, 'MiniTIAS_20260404_120000.png'); + }); + }); +}