diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2b893b4..d6c4073 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + _controller; + bool get isInitialized => _isInitialized; + String? get errorMessage => _errorMessage; + bool get permissionDenied => _permissionDenied; + bool get permissionPermanentlyDenied => _permissionPermanentlyDenied; + + /// カメラを初期化する. + Future initialize() async { + final granted = await _permissionService.requestCamera(); + if (!granted) { + _permissionDenied = true; + _permissionPermanentlyDenied = await _permissionService + .isCameraPermanentlyDenied(); + _errorMessage = 'カメラの権限が必要です'; + notifyListeners(); + return; + } + + _permissionDenied = false; + _permissionPermanentlyDenied = false; + + try { + final cameras = await availableCameras(); + final frontCamera = cameras.firstWhere( + (c) => c.lensDirection == CameraLensDirection.front, + ); + + _controller = CameraController( + frontCamera, + ResolutionPreset.max, + enableAudio: false, + ); + + await _controller!.initialize(); + await _controller!.setFlashMode(FlashMode.off); + _isInitialized = true; + _errorMessage = null; + } catch (e) { + _errorMessage = 'カメラの初期化に失敗しました: $e'; + _isInitialized = false; + } + + notifyListeners(); + } + + /// カメラリソースを解放する. + void disposeCamera() { + _controller?.dispose(); + _controller = null; + _isInitialized = false; + notifyListeners(); + } + + /// アプリの設定画面を開く(権限が永久拒否された場合). + Future openSettings() async { + await _permissionService.openSettings(); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 93afb58..99cf2f0 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -1,13 +1,82 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:mini_tias/providers/camera_provider.dart'; +import 'package:mini_tias/widgets/camera_preview.dart'; /// 撮影画面.カメラプレビューとシャッターボタンを表示する. -/// -/// Step 1 では空のスキャフォールドのみ.Step 2 以降でカメラ機能を実装する. -class CaptureScreen extends StatelessWidget { +class CaptureScreen extends StatefulWidget { const CaptureScreen({super.key}); @override + State createState() => _CaptureScreenState(); +} + +class _CaptureScreenState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initialize(); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final cameraProvider = context.read(); + if (state == AppLifecycleState.paused) { + cameraProvider.disposeCamera(); + } else if (state == AppLifecycleState.resumed) { + cameraProvider.initialize(); + } + } + + @override Widget build(BuildContext context) { - return const Center(child: Text('撮影画面')); + final cameraProvider = context.watch(); + + if (cameraProvider.permissionDenied) { + return _buildPermissionDenied(cameraProvider); + } + + if (cameraProvider.errorMessage != null && !cameraProvider.isInitialized) { + return Center(child: Text(cameraProvider.errorMessage!)); + } + + if (!cameraProvider.isInitialized) { + return const Center(child: CircularProgressIndicator()); + } + + return CameraPreviewWidget(controller: cameraProvider.controller!); + } + + Widget _buildPermissionDenied(CameraProvider cameraProvider) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(cameraProvider.errorMessage ?? 'カメラの権限が必要です'), + const SizedBox(height: 16), + if (cameraProvider.permissionPermanentlyDenied) + ElevatedButton( + onPressed: () => cameraProvider.openSettings(), + child: const Text('設定画面を開く'), + ) + else + ElevatedButton( + onPressed: () => cameraProvider.initialize(), + child: const Text('権限を許可する'), + ), + ], + ), + ); } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index c7d73c8..b3f3d71 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -9,6 +9,7 @@ /// BottomNavigationBar で撮影画面と一覧画面を切り替えるホーム画面. /// /// UI 全体を 180° 回転して表示する(端末を逆さに置いて使用するため). +/// アタッチメントで隠れる下部領域を避け,上部の見える領域に UI を配置する. class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -20,8 +21,6 @@ int _currentIndex = 0; double _brightness = 0.8; - static const _screens = [CaptureScreen(), GalleryScreen()]; - @override void initState() { super.initState(); @@ -41,54 +40,74 @@ @override Widget build(BuildContext context) { final screenHeight = MediaQuery.of(context).size.height; + // アタッチメントで隠れる領域(回転前の下部 = 操作者から見た上部) final attachmentPadding = screenHeight / 3; return Transform.rotate( angle: math.pi, child: Scaffold( - body: Padding( - padding: EdgeInsets.only(bottom: attachmentPadding), - child: _screens[_currentIndex], - ), - bottomNavigationBar: Padding( - padding: EdgeInsets.only(bottom: attachmentPadding), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - const Icon(Icons.brightness_low, size: 20), - Expanded( - child: Slider( - value: _brightness, - onChanged: (value) { - setState(() => _brightness = value); - _setBrightness(value); - }, - ), - ), - const Icon(Icons.brightness_high, size: 20), - ], + backgroundColor: Colors.black, + body: Column( + children: [ + // 操作者に見える領域(回転前の上部) + // ナビゲーションバー + BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) => setState(() => _currentIndex = index), + backgroundColor: Colors.black87, + selectedItemColor: Colors.white, + unselectedItemColor: Colors.white60, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.camera_alt), + label: '撮影', ), - ), - BottomNavigationBar( - currentIndex: _currentIndex, - onTap: (index) => setState(() => _currentIndex = index), - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.camera_alt), - label: '撮影', + BottomNavigationBarItem( + icon: Icon(Icons.photo_library), + label: '一覧', + ), + ], + ), + + // 明るさスライダー + Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon( + Icons.brightness_low, + size: 20, + color: Colors.white, ), - BottomNavigationBarItem( - icon: Icon(Icons.photo_library), - label: '一覧', + Expanded( + child: Slider( + value: _brightness, + onChanged: (value) { + setState(() => _brightness = value); + _setBrightness(value); + }, + ), + ), + const Icon( + Icons.brightness_high, + size: 20, + color: Colors.white, ), ], ), - ], - ), + ), + + // メインコンテンツ(カメラプレビュー or 一覧) + Expanded( + child: _currentIndex == 0 + ? const CaptureScreen() + : const GalleryScreen(), + ), + + // アタッチメントで隠れる領域(空白) + SizedBox(height: attachmentPadding), + ], ), ), ); diff --git a/lib/services/permission_service.dart b/lib/services/permission_service.dart new file mode 100644 index 0000000..54acee7 --- /dev/null +++ b/lib/services/permission_service.dart @@ -0,0 +1,22 @@ +import 'package:permission_handler/permission_handler.dart'; + +/// カメラ・ストレージのパーミッション確認・要求を行うサービス. +class PermissionService { + /// カメラ権限を確認し,未許可なら要求する. + /// + /// 許可済みなら true,拒否なら false を返す. + Future requestCamera() async { + final status = await Permission.camera.request(); + return status.isGranted; + } + + /// カメラ権限が永久に拒否されているかを返す. + Future isCameraPermanentlyDenied() async { + return await Permission.camera.isPermanentlyDenied; + } + + /// アプリの設定画面を開く. + Future openSettings() async { + await openAppSettings(); + } +} diff --git a/lib/widgets/camera_preview.dart b/lib/widgets/camera_preview.dart new file mode 100644 index 0000000..c0b04e7 --- /dev/null +++ b/lib/widgets/camera_preview.dart @@ -0,0 +1,50 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; + +/// カメラのライブプレビューを全画面で表示するウィジェット. +/// +/// フロントカメラの映像を鏡像(左右反転)で表示する. +class CameraPreviewWidget extends StatelessWidget { + const CameraPreviewWidget({super.key, required this.controller}); + + final CameraController controller; + + @override + Widget build(BuildContext context) { + if (!controller.value.isInitialized) { + return const Center(child: CircularProgressIndicator()); + } + + return LayoutBuilder( + builder: (context, constraints) { + final previewSize = controller.value.previewSize; + if (previewSize == null) { + return const Center(child: CircularProgressIndicator()); + } + + // カメラのアスペクト比(プレビューは横長で返るので反転) + final cameraAspect = previewSize.height / previewSize.width; + final screenAspect = constraints.maxWidth / constraints.maxHeight; + + // 画面いっぱいに表示するためのスケール + final scale = cameraAspect > screenAspect + ? constraints.maxHeight / (constraints.maxWidth / cameraAspect) + : constraints.maxWidth / (constraints.maxHeight * cameraAspect); + + return ClipRect( + child: Transform( + alignment: Alignment.center, + // X 軸を反転(鏡像),Y 軸を反転(上下逆転) + transform: Matrix4.diagonal3Values(-scale, -scale, 1.0), + child: Center( + child: AspectRatio( + aspectRatio: cameraAspect, + child: CameraPreview(controller), + ), + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 2820fd7..52f27dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,46 @@ url: "https://pub.dev" source: hosted version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777" + url: "https://pub.dev" + source: hosted + version: "0.12.0+1" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "2c178975759aac0f0ef7ce1ec698b6e2acd792127ea7f38fa79a424fbebeae7f" + url: "https://pub.dev" + source: hosted + version: "0.7.1+2" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d" + url: "https://pub.dev" + source: hosted + version: "0.10.1" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" + url: "https://pub.dev" + source: hosted + version: "0.3.5+3" characters: dependency: transitive description: @@ -41,6 +81,14 @@ url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" cupertino_icons: dependency: "direct main" description: @@ -70,11 +118,24 @@ url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" leak_tracker: dependency: transitive description: @@ -147,6 +208,54 @@ url: "https://pub.dev" source: hosted version: "1.9.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" plugin_platform_interface: dependency: transitive description: @@ -248,6 +357,14 @@ url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -288,6 +405,14 @@ url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.11.4 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3907161..30d4e92 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,8 @@ cupertino_icons: ^1.0.8 provider: ^6.1.5+1 screen_brightness: ^2.1.7 + camera: ^0.12.0+1 + permission_handler: ^12.0.1 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 740eee2..d2b46f9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -6,7 +6,7 @@ import 'package:mini_tias/providers/gallery_provider.dart'; void main() { - testWidgets('アプリ起動時に撮影画面が表示される', (WidgetTester tester) async { + testWidgets('アプリ起動時にナビゲーションバーが表示される', (WidgetTester tester) async { await tester.pumpWidget( MultiProvider( providers: [ @@ -17,7 +17,6 @@ ), ); - expect(find.text('撮影画面'), findsOneWidget); expect(find.text('撮影'), findsOneWidget); expect(find.text('一覧'), findsOneWidget); }); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 81f96b7..d06af2e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenBrightnessWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 94c966e..a4456cf 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows screen_brightness_windows )