diff --git a/AGENTS.md b/AGENTS.md index a0ea09a..741f5c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,4 @@ ## 禁止事項 - .envファイルの内容をコミット・表示しないこと。 - 既存のテストを削除しないこと。 +- こちらからの指示が無い場合はgitの操作は行わないこと。 diff --git a/README.md b/README.md index e50396d..eb4f9e4 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,8 @@ #### 失敗時のサウンド - `~/.config/tree-image-optimizer/sounds/error` に、ユーザーが用意したサウンドファイルを配置すると、アプリケーションで選択可能になります。 + +### ユーザーが用意した背景画像を配置する + +- ユーザー背景画像ファイルは、アプリケーションを再起動すると読み込まれます。 +- `~/.config/tree-image-optimizer/wallpaper` に、ユーザーが用意した背景画像ファイルを配置すると、アプリケーションで選択可能になります。 diff --git a/assets/images/wallpaper/wallpaper1.jpg b/assets/images/wallpaper/wallpaper1.jpg new file mode 100644 index 0000000..4942505 Binary files /dev/null and b/assets/images/wallpaper/wallpaper1.jpg differ diff --git a/assets/images/wallpaper/wallpaper2.jpg b/assets/images/wallpaper/wallpaper2.jpg new file mode 100644 index 0000000..5486440 Binary files /dev/null and b/assets/images/wallpaper/wallpaper2.jpg differ diff --git a/assets/images/wallpaper3.jpg b/assets/images/wallpaper/wallpaper3.jpg similarity index 100% rename from assets/images/wallpaper3.jpg rename to assets/images/wallpaper/wallpaper3.jpg diff --git a/assets/images/wallpaper1.jpg b/assets/images/wallpaper1.jpg deleted file mode 100644 index 1bb0001..0000000 Binary files a/assets/images/wallpaper1.jpg and /dev/null differ diff --git a/assets/images/wallpaper2.jpg b/assets/images/wallpaper2.jpg deleted file mode 100644 index a2f494f..0000000 Binary files a/assets/images/wallpaper2.jpg and /dev/null differ diff --git "a/docs/design/feature/images/\347\224\273\351\235\242\346\247\213\346\210\220-\350\250\255\345\256\232.webp" "b/docs/design/feature/images/\347\224\273\351\235\242\346\247\213\346\210\220-\350\250\255\345\256\232.webp" index 3272506..5e3dbf7 100644 Binary files "a/docs/design/feature/images/\347\224\273\351\235\242\346\247\213\346\210\220-\350\250\255\345\256\232.webp" and "b/docs/design/feature/images/\347\224\273\351\235\242\346\247\213\346\210\220-\350\250\255\345\256\232.webp" differ diff --git a/docs/design/feature/settings.md b/docs/design/feature/settings.md index 84f127f..4a59e80 100644 --- a/docs/design/feature/settings.md +++ b/docs/design/feature/settings.md @@ -118,3 +118,78 @@ #### ℹ️ - ホバーするとpopoverに説明が表示される。 - 「出力先に既に同一のファイル名が存在する場合、上書きコピーされます。」というテキストが記述されていること。 + +### ✏️ 背景画像 + +#### 静的ラベル + +- `背景画像` という静的ラベルを表示します。 + +##### プルダウンの選択肢の表示仕様 + +| 種別 | パス | +|----------------|----------------------------------------------| +| 同梱の背景画像 | `assets/images/wallpaper/wallpaper[1-3].jpg` | +| ユーザーが配置した背景画像 | `.config/tree-image-optimizer/wallpaper` | + +- 上記をまとめてプルダウンに表示します。 +- プルダウン選択肢の表示は特殊で、以下のように上部に同梱サウンドを、その後にユーザーサウンドを表示します。 + ``` + 同梱背景画像1.jpg + 同梱背景画像2.jpg + ユーザー背景画像1.jpg + ユーザー背景画像2.png + ``` +- 同梱背景画像もユーザー背景画像もファイル名を照準でソートします。 +- 選択肢の項目名は `ファイル名` を表示します。 + - 同梱背景画像の場合だけ、 `(サンプル)xxx.jpg` という形式で、 `(サンプル)` をファイル名のprefixにしてください。 + - ユーザー背景画像の場合は `ファイル名` を表示します。 +- 対応している拡張子は以下です。 + - `.jpg` + - `.png` +- ユーザー背景画像が `存在しない` または `権限不足で参照できない` 場合、 `同梱背景画像1` にフォールバックします。 +- 背景画像はアスペクト比を保ったまま、アプリケーションの中に収まるようように設定してください。 + +#### ℹ️ +- ホバーするとpopoverに説明が表示される。 +- 以下のテキストが記述されていること。 + ``` + .config/tree-image-optimizer/wallpaper 配下に背景画像ファイルを配置すると、自分が用意したファイルを選択することができます。 + ``` + +### ✏️ 背景画像の透明度 + +#### 静的ラベル + +- `背景画像の透明度` という静的ラベルを表示します。 + +#### 背景画像の透明度スライダー + +- 最小 = `0.0` 、 最大 = `1.0` です。 +- 初期値は `1.0` です。 +- この数値は `Opacity` の値です。 + +#### ℹ️ +- ホバーするとpopoverに説明が表示される。 +- 以下のテキストが記述されていること。 + ``` + 背景画像の透明度を調節することができます。 + ``` + +### ✏️ 背景色 + +#### 静的ラベル + +- `背景色` という静的ラベルを表示します。 + +#### 背景色カラーピッカー + +- カラーピッカーで背景色を設定することができます。 +- 初期値は `白` です。 + +#### ℹ️ +- ホバーするとpopoverに説明が表示される。 +- 以下のテキストが記述されていること。 + ``` + 背景画像は縦横比を維持するため、余白できることがあります。その余白部分の色が背景色になります。 + ``` diff --git a/lib/app/home_shell.dart b/lib/app/home_shell.dart index 934c102..b936da7 100644 --- a/lib/app/home_shell.dart +++ b/lib/app/home_shell.dart @@ -1,7 +1,11 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:liquid_glass_easy/liquid_glass_easy.dart'; import 'package:material_ui/material_ui.dart'; import '../core/constants/app_constants.dart'; +import 'providers.dart'; import '../ui/screens/about_screen.dart'; import '../ui/screens/convert_screen.dart'; import '../ui/screens/settings_screen.dart'; @@ -9,14 +13,14 @@ import '../ui/theme/glass_constants.dart'; import 'package:tree_image_optimizer/l10n/generated/app_localizations.dart'; /// 左サイドナビ・右コンテンツのレイアウトを持つ画面シェル。 -class HomeShell extends StatefulWidget { +class HomeShell extends ConsumerStatefulWidget { const HomeShell({super.key}); @override - State createState() => _HomeShellState(); + ConsumerState createState() => _HomeShellState(); } -class _HomeShellState extends State { +class _HomeShellState extends ConsumerState { int _selectedIndex = 0; static const _screens = [ConvertScreen(), SettingsScreen(), AboutScreen()]; @@ -26,15 +30,21 @@ class _HomeShellState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final settings = ref.watch(settingsViewModelProvider); + final wallpaper = settings.wallpaper.isNotEmpty + ? settings.wallpaper + : AppConstants.wallpaperAsset; + final wallpaperOpacity = settings.wallpaperOpacity; + final backgroundColor = + _parseColor(settings.wallpaperBackgroundColor) ?? + const Color(0xFFFFFFFF); return Scaffold( body: LiquidGlassView( - backgroundWidget: Opacity( - opacity: AppConstants.wallpaperOpacity, - child: Image.asset( - AppConstants.wallpaperAsset, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - const ColoredBox(color: Color(0xFF1A1A2E)), + backgroundWidget: Container( + color: backgroundColor, + child: Opacity( + opacity: wallpaperOpacity, + child: _buildWallpaper(wallpaper), ), ), realTimeCapture: false, @@ -79,6 +89,47 @@ class _HomeShellState extends State { ), ); } + + Widget _buildWallpaper(String source) { + // アスペクト比を保ったままアプリケーション内に収まるように contain を使用 + if (source.startsWith('assets/')) { + return Image.asset( + source, + fit: BoxFit.contain, + alignment: Alignment.center, + errorBuilder: (context, error, stackTrace) => + const ColoredBox(color: Color(0xFF1A1A2E)), + ); + } + final file = File(source); + // 存在しない場合はフォールバックで同梱壁紙を表示 + if (!file.existsSync()) { + return Image.asset( + AppConstants.wallpaperAsset, + fit: BoxFit.contain, + alignment: Alignment.center, + errorBuilder: (context, error, stackTrace) => + const ColoredBox(color: Color(0xFF1A1A2E)), + ); + } + return Image.file( + file, + fit: BoxFit.contain, + alignment: Alignment.center, + errorBuilder: (context, error, stackTrace) => + const ColoredBox(color: Color(0xFF1A1A2E)), + ); + } + + Color? _parseColor(String hex) { + var h = hex.trim(); + if (h.startsWith('#')) h = h.substring(1); + if (h.length == 6) h = 'FF$h'; + if (h.length != 8) return null; + final v = int.tryParse(h, radix: 16); + if (v == null) return null; + return Color(v); + } } class _NavItem extends StatelessWidget { diff --git a/lib/app/providers.dart b/lib/app/providers.dart index d0f4c91..eff8f2e 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -9,6 +9,7 @@ import '../data/services/path_service.dart'; import '../data/services/process_service.dart'; import '../data/services/sound_service.dart'; import '../data/services/tool_installer.dart'; +import '../data/services/wallpaper_service.dart'; import '../data/services/upscale_service.dart'; import '../logic/usecases/compress_usecase.dart'; import '../logic/usecases/get_target_files_usecase.dart'; @@ -54,6 +55,13 @@ final soundServiceProvider = Provider((ref) { ); }); +final wallpaperServiceProvider = Provider((ref) { + return WallpaperService( + logService: ref.watch(logServiceProvider), + pathService: ref.watch(pathServiceProvider), + ); +}); + final notificationServiceProvider = Provider((ref) { return NotificationService(logService: ref.watch(logServiceProvider)); }); @@ -132,6 +140,7 @@ final settingsViewModelProvider = return SettingsViewModel( settingsRepository: ref.watch(settingsRepositoryProvider), soundService: ref.watch(soundServiceProvider), + wallpaperService: ref.watch(wallpaperServiceProvider), ); }); diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 4b16487..e2bdab4 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -48,13 +48,13 @@ class AppConstants { /// 壁紙アセットのパス。 /// - /// `assets/images/` 配下の壁紙画像を指定する。変更時は `pubspec.yaml` の + /// `assets/images/wallpaper/` 配下の壁紙画像を指定する。変更時は `pubspec.yaml` の /// `flutter.assets` も合わせて更新すること。 - static const String wallpaperAsset = 'assets/images/wallpaper3.jpg'; + static const String wallpaperAsset = 'assets/images/wallpaper/wallpaper1.jpg'; /// 壁紙の不透明度 (0.0: 透明 〜 1.0: 不透明)。 /// /// `HomeShell` の背景画像に適用される。値を下げると壁紙が薄くなり、 /// Liquid Glass の視認性が上がる。 - static const double wallpaperOpacity = 1.0; + static const double wallpaperOpacity = 0.4; } diff --git a/lib/core/models/settings_screen_settings.dart b/lib/core/models/settings_screen_settings.dart index 53aa355..530b470 100644 --- a/lib/core/models/settings_screen_settings.dart +++ b/lib/core/models/settings_screen_settings.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + /// 設定画面の設定値。 class SettingsScreenSettings { const SettingsScreenSettings({ @@ -6,6 +8,9 @@ class SettingsScreenSettings { required this.successSound, required this.errorSound, this.language = '', + this.wallpaper = 'assets/images/wallpaper/wallpaper1.jpg', + this.wallpaperOpacity = 1.0, + this.wallpaperBackgroundColor = '#FFFFFF', }); /// アプリ内で定めている初期値。 @@ -16,6 +21,9 @@ class SettingsScreenSettings { successSound: 'assets/sounds/success/decision49.mp3', errorSound: 'assets/sounds/error/beep1.mp3', language: '', + wallpaper: 'assets/images/wallpaper/wallpaper1.jpg', + wallpaperOpacity: 1.0, + wallpaperBackgroundColor: '#FFFFFF', ); } @@ -34,12 +42,25 @@ class SettingsScreenSettings { /// 選択された表示言語(BCP 47)。空文字は「環境設定に従う」を意味する。 final String language; + /// 背景画像のパス。 + /// 同梱壁紙はアセットパス、ユーザー壁紙は絶対パス。 + final String wallpaper; + + /// 背景画像の透明度 (0.0〜1.0)。 + final double wallpaperOpacity; + + /// 背景色 (hex, 例: #FFFFFF)。 + final String wallpaperBackgroundColor; + SettingsScreenSettings copyWith({ bool? showOsNotification, bool? playSound, String? successSound, String? errorSound, String? language, + String? wallpaper, + double? wallpaperOpacity, + String? wallpaperBackgroundColor, }) { return SettingsScreenSettings( showOsNotification: showOsNotification ?? this.showOsNotification, @@ -47,6 +68,10 @@ class SettingsScreenSettings { successSound: successSound ?? this.successSound, errorSound: errorSound ?? this.errorSound, language: language ?? this.language, + wallpaper: wallpaper ?? this.wallpaper, + wallpaperOpacity: wallpaperOpacity ?? this.wallpaperOpacity, + wallpaperBackgroundColor: + wallpaperBackgroundColor ?? this.wallpaperBackgroundColor, ); } @@ -57,6 +82,24 @@ class SettingsScreenSettings { 'successSound': successSound, 'errorSound': errorSound, 'language': language, + 'wallpaper': wallpaper, + 'wallpaperOpacity': wallpaperOpacity, + 'wallpaperBackgroundColor': wallpaperBackgroundColor, }; } + + /// 背景色を [Color] に変換する。不正な値の場合は白を返す。 + Color get wallpaperBackgroundColorValue { + return _parseColor(wallpaperBackgroundColor) ?? const Color(0xFFFFFFFF); + } + + static Color? _parseColor(String hex) { + var h = hex.trim(); + if (h.startsWith('#')) h = h.substring(1); + if (h.length == 6) h = 'FF$h'; + if (h.length != 8) return null; + final v = int.tryParse(h, radix: 16); + if (v == null) return null; + return Color(v); + } } diff --git a/lib/core/models/wallpaper_option.dart b/lib/core/models/wallpaper_option.dart new file mode 100644 index 0000000..9a53592 --- /dev/null +++ b/lib/core/models/wallpaper_option.dart @@ -0,0 +1,22 @@ +/// 壁紙の選択肢を表す。 +class WallpaperOption { + const WallpaperOption({ + required this.name, + required this.source, + required this.isBundled, + }); + + /// 表示名(ファイル名)。 + final String name; + + /// ソース。 + /// 同梱壁紙はアセットパス(`assets/images/wallpaper/...`)、ユーザー壁紙は絶対パス。 + final String source; + + /// 同梱壁紙かどうか。 + final bool isBundled; + + /// プルダウンに表示するラベル。 + /// 同梱壁紙のみ `(サンプル)` をprefixに付ける。 + String get label => isBundled ? '(サンプル)$name' : name; +} diff --git a/lib/data/repositories/settings_repository.dart b/lib/data/repositories/settings_repository.dart index 26e67b5..82e7ee5 100644 --- a/lib/data/repositories/settings_repository.dart +++ b/lib/data/repositories/settings_repository.dart @@ -215,6 +215,23 @@ class SettingsRepository { defaults.language, () => needsRewrite = true, ); + final wallpaper = _stringValue( + json['wallpaper'], + defaults.wallpaper, + (v) => needsRewrite = true, + ); + final wallpaperOpacity = _doubleValue( + json['wallpaperOpacity'], + 0.0, + 1.0, + defaults.wallpaperOpacity, + () => needsRewrite = true, + ); + final wallpaperBackgroundColor = _colorValue( + json['wallpaperBackgroundColor'], + defaults.wallpaperBackgroundColor, + () => needsRewrite = true, + ); final settings = SettingsScreenSettings( showOsNotification: showOsNotification, @@ -222,6 +239,9 @@ class SettingsRepository { successSound: successSound, errorSound: errorSound, language: language, + wallpaper: wallpaper, + wallpaperOpacity: wallpaperOpacity, + wallpaperBackgroundColor: wallpaperBackgroundColor, ); return _ResolvedSettings(settings, needsRewrite); @@ -285,6 +305,47 @@ class SettingsRepository { onCorrected(fallback); return fallback; } + + double _doubleValue( + Object? value, + double min, + double max, + double fallback, + void Function() onCorrected, + ) { + double? parsed; + if (value is double) { + parsed = value; + } else if (value is int) { + parsed = value.toDouble(); + } else if (value is String) { + parsed = double.tryParse(value); + } + if (parsed == null) { + onCorrected(); + return fallback; + } + final clamped = parsed.clamp(min, max).toDouble(); + if (clamped != parsed) onCorrected(); + return clamped; + } + + String _colorValue( + Object? value, + String fallback, + void Function() onCorrected, + ) { + if (value is String && _isValidColor(value)) return value; + onCorrected(); + return fallback; + } + + bool _isValidColor(String hex) { + var h = hex.trim(); + if (h.startsWith('#')) h = h.substring(1); + if (h.length != 6 && h.length != 8) return false; + return int.tryParse(h, radix: 16) != null; + } } class _ResolvedConvert { diff --git a/lib/data/services/wallpaper_service.dart b/lib/data/services/wallpaper_service.dart new file mode 100644 index 0000000..136fde2 --- /dev/null +++ b/lib/data/services/wallpaper_service.dart @@ -0,0 +1,181 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../../core/models/wallpaper_option.dart'; +import 'log_service.dart'; +import 'path_service.dart'; + +/// 壁紙の一覧取得を提供するサービス。 +class WallpaperService { + WallpaperService({ + LogService? logService, + PathService? pathService, + this.bundledBaseDir, + this.userBaseDir, + }) : _log = logService, + _pathService = pathService ?? PathService(); + + final LogService? _log; + final PathService _pathService; + + /// 同梱壁紙の基準ディレクトリ。テスト用に注入できる。 + /// デフォルトは `/assets/images/wallpaper` + final String? bundledBaseDir; + + /// ユーザー壁紙の基準ディレクトリ。テスト用に注入できる。 + /// デフォルトは `$HOME/.config/tree-image-optimizer/wallpaper` + final String? userBaseDir; + + Future _bundledBaseDir() async { + if (bundledBaseDir != null) return bundledBaseDir!; + + // 1. PathService の基準ディレクトリ配下 + final projectDir = await _pathService.projectDirectory(); + final inProject = Directory('${projectDir.path}/assets/images/wallpaper'); + if (inProject.existsSync()) return inProject.path; + + // 2. カレントディレクトリ配下 + final inCurrent = Directory( + '${Directory.current.path}/assets/images/wallpaper', + ); + if (inCurrent.existsSync()) return inCurrent.path; + + // 3. macOS App.framework の flutter_assets 配下 + try { + final exeDir = File(Platform.resolvedExecutable).parent; + final inAssets = Directory( + '${exeDir.parent.path}/Frameworks/App.framework/Resources/flutter_assets/assets/images/wallpaper', + ); + if (inAssets.existsSync()) return inAssets.path; + } catch (_) {} + + return '${Directory.current.path}/assets/images/wallpaper'; + } + + String get _userBaseDir { + if (userBaseDir != null) return userBaseDir!; + final home = Platform.environment['HOME'] ?? ''; + return '$home/.config/tree-image-optimizer/wallpaper'; + } + + /// 壁紙選択肢を返す。 + /// 上部に同梱壁紙、その後にユーザー壁紙を配置し、各グループ内でファイル名ソートする。 + Future> listWallpapers() async { + final bundled = await _listBundled(); + final user = await _listUser(); + + return [...bundled, ...user]; + } + + Future> _listBundled() async { + final base = await _bundledBaseDir(); + final dir = Directory(base); + final files = await _listWallpaperFiles(dir); + if (files.isNotEmpty) { + return files + .map( + (f) => WallpaperOption( + name: _fileName(f.path), + source: 'assets/images/wallpaper/${_fileName(f.path)}', + isBundled: true, + ), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + } + + // ファイルシステムで見つからない場合、AssetManifest から取得 + try { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + const prefix = 'assets/images/wallpaper/'; + final assets = manifest + .listAssets() + .where((k) => k.startsWith(prefix) && _isWallpaperFile(k)) + .toList(); + return assets + .map( + (k) => + WallpaperOption(name: _fileName(k), source: k, isBundled: true), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + } catch (e) { + _log?.logger.warning('同梱壁紙の取得失敗: $e'); + return const []; + } + } + + Future> _listUser() async { + final dir = Directory(_userBaseDir); + final files = await _listWallpaperFiles(dir); + return files + .map( + (f) => WallpaperOption( + name: _fileName(f.path), + source: f.path, + isBundled: false, + ), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + } + + String _fileName(String path) { + return path.split(Platform.pathSeparator).last; + } + + /// フォルダ内の壁紙ファイル一覧を返す。 + /// フォルダが存在しない・権限不足の場合は空リスト。 + Future> _listWallpaperFiles(Directory dir) async { + try { + if (!await dir.exists()) return const []; + final result = []; + await for (final entity in dir.list()) { + if (entity is! File) continue; + if (!_isWallpaperFile(entity.path)) continue; + result.add(entity); + } + return result; + } catch (e) { + _log?.logger.warning('壁紙フォルダの参照失敗: ${dir.path} $e'); + return const []; + } + } + + bool _isWallpaperFile(String path) { + final lower = path.toLowerCase(); + return lower.endsWith('.jpg') || + lower.endsWith('.jpeg') || + lower.endsWith('.png'); + } + + /// 選択中の壁紙が一覧に無い場合やユーザー壁紙が参照不可の場合はフォールバック先を返す。 + /// 仕様: 存在しない/権限不足の場合は同梱壁紙1にフォールバック。 + String resolveSelected(String current, List options) { + if (options.isEmpty) return current; + if (options.any((o) => o.source == current)) { + // ユーザー壁紙の場合はファイル存在確認 + final matched = options.firstWhere((o) => o.source == current); + if (!matched.isBundled) { + final file = File(matched.source); + try { + if (!file.existsSync()) { + return _fallback(options); + } + } catch (_) { + return _fallback(options); + } + } + return current; + } + return _fallback(options); + } + + String _fallback(List options) { + // 同梱壁紙の最初のものを返す。無ければ先頭。 + final bundled = options.where((o) => o.isBundled).toList(); + if (bundled.isNotEmpty) return bundled.first.source; + return options.first.source; + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d775e02..ecd1b66 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -70,6 +70,12 @@ "languageJapanese": "Japanese", "languageEnglish": "English", "infoLanguage": "Select the display language. When unset, the OS language is used.", + "wallpaper": "Wallpaper", + "infoWallpaper": "Place image files under .config/tree-image-optimizer/wallpaper to select your own wallpaper.", + "wallpaperOpacity": "Wallpaper opacity", + "infoWallpaperOpacity": "Adjust the wallpaper opacity.", + "wallpaperBackgroundColor": "Background color", + "infoWallpaperBackgroundColor": "The wallpaper maintains its aspect ratio, so margins may appear. The margin color is the background color.", "screenAbout": "About", "sectionApp": "Application", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 4e256ea..9ea0837 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -71,6 +71,12 @@ "languageJapanese": "日本語", "languageEnglish": "英語", "infoLanguage": "表示言語を選択します。未設定の場合はOSの言語設定に従います。", + "wallpaper": "背景画像", + "infoWallpaper": ".config/tree-image-optimizer/wallpaper 配下に背景画像ファイルを配置すると、自分が用意したファイルを選択することができます。", + "wallpaperOpacity": "背景画像の透明度", + "infoWallpaperOpacity": "背景画像の透明度を調節することができます。", + "wallpaperBackgroundColor": "背景色", + "infoWallpaperBackgroundColor": "背景画像は縦横比を維持するため、余白できることがあります。その余白部分の色が背景色になります。", "screenAbout": "About", "sectionApp": "アプリケーション", diff --git a/lib/logic/viewmodels/settings_view_model.dart b/lib/logic/viewmodels/settings_view_model.dart index 7aa7d8d..80310de 100644 --- a/lib/logic/viewmodels/settings_view_model.dart +++ b/lib/logic/viewmodels/settings_view_model.dart @@ -1,10 +1,14 @@ +import 'dart:ui'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/constants/app_constants.dart'; import '../../core/models/settings_screen_settings.dart'; import '../../core/models/sound_option.dart'; +import '../../core/models/wallpaper_option.dart'; import '../../data/repositories/settings_repository.dart'; import '../../data/services/sound_service.dart'; +import '../../data/services/wallpaper_service.dart'; /// 設定画面の状態。 class SettingsState { @@ -15,13 +19,18 @@ class SettingsState { this.pngVersion = AppConstants.pngVersion, this.jpegXlVersion = AppConstants.jpegXlVersion, this.av1Version = AppConstants.av1Version, + this.webpVersion = AppConstants.webpVersion, this.showOsNotification = false, this.playSound = false, this.successSound = '', this.errorSound = '', this.language = '', + this.wallpaper = 'assets/images/wallpaper/wallpaper1.jpg', + this.wallpaperOpacity = 1.0, + this.wallpaperBackgroundColor = '#FFFFFF', this.successSounds = const [], this.errorSounds = const [], + this.wallpapers = const [], }); /// upscal-bin のバージョン。 @@ -42,6 +51,9 @@ class SettingsState { /// AV1 のバージョン。 final String av1Version; + /// WebP のバージョン。 + final String webpVersion; + final bool showOsNotification; final bool playSound; final String successSound; @@ -50,8 +62,13 @@ class SettingsState { /// 選択された表示言語(BCP 47)。空文字は「環境設定に従う」を意味する。 final String language; + final String wallpaper; + final double wallpaperOpacity; + final String wallpaperBackgroundColor; + final List successSounds; final List errorSounds; + final List wallpapers; SettingsState copyWith({ bool? showOsNotification, @@ -59,8 +76,12 @@ class SettingsState { String? successSound, String? errorSound, String? language, + String? wallpaper, + double? wallpaperOpacity, + String? wallpaperBackgroundColor, List? successSounds, List? errorSounds, + List? wallpapers, }) { return SettingsState( upscalBinVersion: upscalBinVersion, @@ -69,26 +90,36 @@ class SettingsState { pngVersion: pngVersion, jpegXlVersion: jpegXlVersion, av1Version: av1Version, + webpVersion: webpVersion, showOsNotification: showOsNotification ?? this.showOsNotification, playSound: playSound ?? this.playSound, successSound: successSound ?? this.successSound, errorSound: errorSound ?? this.errorSound, language: language ?? this.language, + wallpaper: wallpaper ?? this.wallpaper, + wallpaperOpacity: wallpaperOpacity ?? this.wallpaperOpacity, + wallpaperBackgroundColor: + wallpaperBackgroundColor ?? this.wallpaperBackgroundColor, successSounds: successSounds ?? this.successSounds, errorSounds: errorSounds ?? this.errorSounds, + wallpapers: wallpapers ?? this.wallpapers, ); } } /// 設定画面のビューモデル。 class SettingsViewModel extends StateNotifier { - SettingsViewModel({this.settingsRepository, this.soundService}) - : super(const SettingsState()) { + SettingsViewModel({ + this.settingsRepository, + this.soundService, + this.wallpaperService, + }) : super(const SettingsState()) { _load(); } final SettingsRepository? settingsRepository; final SoundService? soundService; + final WallpaperService? wallpaperService; Future _load() async { final repository = settingsRepository; @@ -103,6 +134,9 @@ class SettingsViewModel extends StateNotifier { successSound: settings.successSound, errorSound: settings.errorSound, language: settings.language, + wallpaper: settings.wallpaper, + wallpaperOpacity: settings.wallpaperOpacity, + wallpaperBackgroundColor: settings.wallpaperBackgroundColor, ); } @@ -118,6 +152,24 @@ class SettingsViewModel extends StateNotifier { errorSound: _resolveSelected(state.errorSound, errorSounds), ); } + + final wpService = wallpaperService; + if (wpService != null) { + final wallpapers = await wpService.listWallpapers(); + if (!mounted) return; + state = state.copyWith( + wallpapers: wallpapers, + wallpaper: wpService.resolveSelected(state.wallpaper, wallpapers), + ); + // 解決後の値が保存値と異なる場合は永続化 + if (state.wallpapers.isNotEmpty) { + final resolved = wpService.resolveSelected(state.wallpaper, wallpapers); + if (resolved != state.wallpaper) { + state = state.copyWith(wallpaper: resolved); + _save(); + } + } + } } /// 選択中のサウンドが一覧に無い場合は先頭の選択肢で補正する。 @@ -138,6 +190,9 @@ class SettingsViewModel extends StateNotifier { successSound: s.successSound, errorSound: s.errorSound, language: s.language, + wallpaper: s.wallpaper, + wallpaperOpacity: s.wallpaperOpacity, + wallpaperBackgroundColor: s.wallpaperBackgroundColor, ), ); } @@ -181,4 +236,30 @@ class SettingsViewModel extends StateNotifier { state = state.copyWith(language: value); _save(); } + + void setWallpaper(String value) { + state = state.copyWith(wallpaper: value); + _save(); + } + + void setWallpaperOpacity(double value) { + state = state.copyWith(wallpaperOpacity: value.clamp(0.0, 1.0)); + _save(); + } + + void setWallpaperBackgroundColor(String value) { + state = state.copyWith(wallpaperBackgroundColor: value); + _save(); + } + + Color parseWallpaperBackgroundColor() { + final hex = state.wallpaperBackgroundColor; + var h = hex.trim(); + if (h.startsWith('#')) h = h.substring(1); + if (h.length == 6) h = 'FF$h'; + if (h.length != 8) return const Color(0xFFFFFFFF); + final v = int.tryParse(h, radix: 16); + if (v == null) return const Color(0xFFFFFFFF); + return Color(v); + } } diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index a73e93f..3f9874b 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -1,8 +1,10 @@ +import 'package:flutter_color_picker_plus/flutter_color_picker_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_ui/material_ui.dart'; import '../../app/providers.dart'; import '../../core/models/sound_option.dart'; +import '../../core/models/wallpaper_option.dart'; import '../widgets/section.dart'; import 'package:tree_image_optimizer/l10n/generated/app_localizations.dart'; @@ -98,11 +100,41 @@ class SettingsScreen extends ConsumerWidget { const SizedBox(height: 24), SectionCard( title: l10n.sectionBasic, - child: _languageRow( - context: context, - value: state.language, - info: l10n.infoLanguage, - onChanged: (v) => viewModel.setLanguage(v ?? ''), + child: Column( + children: [ + _languageRow( + context: context, + value: state.language, + info: l10n.infoLanguage, + onChanged: (v) => viewModel.setLanguage(v ?? ''), + ), + const SizedBox(height: 16), + _wallpaperRow( + context: context, + label: l10n.wallpaper, + info: l10n.infoWallpaper, + value: state.wallpaper, + options: state.wallpapers, + onChanged: (v) { + if (v != null) viewModel.setWallpaper(v); + }, + ), + const SizedBox(height: 16), + _wallpaperOpacityRow( + context: context, + label: l10n.wallpaperOpacity, + value: state.wallpaperOpacity, + onChanged: (v) => viewModel.setWallpaperOpacity(v), + ), + const SizedBox(height: 16), + _wallpaperBackgroundColorRow( + context: context, + label: l10n.wallpaperBackgroundColor, + info: l10n.infoWallpaperBackgroundColor, + value: state.wallpaperBackgroundColor, + onChanged: (v) => viewModel.setWallpaperBackgroundColor(v), + ), + ], ), ), ], @@ -192,4 +224,163 @@ class SettingsScreen extends ConsumerWidget { ], ); } + + Widget _wallpaperRow({ + required BuildContext context, + required String label, + required String info, + required String value, + required List options, + required ValueChanged onChanged, + }) { + return Row( + children: [ + SizedBox(width: 110, child: Text(label)), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + initialValue: options.any((o) => o.source == value) ? value : null, + isExpanded: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + items: [ + for (final option in options) + DropdownMenuItem( + value: option.source, + child: Text(option.label), + ), + ], + onChanged: onChanged, + ), + ), + const SizedBox(width: 8), + InfoTooltip(text: info), + ], + ); + } + + Widget _wallpaperOpacityRow({ + required BuildContext context, + required String label, + required double value, + required ValueChanged onChanged, + }) { + return Row( + children: [ + SizedBox(width: 110, child: Text(label)), + const SizedBox(width: 8), + Expanded( + child: Slider( + value: value.clamp(0.0, 1.0), + min: 0.0, + max: 1.0, + divisions: 10, + label: value.toStringAsFixed(1), + onChanged: onChanged, + ), + ), + SizedBox(width: 40, child: Text(value.toStringAsFixed(1))), + ], + ); + } + + Widget _wallpaperBackgroundColorRow({ + required BuildContext context, + required String label, + required String info, + required String value, + required ValueChanged onChanged, + }) { + final color = _parseColor(value) ?? const Color(0xFFFFFFFF); + return Row( + children: [ + SizedBox(width: 110, child: Text(label)), + const SizedBox(width: 8), + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(value, style: const TextStyle(fontSize: 12))), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () async { + final picked = await _showColorPicker(context, color); + if (picked != null) { + onChanged(_colorToHex(picked)); + } + }, + child: const Text('選択'), + ), + const SizedBox(width: 8), + InfoTooltip(text: info), + ], + ); + } + + Color? _parseColor(String hex) { + var h = hex.trim(); + if (h.startsWith('#')) h = h.substring(1); + if (h.length == 6) h = 'FF$h'; + if (h.length != 8) return null; + final v = int.tryParse(h, radix: 16); + if (v == null) return null; + return Color(v); + } + + String _colorToHex(Color color) { + final a = (color.a * 255).round(); + final r = (color.r * 255).round(); + final g = (color.g * 255).round(); + final b = (color.b * 255).round(); + if (a == 255) { + return '#${r.toRadixString(16).padLeft(2, '0')}${g.toRadixString(16).padLeft(2, '0')}${b.toRadixString(16).padLeft(2, '0')}' + .toUpperCase(); + } + return '#${a.toRadixString(16).padLeft(2, '0')}${r.toRadixString(16).padLeft(2, '0')}${g.toRadixString(16).padLeft(2, '0')}${b.toRadixString(16).padLeft(2, '0')}' + .toUpperCase(); + } + + Future _showColorPicker(BuildContext context, Color initial) async { + Color pickerColor = initial; + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('背景色を選択'), + content: SingleChildScrollView( + child: ColorPicker( + pickerColor: pickerColor, + onColorChanged: (c) => pickerColor = c, + paletteType: PaletteType.hsvWithHue, + enableAlpha: false, + displayThumbColor: true, + hexInputBar: false, + labelTypes: const [], + portraitOnly: true, + pickerAreaHeightPercent: 0.7, + colorPickerWidth: 300, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('キャンセル'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(pickerColor), + child: const Text('決定'), + ), + ], + ); + }, + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 4201587..0bdd9db 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -222,6 +222,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_color_picker_plus: + dependency: "direct main" + description: + name: flutter_color_picker_plus + sha256: "0760f42ca70bea3f1a65c85c7037c3dbad8dfcd835b4716bf765e40c2621944e" + url: "https://pub.dev" + source: hosted + version: "2.2.1" flutter_desktop_notifications: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 20acc94..9ffa3d6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: image: ^4.5.4 liquid_glass_easy: ^4.1.1 url_launcher: ^6.3.2 + flutter_color_picker_plus: ^2.2.1 dev_dependencies: flutter_test: sdk: flutter @@ -85,9 +86,7 @@ flutter: - assets/sounds/success/ - assets/sounds/error/ - assets/images/icon-tree-01.png - - assets/images/wallpaper1.jpg - - assets/images/wallpaper2.jpg - - assets/images/wallpaper3.jpg + - assets/images/wallpaper/ # To add assets to your application, add an assets section, like this: # assets: