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 520896e..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 @@ -9,8 +9,11 @@ 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 @@ -22,6 +25,15 @@ 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 フォーマットで * フル解像度の画像を 1 フレームキャプチャする. @@ -31,15 +43,7 @@ private var backgroundThread: HandlerThread? = null private var backgroundHandler: Handler? = null private var cameraDevice: CameraDevice? = null - - /** フレーム取得後に処理するコールバックの型エイリアス.*/ - 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 + private val mediaActionSound: MediaActionSound by lazy { MediaActionSound() } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { @@ -55,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() } } diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 0956563..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; @@ -161,6 +177,9 @@ _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/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 6001884..9614251 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -55,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 52f27dd..1d3af06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,22 @@ 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" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -208,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: @@ -256,6 +296,14 @@ url: "https://pub.dev" source: hosted version: "0.2.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -328,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 @@ -413,6 +517,14 @@ url: "https://pub.dev" source: hosted version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + 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 30d4e92..d56b6e4 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 + shared_preferences: ^2.2.0 dev_dependencies: flutter_test: 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); + }); + }); + }); +}