From 655967d6fa7d050db6a0f897b736a6dc0d7388b4 Mon Sep 17 00:00:00 2001 From: poppingmoon <63451158+poppingmoon@users.noreply.github.com> Date: Wed, 11 Oct 2023 06:59:38 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=9E=E3=82=92=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=83=BC=E3=83=AB=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers.dart | 12 ++ lib/repository/color_theme_repository.dart | 88 +++++++++++++ lib/router/app_router.dart | 2 + lib/router/app_router.gr.dart | 116 ++++++++++-------- .../general_settings_page.dart | 86 +++++++------ .../install_theme_dialog.dart | 67 ++++++++++ .../installed_themes_page.dart | 95 ++++++++++++++ lib/view/themes/app_theme_scope.dart | 13 +- 8 files changed, 390 insertions(+), 89 deletions(-) create mode 100644 lib/repository/color_theme_repository.dart create mode 100644 lib/view/settings_page/general_settings_page/install_theme_dialog.dart create mode 100644 lib/view/settings_page/general_settings_page/installed_themes_page.dart diff --git a/lib/providers.dart b/lib/providers.dart index 889837529..cec7dbffe 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -4,6 +4,7 @@ import 'package:file/local.dart'; import 'package:flutter/widgets.dart'; import 'package:miria/model/account.dart'; import 'package:miria/model/acct.dart'; +import 'package:miria/model/color_theme.dart'; import 'package:miria/model/tab_setting.dart'; import 'package:miria/repository/account_repository.dart'; import 'package:miria/repository/account_settings_repository.dart'; @@ -14,6 +15,7 @@ import 'package:miria/repository/favorite_repository.dart'; import 'package:miria/repository/general_settings_repository.dart'; import 'package:miria/repository/hybrid_timeline_repository.dart'; import 'package:miria/repository/import_export_repository.dart'; +import 'package:miria/repository/color_theme_repository.dart'; import 'package:miria/repository/main_stream_repository.dart'; import 'package:miria/repository/global_time_line_repository.dart'; import 'package:miria/repository/home_time_line_repository.dart'; @@ -266,3 +268,13 @@ final misskeyServerListNotifierProvider = AsyncNotifierProvider.autoDispose< ); final cacheManagerProvider = Provider((ref) => null); + +final colorThemeRepositoryProvider = + NotifierProvider>( + ColorThemeRepository.new, +); + +final installedThemeCodeRepositoryProvider = + NotifierProvider>( + InstalledThemeCodeRepository.new, +); diff --git a/lib/repository/color_theme_repository.dart b/lib/repository/color_theme_repository.dart new file mode 100644 index 000000000..aa9297aa0 --- /dev/null +++ b/lib/repository/color_theme_repository.dart @@ -0,0 +1,88 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:json5/json5.dart'; +import 'package:miria/model/color_theme.dart'; +import 'package:miria/model/misskey_theme.dart'; +import 'package:miria/providers.dart'; +import 'package:miria/view/common/error_dialog_handler.dart'; +import 'package:miria/view/themes/built_in_color_themes.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ColorThemeRepository extends Notifier> { + @override + List build() { + final codes = ref.watch(installedThemeCodeRepositoryProvider); + return [ + ...builtInColorThemes, + ...codes.map((code) { + try { + return ColorTheme.misskey( + MisskeyTheme.fromJson(json5Decode(code) as Map), + ); + } catch (e) { + return null; + } + }).nonNulls, + ]; + } + + Future addTheme(String code) async { + final ColorTheme theme; + try { + theme = ColorTheme.misskey( + MisskeyTheme.fromJson(json5Decode(code) as Map), + ); + } catch (e) { + throw SpecifiedException("テーマの形式が間違っています"); + } + if (state.any((element) => element.id == theme.id)) { + throw SpecifiedException("このテーマは既にインストールされています"); + } + + await ref + .read(installedThemeCodeRepositoryProvider.notifier) + .addTheme(code); + } + + Future removeTheme(String id) async { + await ref + .read(installedThemeCodeRepositoryProvider.notifier) + .removeTheme(id); + } +} + +class InstalledThemeCodeRepository extends Notifier> { + final prefsKey = "themes"; + + @override + List build() { + Future(load); + return []; + } + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final themes = prefs.getStringList(prefsKey); + if (themes == null) { + return; + } + state = themes; + } + + Future addTheme(String code) async { + final prefs = await SharedPreferences.getInstance(); + final codes = prefs.getStringList(prefsKey) ?? []; + codes.add(code); + await prefs.setStringList(prefsKey, codes); + state = codes; + } + + Future removeTheme(String id) async { + final prefs = await SharedPreferences.getInstance(); + final codes = prefs.getStringList(prefsKey); + codes!.removeWhere( + (code) => (json5Decode(code) as Map)["id"] == id, + ); + await prefs.setStringList(prefsKey, codes); + state = codes; + } +} diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index bb7b6d2d8..d7804e551 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -25,6 +25,7 @@ import 'package:miria/view/photo_edit_page/photo_edit_page.dart'; import 'package:miria/view/settings_page/account_settings_page/account_list.dart'; import 'package:miria/view/settings_page/app_info_page/app_info_page.dart'; import 'package:miria/view/settings_page/general_settings_page/general_settings_page.dart'; +import 'package:miria/view/settings_page/general_settings_page/installed_themes_page.dart'; import 'package:miria/view/settings_page/import_export_page/import_export_page.dart'; import 'package:miria/view/settings_page/tab_settings_page/tab_settings_list_page.dart'; import 'package:miria/view/several_account_settings_page/hard_mute_page/hard_mute_page.dart'; @@ -97,5 +98,6 @@ class AppRouter extends _$AppRouter { AutoRoute(page: SharingAccountSelectRoute.page), // きしょ…… AutoRoute(page: MisskeyRouteRoute.page), + AutoRoute(page: InstalledThemesRoute.page), ]; } diff --git a/lib/router/app_router.gr.dart b/lib/router/app_router.gr.dart index cc41f0e75..79af0c0e2 100644 --- a/lib/router/app_router.gr.dart +++ b/lib/router/app_router.gr.dart @@ -345,16 +345,6 @@ abstract class _$AppRouter extends RootStackRouter { child: const SplashPage(), ); }, - TimeLineRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: TimeLinePage( - key: args.key, - initialTabSetting: args.initialTabSetting, - ), - ); - }, UsersListDetailRoute.name: (routeData) { final args = routeData.argsAs(); return AutoRoutePage( @@ -420,6 +410,22 @@ abstract class _$AppRouter extends RootStackRouter { ), ); }, + TimeLineRoute.name: (routeData) { + final args = routeData.argsAs(); + return AutoRoutePage( + routeData: routeData, + child: TimeLinePage( + key: args.key, + initialTabSetting: args.initialTabSetting, + ), + ); + }, + InstalledThemesRoute.name: (routeData) { + return AutoRoutePage( + routeData: routeData, + child: const InstalledThemesPage(), + ); + }, }; } @@ -1625,44 +1631,6 @@ class SplashRoute extends PageRouteInfo { static const PageInfo page = PageInfo(name); } -/// generated route for -/// [TimeLinePage] -class TimeLineRoute extends PageRouteInfo { - TimeLineRoute({ - Key? key, - required TabSetting initialTabSetting, - List? children, - }) : super( - TimeLineRoute.name, - args: TimeLineRouteArgs( - key: key, - initialTabSetting: initialTabSetting, - ), - initialChildren: children, - ); - - static const String name = 'TimeLineRoute'; - - static const PageInfo page = - PageInfo(name); -} - -class TimeLineRouteArgs { - const TimeLineRouteArgs({ - this.key, - required this.initialTabSetting, - }); - - final Key? key; - - final TabSetting initialTabSetting; - - @override - String toString() { - return 'TimeLineRouteArgs{key: $key, initialTabSetting: $initialTabSetting}'; - } -} - /// generated route for /// [UsersListDetailPage] class UsersListDetailRoute extends PageRouteInfo { @@ -1914,3 +1882,55 @@ class UserRouteArgs { return 'UserRouteArgs{key: $key, userId: $userId, account: $account}'; } } + +/// generated route for +/// [TimeLinePage] +class TimeLineRoute extends PageRouteInfo { + TimeLineRoute({ + Key? key, + required TabSetting initialTabSetting, + List? children, + }) : super( + TimeLineRoute.name, + args: TimeLineRouteArgs( + key: key, + initialTabSetting: initialTabSetting, + ), + initialChildren: children, + ); + + static const String name = 'TimeLineRoute'; + + static const PageInfo page = + PageInfo(name); +} + +class TimeLineRouteArgs { + const TimeLineRouteArgs({ + this.key, + required this.initialTabSetting, + }); + + final Key? key; + + final TabSetting initialTabSetting; + + @override + String toString() { + return 'TimeLineRouteArgs{key: $key, initialTabSetting: $initialTabSetting}'; + } +} + +/// generated route for +/// [InstalledThemesPage] +class InstalledThemesRoute extends PageRouteInfo { + const InstalledThemesRoute({List? children}) + : super( + InstalledThemesRoute.name, + initialChildren: children, + ); + + static const String name = 'InstalledThemesRoute'; + + static const PageInfo page = PageInfo(name); +} diff --git a/lib/view/settings_page/general_settings_page/general_settings_page.dart b/lib/view/settings_page/general_settings_page/general_settings_page.dart index fdcc22799..a263cedeb 100644 --- a/lib/view/settings_page/general_settings_page/general_settings_page.dart +++ b/lib/view/settings_page/general_settings_page/general_settings_page.dart @@ -5,7 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:miria/const.dart'; import 'package:miria/model/general_settings.dart'; import 'package:miria/providers.dart'; -import 'package:miria/view/themes/built_in_color_themes.dart'; +import 'package:miria/router/app_router.dart'; @RoutePage() class GeneralSettingsPage extends ConsumerStatefulWidget { @@ -44,20 +44,23 @@ class GeneralSettingsPageState extends ConsumerState { void didChangeDependencies() { super.didChangeDependencies(); final settings = ref.read(generalSettingsRepositoryProvider).settings; + final colorThemes = ref.read(colorThemeRepositoryProvider); setState(() { lightModeTheme = settings.lightColorThemeId; - if (lightModeTheme.isEmpty) { - lightModeTheme = builtInColorThemes - .where((element) => !element.isDarkTheme) - .first - .id; + if (lightModeTheme.isEmpty || + colorThemes.every( + (element) => element.isDarkTheme || element.id != lightModeTheme, + )) { + lightModeTheme = + colorThemes.firstWhere((element) => !element.isDarkTheme).id; } darkModeTheme = settings.darkColorThemeId; if (darkModeTheme.isEmpty || - builtInColorThemes.every((element) => - !element.isDarkTheme || element.id != darkModeTheme)) { + colorThemes.every( + (element) => !element.isDarkTheme || element.id != darkModeTheme, + )) { darkModeTheme = - builtInColorThemes.where((element) => element.isDarkTheme).first.id; + colorThemes.firstWhere((element) => element.isDarkTheme).id; } colorSystem = settings.themeColorSystem; nsfwInherit = settings.nsfwInherit; @@ -103,6 +106,7 @@ class GeneralSettingsPageState extends ConsumerState { @override Widget build(BuildContext context) { + final colorThemes = ref.watch(colorThemeRepositoryProvider); return Scaffold( appBar: AppBar(title: const Text("全般設定")), body: SingleChildScrollView( @@ -224,8 +228,9 @@ class GeneralSettingsPageState extends ConsumerState { const Padding(padding: EdgeInsets.only(top: 10)), const Text("ライトモードで使うテーマ"), DropdownButton( + isExpanded: true, items: [ - for (final element in builtInColorThemes + for (final element in colorThemes .where((element) => !element.isDarkTheme)) DropdownMenuItem( value: element.id, @@ -243,34 +248,45 @@ class GeneralSettingsPageState extends ConsumerState { const Padding(padding: EdgeInsets.only(top: 10)), const Text("ダークモードで使うテーマ"), DropdownButton( - items: [ - for (final element in builtInColorThemes - .where((element) => element.isDarkTheme)) - DropdownMenuItem( - value: element.id, - child: Text("${element.name}っぽいの"), - ) - ], - value: darkModeTheme, - onChanged: (value) => setState(() { - darkModeTheme = value ?? ""; - save(); - })), + isExpanded: true, + items: [ + for (final element in colorThemes + .where((element) => element.isDarkTheme)) + DropdownMenuItem( + value: element.id, + child: Text("${element.name}っぽいの"), + ), + ], + value: darkModeTheme, + onChanged: (value) => setState(() { + darkModeTheme = value ?? ""; + save(); + }), + ), const Padding(padding: EdgeInsets.only(top: 10)), const Text("ライトモード・ダークモードのつかいわけ"), DropdownButton( - items: [ - for (final colorSystem in ThemeColorSystem.values) - DropdownMenuItem( - value: colorSystem, - child: Text(colorSystem.displayName), - ) - ], - value: colorSystem, - onChanged: (value) => setState(() { - colorSystem = value ?? ThemeColorSystem.system; - save(); - })) + isExpanded: true, + items: [ + for (final colorSystem in ThemeColorSystem.values) + DropdownMenuItem( + value: colorSystem, + child: Text(colorSystem.displayName), + ), + ], + value: colorSystem, + onChanged: (value) => setState(() { + colorSystem = value ?? ThemeColorSystem.system; + save(); + }), + ), + ListTile( + title: const Text("テーマの管理"), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () { + context.pushRoute(const InstalledThemesRoute()); + }, + ), ], ), ), diff --git a/lib/view/settings_page/general_settings_page/install_theme_dialog.dart b/lib/view/settings_page/general_settings_page/install_theme_dialog.dart new file mode 100644 index 000000000..0a7ea660c --- /dev/null +++ b/lib/view/settings_page/general_settings_page/install_theme_dialog.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:json5/json5.dart'; +import 'package:miria/model/color_theme.dart'; +import 'package:miria/model/misskey_theme.dart'; +import 'package:miria/providers.dart'; +import 'package:miria/view/common/error_dialog_handler.dart'; + +final _formKeyProvider = Provider.autoDispose((ref) => GlobalKey()); + +class InstallThemeDialog extends ConsumerWidget { + const InstallThemeDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = ref.watch(_formKeyProvider); + + return AlertDialog( + scrollable: true, + title: const Text("テーマのインストール"), + content: Form( + key: formKey, + child: Column( + children: [ + TextFormField( + decoration: const InputDecoration( + labelText: "テーマコード", + ), + keyboardType: TextInputType.multiline, + maxLines: null, + minLines: 10, + textAlignVertical: TextAlignVertical.top, + validator: (code) { + if (code == null || code.isEmpty) { + return "値が入力されていません"; + } + try { + ColorTheme.misskey( + MisskeyTheme.fromJson( + json5Decode(code) as Map, + ), + ); + } catch (e) { + return "テーマの形式が間違っています"; + } + return null; + }, + onSaved: (code) { + if (formKey.currentState!.validate()) { + ref + .read(colorThemeRepositoryProvider.notifier) + .addTheme(code!) + .expectFailure(context); + Navigator.of(context).pop(); + } + }, + ), + ElevatedButton( + onPressed: () => formKey.currentState?.save(), + child: const Text("インストール"), + ), + ], + ), + ), + ); + } +} diff --git a/lib/view/settings_page/general_settings_page/installed_themes_page.dart b/lib/view/settings_page/general_settings_page/installed_themes_page.dart new file mode 100644 index 000000000..6c365310e --- /dev/null +++ b/lib/view/settings_page/general_settings_page/installed_themes_page.dart @@ -0,0 +1,95 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:json5/json5.dart'; +import 'package:miria/model/misskey_theme.dart'; +import 'package:miria/providers.dart'; +import 'package:miria/view/dialogs/simple_confirm_dialog.dart'; +import 'package:miria/view/settings_page/general_settings_page/install_theme_dialog.dart'; + +@RoutePage() +class InstalledThemesPage extends ConsumerWidget { + const InstalledThemesPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final codes = ref.watch(installedThemeCodeRepositoryProvider); + final themes = codes + .map( + (code) => MisskeyTheme.fromJson( + json5Decode(code) as Map, + ), + ) + .toList(); + + return Scaffold( + appBar: AppBar( + title: const Text("インストールされたテーマ"), + actions: [ + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const InstallThemeDialog(), + ); + }, + icon: const Icon(Icons.add), + ), + ], + ), + body: themes.isEmpty + ? const Center(child: Text("インストールされたテーマがありません")) + : ListView.builder( + itemCount: themes.length, + itemBuilder: (context, index) { + final theme = themes[index]; + final code = codes[index]; + return ListTile( + title: Text(theme.name), + subtitle: Text(theme.author ?? ""), + trailing: IconButton( + onPressed: () async { + final result = await SimpleConfirmDialog.show( + context: context, + message: "このテーマを削除しますか?", + primary: "削除する", + secondary: "やめる", + ); + if (!context.mounted) return; + if (result ?? false) { + ref + .read(colorThemeRepositoryProvider.notifier) + .removeTheme(theme.id); + } + }, + icon: const Icon(Icons.delete), + ), + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: const Text("テーマコード"), + content: Text(code), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: code)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("コピーしました")), + ); + Navigator.of(context).pop(); + }, + child: const Text("コピー"), + ), + ], + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/view/themes/app_theme_scope.dart b/lib/view/themes/app_theme_scope.dart index dfa6c5408..14b4c8ba7 100644 --- a/lib/view/themes/app_theme_scope.dart +++ b/lib/view/themes/app_theme_scope.dart @@ -8,7 +8,6 @@ import 'package:miria/model/color_theme.dart'; import 'package:miria/model/general_settings.dart'; import 'package:miria/providers.dart'; import 'package:miria/view/themes/app_theme.dart'; -import 'package:miria/view/themes/built_in_color_themes.dart'; class AppThemeScope extends ConsumerStatefulWidget { final Widget child; @@ -364,6 +363,7 @@ class AppThemeScopeState extends ConsumerState { .select((value) => value.settings.cursiveFontName)); final fantasyFontName = ref.watch(generalSettingsRepositoryProvider .select((value) => value.settings.fantasyFontName)); + final colorThemes = ref.watch(colorThemeRepositoryProvider); final bool isDark; if (colorSystem == ThemeColorSystem.system) { @@ -375,11 +375,12 @@ class AppThemeScopeState extends ConsumerState { isDark = false; } - final foundColorTheme = builtInColorThemes.firstWhereOrNull((e) => - e.isDarkTheme == isDark && - e.id == (isDark ? darkTheme : lightTheme)) ?? - builtInColorThemes - .firstWhere((element) => element.isDarkTheme == isDark); + final foundColorTheme = colorThemes.firstWhereOrNull( + (e) => + e.isDarkTheme == isDark && + e.id == (isDark ? darkTheme : lightTheme), + ) ?? + colorThemes.firstWhere((element) => element.isDarkTheme == isDark); return Theme( data: buildTheme(