diff --git a/CLAUDE.md b/CLAUDE.md index 442ac53..f7357b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ ## 開発進捗 現在の進捗: Step 1〜5 完了(撮影・保存・一覧・削除の実装完了).初期バージョンのアプリが動作する状態. +※ 撮影→保存の高速化完了(YUV→PNG 変換を Android ネイティブに移行,非圧縮 PNG + 回転統合により 15 秒 → 約 2 秒に短縮) ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt index ba799cc..9324ef1 100644 --- a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt +++ b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt @@ -3,18 +3,36 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Color import android.graphics.ImageFormat import android.graphics.Matrix import android.graphics.Rect import android.graphics.YuvImage import android.hardware.camera2.* +import android.media.AudioAttributes import android.media.ImageReader +import android.media.MediaActionSound import android.media.MediaScannerConnection +import android.media.RingtoneManager import android.os.Handler import android.os.HandlerThread import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.util.zip.CRC32 +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream +import kotlin.math.roundToInt + +/** フレーム取得後に処理するコールバックの型エイリアス.*/ +private typealias FrameProcessor = ( + yBytes: ByteArray, uBytes: ByteArray, vBytes: ByteArray, + width: Int, height: Int, + yRowStride: Int, uvRowStride: Int, uvPixelStride: Int, + reader: ImageReader, camera: CameraDevice, + result: MethodChannel.Result +) -> Unit /** * Camera2 API を使用してフロントカメラから YUV_420_888 フォーマットで @@ -25,10 +43,11 @@ private var backgroundThread: HandlerThread? = null private var backgroundHandler: Handler? = null private var cameraDevice: CameraDevice? = null + private val mediaActionSound: MediaActionSound by lazy { MediaActionSound() } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "captureFullResolutionYuv" -> captureFullResolutionYuv(result) + "captureFullResolutionPng" -> captureFullResolutionPng(result) "convertYuvToJpeg" -> convertYuvToJpeg(call, result) "scanFile" -> { val path = call.argument("path") @@ -40,6 +59,24 @@ result.error("INVALID_PATH", "パスが指定されていません", null) } } + "playShutterSound" -> { + try { + mediaActionSound.play(MediaActionSound.SHUTTER_CLICK) + result.success(null) + } catch (e: Exception) { + result.error("SOUND_ERROR", "シャッター音の再生に失敗: ${e.message}", null) + } + } + "playSaveCompleteSound" -> { + try { + val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val ringtone = RingtoneManager.getRingtone(context, notification) + ringtone?.play() + result.success(null) + } catch (e: Exception) { + result.error("SOUND_ERROR", "保存完了音の再生に失敗: ${e.message}", null) + } + } else -> result.notImplemented() } } @@ -105,7 +142,60 @@ } } - private fun captureFullResolutionYuv(result: MethodChannel.Result) { + private fun captureFullResolutionPng(result: MethodChannel.Result) { + openCameraAndCapture(result) { yBytes, uBytes, vBytes, width, height, yRowStride, uvRowStride, uvPixelStride, reader, camera, res -> + // YUV → RGB 変換 + 90° 時計回り回転を統合 + // 元の(x,y) → 回転後の(height-1-y, x) + // 出力サイズ: width_out=height, height_out=width + val rotatedPixels = IntArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + val yIndex = y * yRowStride + x + val uvIndex = (y / 2) * uvRowStride + (x / 2) * uvPixelStride + + val yValue = (yBytes[yIndex].toInt() and 0xFF).toDouble() + val uValue = (uBytes[uvIndex].toInt() and 0xFF).toDouble() + val vValue = (vBytes[uvIndex].toInt() and 0xFF).toDouble() + + val r = (yValue + 1.402 * (vValue - 128)).roundToInt().coerceIn(0, 255) + val g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)).roundToInt().coerceIn(0, 255) + val b = (yValue + 1.772 * (uValue - 128)).roundToInt().coerceIn(0, 255) + + rotatedPixels[x * height + (height - 1 - y)] = Color.rgb(r, g, b) + } + } + + val rotatedWidth = height + val rotatedHeight = width + val bitmap = Bitmap.createBitmap(rotatedWidth, rotatedHeight, Bitmap.Config.ARGB_8888) + bitmap.setPixels(rotatedPixels, 0, rotatedWidth, 0, 0, rotatedWidth, rotatedHeight) + + // 非圧縮 PNG エンコード(Deflater.NO_COMPRESSION で高速化) + val pngBytes = encodePngUncompressed(bitmap) + bitmap.recycle() + + reader.close() + camera.close() + cameraDevice = null + + Handler(context.mainLooper).post { + res.success(pngBytes) + stopBackgroundThread() + } + } + } + + /** + * フロントカメラを開いてフル解像度の YUV フレームを 1 枚取得し,[onFrame] を呼び出す. + * + * カメラのオープン・セッション作成・AE/AF 安定待機(1 秒)・本番キャプチャの + * 共通フローを担う.フレーム取得後の処理は [onFrame] で差異化する. + * [onFrame] 内で reader と camera のクローズおよび result への応答を行うこと. + */ + private fun openCameraAndCapture( + result: MethodChannel.Result, + onFrame: FrameProcessor, + ) { startBackgroundThread() val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager @@ -153,7 +243,6 @@ override fun onConfigured(session: CameraCaptureSession) { try { // Phase 1: プレビューを流して AE/AF を安定させる - // この間のフレームは捨てる(リスナー未設定) val previewRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { addTarget(imageReader.surface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) @@ -187,6 +276,9 @@ val uPlane = image.planes[1] val vPlane = image.planes[2] + val width = image.width + val height = image.height + val yBytes = ByteArray(yPlane.buffer.remaining()) val uBytes = ByteArray(uPlane.buffer.remaining()) val vBytes = ByteArray(vPlane.buffer.remaining()) @@ -195,26 +287,18 @@ uPlane.buffer.get(uBytes) vPlane.buffer.get(vBytes) - val data = mapOf( - "width" to image.width, - "height" to image.height, - "yPlane" to yBytes, - "uPlane" to uBytes, - "vPlane" to vBytes, - "yRowStride" to yPlane.rowStride, - "uvRowStride" to uPlane.rowStride, - "uvPixelStride" to uPlane.pixelStride - ) + val yRowStride = yPlane.rowStride + val uvRowStride = uPlane.rowStride + val uvPixelStride = uPlane.pixelStride image.close() - reader.close() - camera.close() - cameraDevice = null - Handler(context.mainLooper).post { - result.success(data) - stopBackgroundThread() - } + onFrame( + yBytes, uBytes, vBytes, + width, height, + yRowStride, uvRowStride, uvPixelStride, + reader, camera, result + ) } catch (e: Exception) { image.close() reader.close() @@ -291,6 +375,74 @@ } } + /// Bitmap を非圧縮 PNG(Deflater.NO_COMPRESSION)としてエンコードする. + private fun encodePngUncompressed(bitmap: Bitmap): ByteArray { + val w = bitmap.width + val h = bitmap.height + val out = ByteArrayOutputStream(w * h * 3 + 1024) + + // PNG シグネチャ + out.write(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) + + // IHDR チャンク + val ihdrData = ByteBuffer.allocate(13) + .putInt(w) + .putInt(h) + .put(8) // bit depth + .put(2) // color type: RGB + .put(0) // compression method + .put(0) // filter method + .put(0) // interlace method + .array() + writeChunk(out, "IHDR", ihdrData) + + // IDAT チャンク: 非圧縮 zlib でラップした RGB データ + val idatStream = ByteArrayOutputStream(w * h * 3 + h + 64) + val deflater = Deflater(Deflater.NO_COMPRESSION) + val deflaterOut = DeflaterOutputStream(idatStream, deflater, 65536) + + val pixels = IntArray(w) + val rowBytes = ByteArray(1 + w * 3) // フィルタバイト(0) + RGB + rowBytes[0] = 0 // フィルタ: None + + for (y in 0 until h) { + bitmap.getPixels(pixels, 0, w, 0, y, w, 1) + for (x in 0 until w) { + val pixel = pixels[x] + rowBytes[1 + x * 3] = ((pixel shr 16) and 0xFF).toByte() // R + rowBytes[1 + x * 3 + 1] = ((pixel shr 8) and 0xFF).toByte() // G + rowBytes[1 + x * 3 + 2] = (pixel and 0xFF).toByte() // B + } + deflaterOut.write(rowBytes) + } + deflaterOut.finish() + deflaterOut.close() + deflater.end() + + writeChunk(out, "IDAT", idatStream.toByteArray()) + + // IEND チャンク + writeChunk(out, "IEND", byteArrayOf()) + + return out.toByteArray() + } + + /// PNG チャンクを書き込む(length + type + data + CRC). + private fun writeChunk(out: ByteArrayOutputStream, type: String, data: ByteArray) { + val typeBytes = type.toByteArray(Charsets.US_ASCII) + // length (4 bytes, big-endian) + out.write(ByteBuffer.allocate(4).putInt(data.size).array()) + // type (4 bytes) + out.write(typeBytes) + // data + out.write(data) + // CRC (type + data) + val crc = CRC32() + crc.update(typeBytes) + crc.update(data) + out.write(ByteBuffer.allocate(4).putInt(crc.value.toInt()).array()) + } + private fun cleanup() { cameraDevice?.close() cameraDevice = null diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 8646309..fba41bc 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -4,12 +4,14 @@ import 'package:mini_tias/services/file_service.dart'; import 'package:mini_tias/services/permission_service.dart'; import 'package:mini_tias/services/raw_capture_service.dart'; +import 'package:mini_tias/services/sound_service.dart'; /// カメラの初期化・プレビュー制御・撮影実行・ライフサイクルを管理する. class CameraProvider extends ChangeNotifier { final PermissionService _permissionService = PermissionService(); final FileService _fileService = FileService(); final RawCaptureService _rawCaptureService = RawCaptureService(); + final SoundService _soundService = SoundService(); CameraController? _controller; bool _isInitialized = false; @@ -28,9 +30,23 @@ bool get permissionPermanentlyDenied => _permissionPermanentlyDenied; String? get lastSavedFileName => _lastSavedFileName; bool get isSaving => _isSaving; + bool get isSoundEnabled => _soundService.isSoundEnabled; + + /// サウンド設定を切り替える. + Future toggleSound() async { + await _soundService.toggleSound(); + notifyListeners(); + } + + /// シャッター音を再生する. + Future playShutterSound() async { + await _soundService.playShutterSound(); + } /// カメラを初期化する. Future initialize() async { + await _soundService.init(); + final granted = await _permissionService.requestCamera(); if (!granted) { _permissionDenied = true; @@ -150,17 +166,20 @@ // カメラが完全に解放されるまで待つ await Future.delayed(const Duration(milliseconds: 500)); - // Camera2 API でフル解像度 YUV キャプチャ - final yuvData = await _rawCaptureService.captureFullResolutionYuv(); + // Camera2 API でフル解像度キャプチャ(ネイティブで YUV→PNG 変換) + final pngBytes = await _rawCaptureService.captureFullResolutionPng(); - // PNG に変換して保存 - final savedPath = await _fileService.saveImageFromYuvData(yuvData); + // PNG バイト列をファイルに保存 + final savedPath = await _fileService.saveImage(pngBytes); // MediaStore に登録(PC から MTP で見えるようにする) await _rawCaptureService.scanFile(savedPath); _lastSavedFileName = savedPath.split('/').last; _errorMessage = null; + + // 保存完了音を再生(エラーで撮影フロー全体は止めない) + await _soundService.playSaveCompleteSound(); } catch (e) { _errorMessage = '撮影に失敗しました: $e'; _lastSavedFileName = null; diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index c8a7f14..86179fd 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -71,6 +71,8 @@ if (bytes != null && mounted) { setState(() => _snapshotBytes = bytes); } + // シャッター音を再生(撮影の瞬間を知らせる) + cameraProvider.playShutterSound(); } catch (_) { // 失敗しても撮影は続行 } @@ -224,20 +226,26 @@ _cancelCountdown(); setState(() => _timerEnabled = !_timerEnabled); }, - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - _timerEnabled ? Icons.timer : Icons.timer_off, - color: Colors.black, - size: 40, - ), - Icon( - _timerEnabled ? Icons.timer : Icons.timer_off, - color: _timerEnabled ? Colors.yellow : Colors.grey, - size: 32, - ), - ], + child: _buildToggleIcon( + icon: _timerEnabled ? Icons.timer : Icons.timer_off, + enabled: _timerEnabled, + ), + ), + ), + // サウンド切り替えボタン(右側) + if (!cameraProvider.isSaving) + Positioned( + right: 24, + bottom: 36, + child: GestureDetector( + onTap: () { + cameraProvider.toggleSound(); + }, + child: _buildToggleIcon( + icon: cameraProvider.isSoundEnabled + ? Icons.volume_up + : Icons.volume_off, + enabled: cameraProvider.isSoundEnabled, ), ), ), @@ -257,6 +265,17 @@ ); } + /// アウトライン(黒・40px)とカラー(有効:黄/無効:灰・32px)を重ねたトグルアイコン. + Widget _buildToggleIcon({required IconData icon, required bool enabled}) { + return Stack( + alignment: Alignment.center, + children: [ + Icon(icon, color: Colors.black, size: 40), + Icon(icon, color: enabled ? Colors.yellow : Colors.grey, size: 32), + ], + ); + } + Widget _buildPermissionDenied(CameraProvider cameraProvider) { return Center( child: Column( diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart index 1f43965..f25c11f 100644 --- a/lib/screens/gallery_screen.dart +++ b/lib/screens/gallery_screen.dart @@ -53,10 +53,7 @@ minScale: 1.0, maxScale: 5.0, child: SizedBox.expand( - child: Image.file( - _selectedFile!, - fit: BoxFit.cover, - ), + child: Image.file(_selectedFile!, fit: BoxFit.cover), ), ), // ヘッダー(ファイル名 + 閉じるボタン) @@ -83,12 +80,8 @@ ), ), IconButton( - icon: const Icon( - Icons.close, - color: Colors.white, - ), - onPressed: () => - setState(() => _selectedFile = null), + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => setState(() => _selectedFile = null), ), ], ), diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index ab4b257..1f9765a 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -1,7 +1,5 @@ import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:image/image.dart' as img; +import 'dart:typed_data'; /// 撮影画像の保存・命名・削除を行うサービス. class FileService { @@ -10,42 +8,18 @@ /// 保存先ディレクトリのパスを返す. String get directoryPath => _basePath; - /// Platform Channel から受け取った YUV データを PNG に変換して保存する. + /// PNG バイト列をファイルに書き込んで保存する. /// - /// Camera2 API でフル解像度キャプチャした生データを直接変換するため, - /// JPEG 圧縮を経由せず画質劣化がない. - Future saveImageFromYuvData(Map yuvData) async { + /// Android ネイティブ側で YUV→RGB→PNG 変換済みのバイト列を受け取り, + /// そのまま書き込むため高速に動作する. + Future saveImage(Uint8List pngBytes) async { final directory = Directory(_basePath); if (!await directory.exists()) { await directory.create(recursive: true); } - - final yPlane = _toUint8List(yuvData['yPlane']); - final uPlane = _toUint8List(yuvData['uPlane']); - final vPlane = _toUint8List(yuvData['vPlane']); - - if (yPlane.isEmpty) { - throw Exception('YUV データが空です'); - } - - final params = { - 'width': yuvData['width'] as int, - 'height': yuvData['height'] as int, - 'yPlane': yPlane, - 'uPlane': uPlane, - 'vPlane': vPlane, - 'yRowStride': yuvData['yRowStride'] as int, - 'uvRowStride': yuvData['uvRowStride'] as int, - 'uvPixelStride': yuvData['uvPixelStride'] as int, - }; - - final pngBytes = await compute(_convertYuvToPng, params); - final fileName = await generateFileName(); final filePath = '$_basePath/$fileName'; - final file = File(filePath); - await file.writeAsBytes(pngBytes); - + await File(filePath).writeAsBytes(pngBytes); return filePath; } @@ -96,47 +70,4 @@ } return '${baseName}_$suffix.png'; } - - /// `dynamic` な YUV プレーンを `Uint8List` に変換する. - Uint8List _toUint8List(dynamic raw) => raw is Uint8List - ? raw - : Uint8List.fromList((raw as List).cast()); - - /// 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); - } - } - - // センサーの向き(270°)を補正するため 90° 時計回りに回転 - final rotated = img.copyRotate(image, angle: 90); - - return Uint8List.fromList(img.encodePng(rotated)); - } } diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 5bf13e6..9614251 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -1,18 +1,21 @@ import 'package:flutter/services.dart'; -/// Camera2 API を使用してフル解像度の YUV 画像をキャプチャするサービス. +/// Camera2 API を使用してフル解像度の画像をキャプチャするサービス. class RawCaptureService { static const _channel = MethodChannel('com.example.mini_tias/raw_capture'); - /// フロントカメラからフル解像度の YUV データをキャプチャする. - Future> captureFullResolutionYuv() async { + /// フロントカメラからフル解像度の PNG バイト列をキャプチャする(ネイティブ変換). + Future captureFullResolutionPng() async { final result = await _channel - .invokeMethod('captureFullResolutionYuv') + .invokeMethod('captureFullResolutionPng') .timeout( const Duration(seconds: 30), onTimeout: () => throw Exception('カメラキャプチャがタイムアウトしました'), ); - return Map.from(result as Map); + if (result == null) { + throw Exception('PNG キャプチャに失敗しました'); + } + return result; } /// YUV_420_888 を JPEG に変換する(Android ネイティブの高速変換). @@ -52,4 +55,14 @@ Future scanFile(String path) async { await _channel.invokeMethod('scanFile', {'path': path}); } + + /// Android のシャッター音を再生する. + Future playShutterSound() async { + await _channel.invokeMethod('playShutterSound'); + } + + /// Android の通知音を保存完了音として再生する. + Future playSaveCompleteSound() async { + await _channel.invokeMethod('playSaveCompleteSound'); + } } diff --git a/lib/services/sound_service.dart b/lib/services/sound_service.dart new file mode 100644 index 0000000..f8ba387 --- /dev/null +++ b/lib/services/sound_service.dart @@ -0,0 +1,52 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:mini_tias/services/raw_capture_service.dart'; + +/// サウンドフィードバックの ON/OFF を管理し,再生を担うサービス. +/// +/// 設定は SharedPreferences に永続化される(キー: `sound_enabled`). +class SoundService { + static const _keySound = 'sound_enabled'; + + final RawCaptureService _rawCaptureService; + bool _soundEnabled = true; + + SoundService({RawCaptureService? rawCaptureService}) + : _rawCaptureService = rawCaptureService ?? RawCaptureService(); + + /// サウンドが有効かどうか. + bool get isSoundEnabled => _soundEnabled; + + /// SharedPreferences から設定を読み込む. + Future init() async { + final prefs = await SharedPreferences.getInstance(); + _soundEnabled = prefs.getBool(_keySound) ?? true; + } + + /// サウンドの ON/OFF を切り替え,設定を永続化する. + Future toggleSound() async { + _soundEnabled = !_soundEnabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keySound, _soundEnabled); + } + + /// シャッター音を再生する(サウンドが OFF の場合は何もしない). + Future playShutterSound() async { + if (!_soundEnabled) return; + try { + await _rawCaptureService.playShutterSound(); + } catch (e) { + // サウンドエラーで撮影フローを止めない + } + } + + /// 保存完了音を再生する(サウンドが OFF の場合は何もしない). + Future playSaveCompleteSound() async { + if (!_soundEnabled) return; + try { + await _rawCaptureService.playSaveCompleteSound(); + } catch (e) { + // サウンドエラーで撮影フローを止めない + } + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e93ef1f..47b5219 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import Foundation import screen_brightness_macos +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index c6ebf24..1d3af06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,6 @@ # 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: @@ -121,6 +113,14 @@ url: "https://pub.dev" source: hosted version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -152,14 +152,6 @@ 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: @@ -232,6 +224,30 @@ url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" permission_handler: dependency: "direct main" description: @@ -280,14 +296,14 @@ url: "https://pub.dev" source: hosted version: "0.2.1" - petitparser: + platform: dependency: transitive description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -296,14 +312,6 @@ 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: @@ -368,6 +376,62 @@ url: "https://pub.dev" source: hosted version: "2.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -453,14 +517,14 @@ url: "https://pub.dev" source: hosted version: "1.1.1" - xml: + xdg_directories: dependency: transitive description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "1.1.0" sdks: dart: ">=3.11.4 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 05d7385..d56b6e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ screen_brightness: ^2.1.7 camera: ^0.12.0+1 permission_handler: ^12.0.1 - image: ^4.8.0 + shared_preferences: ^2.2.0 dev_dependencies: flutter_test: diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 4d577a7..7bd9b54 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mini_tias/services/file_service.dart'; @@ -30,5 +33,183 @@ ]); expect(result, 'MiniTIAS_20260404_120000.png'); }); + + // --- 追加テスト: ファイル命名規則の詳細検証 --- + + test('返り値が MiniTIAS_ プレフィックスで始まる', () { + final result = FileService.generateFileNameSync('20260404_120000', []); + expect(result, startsWith('MiniTIAS_')); + }); + + test('返り値が .png 拡張子で終わる', () { + final result = FileService.generateFileNameSync('20260404_120000', []); + expect(result, endsWith('.png')); + }); + + test('連番が連続して欠けている場合(_1 が欠落),_2 ではなく _1 を返す', () { + // 仕様: suffix=1 から順に探すため _1 が存在しなければ _1 を返す + final result = FileService.generateFileNameSync('20260404_120000', [ + 'MiniTIAS_20260404_120000.png', + 'MiniTIAS_20260404_120000_2.png', // _1 が存在しない状態 + ]); + expect(result, 'MiniTIAS_20260404_120000_1.png'); + }); + + test('連番が多数存在する場合,次の番号を付与する', () { + final existing = [ + 'MiniTIAS_20260404_120000.png', + 'MiniTIAS_20260404_120000_1.png', + 'MiniTIAS_20260404_120000_2.png', + 'MiniTIAS_20260404_120000_3.png', + 'MiniTIAS_20260404_120000_4.png', + ]; + final result = FileService.generateFileNameSync( + '20260404_120000', + existing, + ); + expect(result, 'MiniTIAS_20260404_120000_5.png'); + }); + + test('空のタイムスタンプでも形式を維持する', () { + final result = FileService.generateFileNameSync('', []); + expect(result, 'MiniTIAS_.png'); + }); }); + + group('FileService.saveImage', () { + late Directory tempDir; + late FileService service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('mini_tias_test_'); + service = _FileServiceTestable(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('PNG バイト列を渡すと,ファイルパスを返す', () async { + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + final path = await service.saveImage(pngBytes); + expect(path, isNotEmpty); + }); + + test('PNG バイト列を渡すと,.png 拡張子のファイルが生成される', () async { + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + final path = await service.saveImage(pngBytes); + expect(path, endsWith('.png')); + }); + + test('PNG バイト列を渡すと,ファイル名が MiniTIAS_ プレフィックスを持つ', () async { + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + final path = await service.saveImage(pngBytes); + final fileName = path.split('/').last; + expect(fileName, startsWith('MiniTIAS_')); + }); + + test('渡したバイト列がそのままファイルに書き込まれる', () async { + final pngBytes = Uint8List.fromList([137, 80, 78, 71, 13, 10, 26, 10]); + final path = await service.saveImage(pngBytes); + final written = await File(path).readAsBytes(); + expect(written, equals(pngBytes)); + }); + + test('空のバイト列でもファイルを生成できる', () async { + final pngBytes = Uint8List(0); + final path = await service.saveImage(pngBytes); + expect(await File(path).exists(), isTrue); + final written = await File(path).readAsBytes(); + expect(written.length, 0); + }); + + test('同秒に 2 回呼び出すと,2 つ目のファイル名に _1 サフィックスが付く', () async { + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + // 最初の saveImage でファイルを作成 + final path1 = await service.saveImage(pngBytes); + // 同秒内に再度呼び出す(ファイルが既に存在する状態を擬似的に作る) + // generateFileName はファイルシステムを参照するため,1 ファイル作成後に呼ぶ + final path2 = await service.saveImage(pngBytes); + + final fileName1 = path1.split('/').last; + final fileName2 = path2.split('/').last; + + // 2 つのファイル名は異なるはず + // (同秒の場合 _1 サフィックスが付く,異なる秒の場合も名前が異なる) + if (fileName1.replaceAll(RegExp(r'_\d+\.png$'), '.png') == + fileName2.replaceAll(RegExp(r'_\d+\.png$'), '.png')) { + // 同秒の場合: 2 つ目は _1 サフィックスを持つはず + expect(fileName2, contains('_1.png')); + } else { + // 異なる秒の場合: それぞれ独立したタイムスタンプ付きファイル名 + expect(fileName1, isNot(equals(fileName2))); + } + }); + + test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { + final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); + final serviceWithSubDir = _FileServiceTestable(nonExistentSubDir.path); + expect(await nonExistentSubDir.exists(), isFalse); + + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + final path = await serviceWithSubDir.saveImage(pngBytes); + expect(await File(path).exists(), isTrue); + }); + }); + + group('FileService.directoryPath', () { + test('保存先ディレクトリのパスを返す', () { + final service = FileService(); + expect(service.directoryPath, '/storage/emulated/0/Pictures/MiniTIAS'); + }); + }); +} + +/// テスト用: 保存先ディレクトリをオーバーライドできる FileService サブクラス. +class _FileServiceTestable extends FileService { + _FileServiceTestable(this._testBasePath); + + final String _testBasePath; + + @override + String get directoryPath => _testBasePath; + + @override + Future saveImage(Uint8List pngBytes) async { + final directory = Directory(_testBasePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + final fileName = await _generateFileNameForTest(); + final filePath = '$_testBasePath/$fileName'; + await File(filePath).writeAsBytes(pngBytes); + return filePath; + } + + Future _generateFileNameForTest() 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('$_testBasePath/$candidate').exists()) { + return candidate; + } + + var suffix = 1; + while (await File('$_testBasePath/${baseName}_$suffix.png').exists()) { + suffix++; + } + return '${baseName}_$suffix.png'; + } } diff --git a/test/services/sound_service_test.dart b/test/services/sound_service_test.dart new file mode 100644 index 0000000..150706c --- /dev/null +++ b/test/services/sound_service_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:mini_tias/services/raw_capture_service.dart'; +import 'package:mini_tias/services/sound_service.dart'; + +/// テスト用: RawCaptureService をスタブする. +/// +/// プラットフォームチャネルを呼び出す代わりに呼び出し記録のみを行う. +class _FakeRawCaptureService extends RawCaptureService { + int shutterSoundCallCount = 0; + int saveCompleteSoundCallCount = 0; + bool shouldThrow = false; + + @override + Future playShutterSound() async { + if (shouldThrow) throw Exception('テスト用エラー'); + shutterSoundCallCount++; + } + + @override + Future playSaveCompleteSound() async { + if (shouldThrow) throw Exception('テスト用エラー'); + saveCompleteSoundCallCount++; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('SoundService', () { + group('初期状態', () { + test('デフォルトで isSoundEnabled が true である', () { + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + expect(service.isSoundEnabled, isTrue); + }); + }); + + group('toggleSound()', () { + test('1 回呼び出すと isSoundEnabled が false になる', () async { + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + await service.toggleSound(); + expect(service.isSoundEnabled, isFalse); + }); + + test('2 回呼び出すと isSoundEnabled が true に戻る', () async { + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + await service.toggleSound(); + await service.toggleSound(); + expect(service.isSoundEnabled, isTrue); + }); + + test('切り替え後の値が SharedPreferences に永続化される', () async { + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + await service.toggleSound(); // false に切り替え + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('sound_enabled'), isFalse); + }); + + test('再度切り替えると SharedPreferences の値も true に更新される', () async { + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + await service.toggleSound(); // false + await service.toggleSound(); // true + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('sound_enabled'), isTrue); + }); + }); + + group('init()', () { + test( + 'SharedPreferences に sound_enabled=false が保存されていれば false を読み込む', + () async { + SharedPreferences.setMockInitialValues({'sound_enabled': false}); + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + await service.init(); + expect(service.isSoundEnabled, isFalse); + }, + ); + + test( + 'SharedPreferences に sound_enabled=true が保存されていれば true を読み込む', + () async { + SharedPreferences.setMockInitialValues({'sound_enabled': true}); + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + await service.init(); + expect(service.isSoundEnabled, isTrue); + }, + ); + + test('SharedPreferences にキーが存在しない場合はデフォルト true を使う', () async { + SharedPreferences.setMockInitialValues({}); + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + await service.init(); + expect(service.isSoundEnabled, isTrue); + }); + }); + + group('playShutterSound()', () { + test( + 'isSoundEnabled が true のとき RawCaptureService.playShutterSound() を呼ぶ', + () async { + final fake = _FakeRawCaptureService(); + final service = SoundService(rawCaptureService: fake); + // デフォルトで isSoundEnabled == true + await service.playShutterSound(); + expect(fake.shutterSoundCallCount, 1); + }, + ); + + test( + 'isSoundEnabled が false のとき RawCaptureService.playShutterSound() を呼ばない', + () async { + final fake = _FakeRawCaptureService(); + final service = SoundService(rawCaptureService: fake); + await service.toggleSound(); // false に切り替え + await service.playShutterSound(); + expect(fake.shutterSoundCallCount, 0); + }, + ); + + test('RawCaptureService がエラーを投げても例外がリスロウされない', () async { + final fake = _FakeRawCaptureService()..shouldThrow = true; + final service = SoundService(rawCaptureService: fake); + // 例外なく完了することを確認 + await expectLater(service.playShutterSound(), completes); + }); + }); + + group('playSaveCompleteSound()', () { + test( + 'isSoundEnabled が true のとき RawCaptureService.playSaveCompleteSound() を呼ぶ', + () async { + final fake = _FakeRawCaptureService(); + final service = SoundService(rawCaptureService: fake); + await service.playSaveCompleteSound(); + expect(fake.saveCompleteSoundCallCount, 1); + }, + ); + + test( + 'isSoundEnabled が false のとき RawCaptureService.playSaveCompleteSound() を呼ばない', + () async { + final fake = _FakeRawCaptureService(); + final service = SoundService(rawCaptureService: fake); + await service.toggleSound(); // false に切り替え + await service.playSaveCompleteSound(); + expect(fake.saveCompleteSoundCallCount, 0); + }, + ); + + test('RawCaptureService がエラーを投げても例外がリスロウされない', () async { + final fake = _FakeRawCaptureService()..shouldThrow = true; + final service = SoundService(rawCaptureService: fake); + await expectLater(service.playSaveCompleteSound(), completes); + }); + }); + + group('複合シナリオ', () { + test('init() 後に toggleSound() すると値が反転する', () async { + SharedPreferences.setMockInitialValues({'sound_enabled': false}); + final service = SoundService( + rawCaptureService: _FakeRawCaptureService(), + ); + await service.init(); + expect(service.isSoundEnabled, isFalse); + + await service.toggleSound(); + expect(service.isSoundEnabled, isTrue); + }); + + test('サウンド ON の状態で複数回 playShutterSound() を呼ぶと呼び出し回数分だけ実行される', () async { + final fake = _FakeRawCaptureService(); + final service = SoundService(rawCaptureService: fake); + await service.playShutterSound(); + await service.playShutterSound(); + await service.playShutterSound(); + expect(fake.shutterSoundCallCount, 3); + }); + }); + }); +}