diff --git "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" index d1f0e6d..951f432 100644 --- "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" +++ "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" @@ -38,7 +38,7 @@ ### レイアウト -UI 全体を `Transform.rotate(angle: pi)` で 180° 回転する.以下は**コード上の配置順**(回転前)を示す.操作者が逆さに置いた端末を見ると,上下が反転して表示される. +アプリ全体を `app.dart` の `MaterialApp` ごと `Transform.rotate(angle: pi)` で 180° 回転する.これにより画面・ダイアログ・スナックバー等すべての UI が自動的に回転されるため,個々のウィジェットで回転を意識する必要がない.以下は**コード上の配置順**(回転前)を示す.操作者が逆さに置いた端末を見ると,上下が反転して表示される. ```text 操作者の視点(回転後) コード上の配置(回転前) diff --git a/lib/app.dart b/lib/app.dart index 7a8a412..35b393c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,21 +1,33 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:mini_tias/screens/home_screen.dart'; /// MiniTIAS アプリのルートウィジェット. +/// +/// 端末を逆さに置いて使用するため,アプリ全体を 180° 回転する. +/// これにより画面・ダイアログ・スナックバー等すべての UI が +/// 操作者から見て正しい向きで表示される. class MiniTiasApp extends StatelessWidget { const MiniTiasApp({super.key}); @override Widget build(BuildContext context) { - return MaterialApp( - title: 'MiniTIAS', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey), - useMaterial3: true, + return Directionality( + textDirection: TextDirection.ltr, + child: Transform.rotate( + angle: math.pi, + child: MaterialApp( + title: 'MiniTIAS', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey), + useMaterial3: true, + ), + home: const HomeScreen(), + debugShowCheckedModeBanner: false, + ), ), - home: const HomeScreen(), - debugShowCheckedModeBanner: false, ); } } diff --git a/lib/providers/gallery_provider.dart b/lib/providers/gallery_provider.dart index 510e711..3cc00ed 100644 --- a/lib/providers/gallery_provider.dart +++ b/lib/providers/gallery_provider.dart @@ -1,6 +1,66 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; /// 画像一覧の取得・キャッシュ・削除操作を管理する. -/// -/// Step 1 では空の Provider.Step 4 以降で一覧機能を実装する. -class GalleryProvider extends ChangeNotifier {} +class GalleryProvider extends ChangeNotifier { + static const _basePath = '/storage/emulated/0/Pictures/MiniTIAS'; + + List _images = []; + bool _isLoading = false; + + List get images => _images; + bool get isLoading => _isLoading; + + /// Pictures/MiniTIAS/ 内の PNG ファイルを取得し,新しい順に並べる. + Future loadImages() async { + _isLoading = true; + notifyListeners(); + + try { + final directory = Directory(_basePath); + if (!await directory.exists()) { + _images = []; + } else { + final files = await directory + .list() + .where( + (entity) => + entity is File && entity.path.toLowerCase().endsWith('.png'), + ) + .cast() + .toList(); + + // 更新日時の新しい順にソート + files.sort((a, b) { + final aStat = a.statSync(); + final bStat = b.statSync(); + return bStat.modified.compareTo(aStat.modified); + }); + + _images = files; + } + } catch (e) { + debugPrint('画像一覧の取得に失敗: $e'); + _images = []; + } + + _isLoading = false; + notifyListeners(); + } + + /// 指定したファイルを削除し,一覧から除去する. + Future deleteImage(File file) async { + try { + if (await file.exists()) { + await file.delete(); + } + _images.remove(file); + notifyListeners(); + return true; + } catch (e) { + debugPrint('画像の削除に失敗: $e'); + return false; + } + } +} diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart index 9136dc9..5e08a72 100644 --- a/lib/screens/gallery_screen.dart +++ b/lib/screens/gallery_screen.dart @@ -1,13 +1,52 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:mini_tias/providers/gallery_provider.dart'; +import 'package:mini_tias/widgets/image_detail_dialog.dart'; +import 'package:mini_tias/widgets/image_grid.dart'; /// 一覧画面.撮影済み画像のサムネイルグリッドを表示する. -/// -/// Step 1 では空のスキャフォールドのみ.Step 4 以降で一覧機能を実装する. -class GalleryScreen extends StatelessWidget { +class GalleryScreen extends StatefulWidget { const GalleryScreen({super.key}); @override + State createState() => _GalleryScreenState(); +} + +class _GalleryScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadImages(); + }); + } + + @override Widget build(BuildContext context) { - return const Center(child: Text('一覧画面')); + final galleryProvider = context.watch(); + + if (galleryProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (galleryProvider.images.isEmpty) { + return const Center(child: Text('撮影した画像がありません')); + } + + return ImageGrid( + images: galleryProvider.images, + onImageTap: (file) { + showDialog( + context: context, + builder: (_) => ImageDetailDialog( + file: file, + onDelete: () { + galleryProvider.deleteImage(file); + }, + ), + ); + }, + ); } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b3f3d71..fdce83d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,15 +1,15 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import 'package:mini_tias/providers/gallery_provider.dart'; import 'package:mini_tias/screens/capture_screen.dart'; import 'package:mini_tias/screens/gallery_screen.dart'; /// BottomNavigationBar で撮影画面と一覧画面を切り替えるホーム画面. /// -/// UI 全体を 180° 回転して表示する(端末を逆さに置いて使用するため). /// アタッチメントで隠れる下部領域を避け,上部の見える領域に UI を配置する. +/// ※ 180° 回転は app.dart で一括適用しているため,ここでは不要. class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -40,75 +40,71 @@ @override Widget build(BuildContext context) { final screenHeight = MediaQuery.of(context).size.height; - // アタッチメントで隠れる領域(回転前の下部 = 操作者から見た上部) final attachmentPadding = screenHeight / 3; - return Transform.rotate( - angle: math.pi, - child: Scaffold( - 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: '撮影', + return Scaffold( + backgroundColor: Colors.black, + body: Column( + children: [ + // ナビゲーションバー + BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() => _currentIndex = index); + if (index == 1) { + context.read().loadImages(); + } + }, + backgroundColor: Colors.black87, + selectedItemColor: Colors.white, + unselectedItemColor: Colors.white60, + 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), + Expanded( + child: Slider( + value: _brightness, + onChanged: (value) { + setState(() => _brightness = value); + _setBrightness(value); + }, + ), ), - BottomNavigationBarItem( - icon: Icon(Icons.photo_library), - label: '一覧', + const Icon( + Icons.brightness_high, + size: 20, + color: Colors.white, ), ], ), + ), - // 明るさスライダー - Container( - color: Colors.black54, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - const Icon( - Icons.brightness_low, - size: 20, - color: Colors.white, - ), - 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(), + ), - // メインコンテンツ(カメラプレビュー or 一覧) - Expanded( - child: _currentIndex == 0 - ? const CaptureScreen() - : const GalleryScreen(), - ), - - // アタッチメントで隠れる領域(空白) - SizedBox(height: attachmentPadding), - ], - ), + // アタッチメントで隠れる領域(空白) + SizedBox(height: attachmentPadding), + ], ), ); } diff --git a/lib/widgets/image_detail_dialog.dart b/lib/widgets/image_detail_dialog.dart new file mode 100644 index 0000000..c014875 --- /dev/null +++ b/lib/widgets/image_detail_dialog.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +/// 画像の拡大表示ダイアログ.ピンチズームと削除ボタンを備える. +class ImageDetailDialog extends StatelessWidget { + const ImageDetailDialog({ + super.key, + required this.file, + required this.onDelete, + }); + + final File file; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final fileName = file.path.split('/').last; + + return Dialog( + backgroundColor: Colors.black, + insetPadding: const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ヘッダー(ファイル名 + 閉じるボタン) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + fileName, + style: const TextStyle(color: Colors.white, fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + // 画像(ピンチズーム対応) + Flexible( + child: InteractiveViewer( + minScale: 1.0, + maxScale: 5.0, + child: Image.file(file), + ), + ), + // 削除ボタン + Padding( + padding: const EdgeInsets.all(8), + child: TextButton.icon( + onPressed: () => _confirmDelete(context), + icon: const Icon(Icons.delete, color: Colors.red), + label: const Text('削除', style: TextStyle(color: Colors.red)), + ), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('画像を削除'), + content: const Text('この画像を削除しますか?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('キャンセル'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); // 確認ダイアログを閉じる + Navigator.of(context).pop(); // 詳細ダイアログを閉じる + onDelete(); + }, + child: const Text('削除', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/image_grid.dart b/lib/widgets/image_grid.dart new file mode 100644 index 0000000..2581448 --- /dev/null +++ b/lib/widgets/image_grid.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +/// 撮影済み画像のサムネイルを 3 列のグリッドで表示するウィジェット. +class ImageGrid extends StatelessWidget { + const ImageGrid({super.key, required this.images, required this.onImageTap}); + + final List images; + final void Function(File file) onImageTap; + + @override + Widget build(BuildContext context) { + return GridView.builder( + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: images.length, + itemBuilder: (context, index) { + final file = images[index]; + return GestureDetector( + onTap: () => onImageTap(file), + child: Image.file(file, fit: BoxFit.cover, cacheWidth: 200), + ); + }, + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index d2b46f9..cff4d50 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -33,8 +34,12 @@ ); await tester.tap(find.text('一覧')); - await tester.pumpAndSettle(); + await tester.pump(); - expect(find.text('一覧画面'), findsOneWidget); + // 一覧タブがアクティブになっていることを確認 + final navBar = tester.widget( + find.byType(BottomNavigationBar), + ); + expect(navBar.currentIndex, 1); }); }