diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 55369c6e0..338eef109 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -718,6 +718,10 @@ } } }, + + "invalidThemeFormat": "テーマの形式が間違っています", + "duplicatedTheme": "このテーマは既にインストールされています", + "importFromThisFolder": "このフォルダーからインポートする", "exportedFileNotFound": "ここにMiriaの設定ファイルあれへんかったわ", "importCompleted": "インポート終わったで。", @@ -814,6 +818,7 @@ } }, "selectLightOrDarkMode": "ライトモード・ダークモードのつかいわけ", + "manageThemes": "テーマの管理", "reaction": "リアクション", "emojiTapReaction": "ノート内の絵文字タップでリアクションする", "emojiTapReactionDescription": "ノート内の絵文字をタップしてリアクションします。MFMや外部サーバーの絵文字の場合うまく機能しないことがあります。", @@ -825,6 +830,12 @@ "fontFantasy": "フォント ($[font.fantasy 用)", "fontSize": "フォントサイズ", "systemFont": "システム標準", + "installTheme": "テーマのインストール", + "themeCode": "テーマコード", + "install": "インストール", + "installedThemes": "インストールされたテーマ", + "noInstalledThemes": "インストールされたテーマがありません", + "confirmDeleteTheme": "このテーマを削除しますか?", "selectFolder": "フォルダー選択", "settingsFileManagement": "設定ファイルの管理", diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index 194b63d0e..afa975f8a 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -52,6 +52,8 @@ import "package:miria/view/server_detail_dialog.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/install_theme_dialog.dart"; +import "package:miria/view/settings_page/general_settings_page/installed_themes_page.dart"; import "package:miria/view/settings_page/import_export_page/folder_select_dialog.dart"; import "package:miria/view/settings_page/import_export_page/import_export_page.dart"; import "package:miria/view/settings_page/settings_page.dart"; @@ -135,6 +137,7 @@ class AppRouter extends _$AppRouter { AutoRoute(page: MisskeyGamesRoute.page), // きしょ…… AutoRoute(page: MisskeyRouteRoute.page), + AutoRoute(page: InstalledThemesRoute.page), AutoRoute(path: "/share-extension", page: ShareExtensionRoute.page), @@ -150,6 +153,7 @@ class AppRouter extends _$AppRouter { AutoDialogRoute(page: ChannelDetailRoute.page), AutoDialogRoute(page: ServerDetailRoute.page), AutoDialogRoute(page: ReactionUserRoute.page), + AutoDialogRoute(page: InstallThemeRoute.page), AutoDialogRoute(page: ChannelSelectRoute.page), AutoDialogRoute(page: ClipSettingsRoute.page), AutoDialogRoute(page: ReactionPickerRoute.page), diff --git a/lib/router/app_router.gr.dart b/lib/router/app_router.gr.dart index 2477e2e3a..2cf8bd1cb 100644 --- a/lib/router/app_router.gr.dart +++ b/lib/router/app_router.gr.dart @@ -107,12 +107,13 @@ abstract class _$AppRouter extends RootStackRouter { final args = routeData.argsAs(); return AutoRoutePage( routeData: routeData, - child: AntennaSettingsDialog( + child: WrappedRoute( + child: AntennaSettingsDialog( account: args.account, key: args.key, title: args.title, initialSettings: args.initialSettings, - ), + )), ); }, AppInfoRoute.name: (routeData) { @@ -336,6 +337,18 @@ abstract class _$AppRouter extends RootStackRouter { child: const ImportExportPage(), ); }, + InstallThemeRoute.name: (routeData) { + return AutoRoutePage( + routeData: routeData, + child: const InstallThemeDialog(), + ); + }, + InstalledThemesRoute.name: (routeData) { + return AutoRoutePage( + routeData: routeData, + child: const InstalledThemesPage(), + ); + }, InstanceMuteRoute.name: (routeData) { final args = routeData.argsAs(); return AutoRoutePage( @@ -1902,6 +1915,34 @@ class ImportExportRoute extends PageRouteInfo { static const PageInfo page = PageInfo(name); } +/// generated route for +/// [InstallThemeDialog] +class InstallThemeRoute extends PageRouteInfo { + const InstallThemeRoute({List? children}) + : super( + InstallThemeRoute.name, + initialChildren: children, + ); + + static const String name = 'InstallThemeRoute'; + + static const PageInfo page = PageInfo(name); +} + +/// 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); +} + /// generated route for /// [InstanceMutePage] class InstanceMuteRoute extends PageRouteInfo { diff --git a/lib/state_notifier/common/download_file_notifier.g.dart b/lib/state_notifier/common/download_file_notifier.g.dart index 09b95ecfb..d7688f927 100644 --- a/lib/state_notifier/common/download_file_notifier.g.dart +++ b/lib/state_notifier/common/download_file_notifier.g.dart @@ -7,7 +7,7 @@ part of 'download_file_notifier.dart'; // ************************************************************************** String _$downloadFileNotifierHash() => - r'1e16b1a213ec582509b1843d15b1987e27020a26'; + r'99b394364feb8276a67c277ad703172e94fffc1b'; /// See also [DownloadFileNotifier]. @ProviderFor(DownloadFileNotifier) diff --git a/lib/state_notifier/installed_themes_page/misskey_theme_codes_notifier.dart b/lib/state_notifier/installed_themes_page/misskey_theme_codes_notifier.dart new file mode 100644 index 000000000..436b6a96f --- /dev/null +++ b/lib/state_notifier/installed_themes_page/misskey_theme_codes_notifier.dart @@ -0,0 +1,77 @@ +import "package:json5/json5.dart"; +import "package:miria/model/color_theme.dart"; +import "package:miria/model/misskey_theme.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +part "misskey_theme_codes_notifier.g.dart"; + +@riverpod +class MisskeyThemeCodesNotifier extends _$MisskeyThemeCodesNotifier { + @override + List build() { + Future(_load); + return []; + } + + static const _key = "themes"; + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final themes = prefs.getStringList(_key); + if (themes == null) { + return; + } + state = themes; + } + + Future _save() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_key, state); + } + + Future install(String code) async { + state = [...state, code]; + await _save(); + } + + Future uninstall(int index) async { + state = [ + ...state.sublist(0, index), + ...state.sublist(index + 1), + ]; + await _save(); + } + + Future import(List codes) async { + state = codes; + await _save(); + } +} + +@riverpod +List misskeyThemes(MisskeyThemesRef ref) { + final codes = ref.watch(misskeyThemeCodesNotifierProvider); + return codes.map((code) { + try { + return MisskeyTheme.fromJson(json5Decode(code) as Map); + } catch (_) { + return null; + } + }).toList(); +} + +@riverpod +List installedColorThemes(InstalledColorThemesRef ref) { + final themes = ref.watch(misskeyThemesProvider); + return themes.nonNulls + .map((theme) { + try { + return ColorTheme.misskey(theme); + } catch (_) { + return null; + } + }) + .nonNulls + .toList(); +} diff --git a/lib/state_notifier/installed_themes_page/misskey_theme_codes_notifier.g.dart b/lib/state_notifier/installed_themes_page/misskey_theme_codes_notifier.g.dart new file mode 100644 index 000000000..e361957ff --- /dev/null +++ b/lib/state_notifier/installed_themes_page/misskey_theme_codes_notifier.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'misskey_theme_codes_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$misskeyThemesHash() => r'89c2f0a221a95189f5f602c7f3cb5a24be8be8bd'; + +/// See also [misskeyThemes]. +@ProviderFor(misskeyThemes) +final misskeyThemesProvider = AutoDisposeProvider>.internal( + misskeyThemes, + name: r'misskeyThemesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$misskeyThemesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef MisskeyThemesRef = AutoDisposeProviderRef>; +String _$installedColorThemesHash() => + r'cb579c552f3df0859e4547a5d4f0b1a6acb2bbc7'; + +/// See also [installedColorThemes]. +@ProviderFor(installedColorThemes) +final installedColorThemesProvider = + AutoDisposeProvider>.internal( + installedColorThemes, + name: r'installedColorThemesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$installedColorThemesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef InstalledColorThemesRef = AutoDisposeProviderRef>; +String _$misskeyThemeCodesNotifierHash() => + r'cde0f918592daf1200a8b10acae3ccf4a78046a0'; + +/// See also [MisskeyThemeCodesNotifier]. +@ProviderFor(MisskeyThemeCodesNotifier) +final misskeyThemeCodesNotifierProvider = AutoDisposeNotifierProvider< + MisskeyThemeCodesNotifier, List>.internal( + MisskeyThemeCodesNotifier.new, + name: r'misskeyThemeCodesNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$misskeyThemeCodesNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$MisskeyThemeCodesNotifier = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, inference_failure_on_uninitialized_variable, inference_failure_on_function_return_type, inference_failure_on_untyped_parameter, deprecated_member_use_from_same_package diff --git a/lib/state_notifier/note_create_page/note_create_state_notifier.g.dart b/lib/state_notifier/note_create_page/note_create_state_notifier.g.dart index a4167f385..e413298f7 100644 --- a/lib/state_notifier/note_create_page/note_create_state_notifier.g.dart +++ b/lib/state_notifier/note_create_page/note_create_state_notifier.g.dart @@ -7,7 +7,7 @@ part of 'note_create_state_notifier.dart'; // ************************************************************************** String _$noteCreateNotifierHash() => - r'a6c19cac73b572cf30473220f793e1826dbeb286'; + r'42d6999502d39a002445a6d1a974970d77dc8d4d'; /// See also [NoteCreateNotifier]. @ProviderFor(NoteCreateNotifier) 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 b71a61677..f310cb6da 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 @@ -9,6 +9,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:miria/const.dart"; import "package:miria/model/general_settings.dart"; import "package:miria/providers.dart"; +import "package:miria/router/app_router.dart"; +import "package:miria/state_notifier/installed_themes_page/misskey_theme_codes_notifier.dart"; import "package:miria/view/themes/built_in_color_themes.dart"; @RoutePage() @@ -109,6 +111,11 @@ class GeneralSettingsPage extends HookConsumerWidget { useMemoized(() => unawaited(save()), dependencies); + final colorThemes = [ + ...builtInColorThemes, + ...ref.watch(installedColorThemesProvider), + ]; + return Scaffold( appBar: AppBar(title: Text(S.of(context).generalSettings)), body: SingleChildScrollView( @@ -244,8 +251,9 @@ class GeneralSettingsPage extends HookConsumerWidget { const Padding(padding: EdgeInsets.only(top: 10)), Text(S.of(context).themeForLightMode), DropdownButton( + isExpanded: true, items: [ - for (final element in builtInColorThemes + for (final element in colorThemes .where((element) => !element.isDarkTheme)) DropdownMenuItem( value: element.id, @@ -259,8 +267,9 @@ class GeneralSettingsPage extends HookConsumerWidget { const Padding(padding: EdgeInsets.only(top: 10)), Text(S.of(context).themeForDarkMode), DropdownButton( + isExpanded: true, items: [ - for (final element in builtInColorThemes + for (final element in colorThemes .where((element) => element.isDarkTheme)) DropdownMenuItem( value: element.id, @@ -273,6 +282,7 @@ class GeneralSettingsPage extends HookConsumerWidget { const Padding(padding: EdgeInsets.only(top: 10)), Text(S.of(context).selectLightOrDarkMode), DropdownButton( + isExpanded: true, items: [ for (final colorSystem in ThemeColorSystem.values) DropdownMenuItem( @@ -284,6 +294,13 @@ class GeneralSettingsPage extends HookConsumerWidget { onChanged: (value) => colorSystem.value = value ?? ThemeColorSystem.system, ), + ListTile( + title: Text(S.of(context).manageThemes), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () async { + await 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..05689f2fe --- /dev/null +++ b/lib/view/settings_page/general_settings_page/install_theme_dialog.dart @@ -0,0 +1,66 @@ +import "package:auto_route/auto_route.dart"; +import "package:flutter/material.dart"; +import "package:flutter_gen/gen_l10n/app_localizations.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:json5/json5.dart"; +import "package:miria/model/color_theme.dart"; +import "package:miria/model/misskey_theme.dart"; +import "package:miria/state_notifier/installed_themes_page/misskey_theme_codes_notifier.dart"; + +@RoutePage() +class InstallThemeDialog extends HookConsumerWidget { + const InstallThemeDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey(), []); + + return AlertDialog( + scrollable: true, + title: Text(S.of(context).installTheme), + content: Form( + key: formKey, + child: Column( + children: [ + TextFormField( + decoration: InputDecoration(labelText: S.of(context).themeCode), + keyboardType: TextInputType.multiline, + maxLines: null, + minLines: 10, + textAlignVertical: TextAlignVertical.top, + validator: (code) { + if (code == null || code.isEmpty) { + return S.of(context).pleaseInputSomething; + } + try { + ColorTheme.misskey( + MisskeyTheme.fromJson( + json5Decode(code) as Map, + ), + ); + } catch (e) { + return S.of(context).invalidThemeFormat; + } + return null; + }, + onSaved: (code) async { + if (formKey.currentState!.validate()) { + await ref + .read(misskeyThemeCodesNotifierProvider.notifier) + .install(code!); + if (!context.mounted) return; + Navigator.of(context).pop(); + } + }, + ), + ElevatedButton( + onPressed: () => formKey.currentState?.save(), + child: Text(S.of(context).install), + ), + ], + ), + ), + ); + } +} 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..55999d467 --- /dev/null +++ b/lib/view/settings_page/general_settings_page/installed_themes_page.dart @@ -0,0 +1,96 @@ +import "package:auto_route/auto_route.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_gen/gen_l10n/app_localizations.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:json5/json5.dart"; +import "package:miria/model/misskey_theme.dart"; +import "package:miria/router/app_router.dart"; +import "package:miria/state_notifier/installed_themes_page/misskey_theme_codes_notifier.dart"; +import "package:miria/view/dialogs/simple_confirm_dialog.dart"; + +@RoutePage() +class InstalledThemesPage extends ConsumerWidget { + const InstalledThemesPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final codes = ref.watch(misskeyThemeCodesNotifierProvider); + final themes = codes + .map( + (code) => MisskeyTheme.fromJson( + json5Decode(code) as Map, + ), + ) + .toList(); + + return Scaffold( + appBar: AppBar( + title: Text(S.of(context).installedThemes), + actions: [ + IconButton( + onPressed: () async { + await context.pushRoute(const InstallThemeRoute()); + }, + icon: const Icon(Icons.add), + ), + ], + ), + body: themes.isEmpty + ? Center(child: Text(S.of(context).noInstalledThemes)) + : 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: S.of(context).confirmDeleteTheme, + primary: S.of(context).willDelete, + secondary: S.of(context).cancel, + ); + if (!context.mounted) return; + if (result ?? false) { + await ref + .read(misskeyThemeCodesNotifierProvider.notifier) + .uninstall(index); + } + }, + icon: const Icon(Icons.delete), + ), + onTap: () async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: Text(S.of(context).themeCode), + content: Text(code), + actions: [ + TextButton( + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: code), + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(S.of(context).doneCopy)), + ); + Navigator.of(context).pop(); + }, + child: Text(S.of(context).copy), + ), + ], + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/view/themes/app_theme_scope.dart b/lib/view/themes/app_theme_scope.dart index e03d21826..1cda81a3a 100644 --- a/lib/view/themes/app_theme_scope.dart +++ b/lib/view/themes/app_theme_scope.dart @@ -7,6 +7,7 @@ import "package:miria/extensions/color_extension.dart"; import "package:miria/model/color_theme.dart"; import "package:miria/model/general_settings.dart"; import "package:miria/providers.dart"; +import "package:miria/state_notifier/installed_themes_page/misskey_theme_codes_notifier.dart"; import "package:miria/view/themes/app_theme.dart"; import "package:miria/view/themes/built_in_color_themes.dart"; @@ -439,6 +440,10 @@ class AppThemeScopeState extends ConsumerState { generalSettingsRepositoryProvider .select((value) => value.settings.languages), ); + final colorThemes = [ + ...builtInColorThemes, + ...ref.watch(installedColorThemesProvider), + ]; final bool isDark; if (colorSystem == ThemeColorSystem.system) { @@ -450,13 +455,12 @@ class AppThemeScopeState extends ConsumerState { isDark = false; } - final foundColorTheme = builtInColorThemes.firstWhereOrNull( + final foundColorTheme = colorThemes.firstWhereOrNull( (e) => e.isDarkTheme == isDark && e.id == (isDark ? darkTheme : lightTheme), ) ?? - builtInColorThemes - .firstWhere((element) => element.isDarkTheme == isDark); + colorThemes.firstWhere((element) => element.isDarkTheme == isDark); return Theme( data: buildTheme(