diff --git a/analysis_options.yaml b/analysis_options.yaml index a75a8dc..694764a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -19,7 +19,6 @@ linter: - avoid_setters_without_getters - avoid_single_cascade_in_expression_statements - avoid_type_to_string - - avoid_types_on_closure_parameters - avoid_unnecessary_containers - avoid_unused_constructor_parameters - avoid_void_async @@ -105,13 +104,13 @@ linter: - unrelated_type_equality_checks - use_full_hex_values_for_flutter_colors - use_is_even_rather_than_modulo - - use_test_throws_matchers + - use_named_constants - use_raw_strings - use_rethrow_when_possible - use_setters_to_change_properties + - use_test_throws_matchers - use_to_and_as_if_applicable - void_checks - - use_named_constants analyzer: exclude: diff --git a/lib/app.dart b/lib/app.dart index 5944e4d..e8cc8d8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,29 +1,29 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:keyboard_dismisser/keyboard_dismisser.dart'; -import 'hooks/stores.dart'; import 'l10n/l10n.dart'; import 'pages/home_page.dart'; +import 'stores/config_store.dart'; import 'theme.dart'; +import 'util/observer_consumers.dart'; -class MyApp extends HookWidget { +class MyApp extends StatelessWidget { const MyApp(); @override Widget build(BuildContext context) { - final configStore = useConfigStore(); - return KeyboardDismisser( - child: MaterialApp( - title: 'lemmur', - supportedLocales: L10n.supportedLocales, - localizationsDelegates: L10n.localizationsDelegates, - themeMode: configStore.theme, - darkTheme: configStore.amoledDarkMode ? amoledTheme : darkTheme, - locale: configStore.locale, - theme: lightTheme, - home: const HomePage(), + child: ObserverBuilder( + builder: (context, store) => MaterialApp( + title: 'lemmur', + supportedLocales: L10n.supportedLocales, + localizationsDelegates: L10n.localizationsDelegates, + themeMode: store.theme, + darkTheme: store.amoledDarkMode ? amoledTheme : darkTheme, + locale: store.locale, + theme: lightTheme, + home: const HomePage(), + ), ), ); } diff --git a/lib/hooks/stores.dart b/lib/hooks/stores.dart index 8c6988d..9038dd9 100644 --- a/lib/hooks/stores.dart +++ b/lib/hooks/stores.dart @@ -1,13 +1,23 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:mobx/mobx.dart'; import 'package:provider/provider.dart'; import '../stores/accounts_store.dart'; -import '../stores/config_store.dart'; AccountsStore useAccountsStore() => useContext().watch(); T useAccountsStoreSelect(T selector(AccountsStore store)) => useContext().select(selector); -ConfigStore useConfigStore() => useContext().watch(); -T useConfigStoreSelect(T selector(ConfigStore store)) => - useContext().select(selector); +V useStore(V Function(S value) selector) { + final context = useContext(); + final store = context.read(); + final state = useState(selector(store)); + + useEffect(() { + return autorun((_) { + state.value = selector(store); + }); + }, []); + + return state.value; +} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index edc9f2e..ca5c83e 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; export 'package:flutter_gen/gen_l10n/l10n.dart'; export 'l10n_api.dart'; export 'l10n_from_string.dart'; -abstract class LocaleSerde { - static Locale fromJson(String? json) { +class LocaleConverter implements JsonConverter { + const LocaleConverter(); + + @override + Locale fromJson(String? json) { if (json == null) return const Locale('en'); final lang = json.split('-'); @@ -14,7 +18,8 @@ abstract class LocaleSerde { return Locale(lang[0], lang.length > 1 ? lang[1] : null); } - static String toJson(Locale locale) => locale.toLanguageTag(); + @override + String? toJson(Locale locale) => locale.toLanguageTag(); } const _languageNames = { diff --git a/lib/main_common.dart b/lib/main_common.dart index 3eec4c4..e67f81c 100644 --- a/lib/main_common.dart +++ b/lib/main_common.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'app_config.dart'; @@ -14,16 +15,17 @@ Future mainCommon(AppConfig appConfig) async { WidgetsFlutterBinding.ensureInitialized(); final logConsoleStore = LogConsolePageStore(); + final sharedPrefs = await SharedPreferences.getInstance(); _setupLogger(appConfig, logConsoleStore); - final configStore = await ConfigStore.load(); + final configStore = ConfigStore.load(sharedPrefs); final accountsStore = await AccountsStore.load(); runApp( MultiProvider( providers: [ - ChangeNotifierProvider.value(value: configStore), + Provider.value(value: configStore), ChangeNotifierProvider.value(value: accountsStore), Provider.value(value: logConsoleStore), ], diff --git a/lib/pages/full_post/comment_section.dart b/lib/pages/full_post/comment_section.dart index 378da9a..45404d3 100644 --- a/lib/pages/full_post/comment_section.dart +++ b/lib/pages/full_post/comment_section.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../../comment_tree.dart'; import '../../l10n/l10n.dart'; diff --git a/lib/pages/full_post/full_post.dart b/lib/pages/full_post/full_post.dart index b7a31a1..c9f2fd0 100644 --- a/lib/pages/full_post/full_post.dart +++ b/lib/pages/full_post/full_post.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:lemmy_api_client/v3.dart'; -import 'package:provider/provider.dart'; import '../../hooks/logged_in_action.dart'; import '../../stores/accounts_store.dart'; diff --git a/lib/pages/home_tab.dart b/lib/pages/home_tab.dart index f768c6f..901b733 100644 --- a/lib/pages/home_tab.dart +++ b/lib/pages/home_tab.dart @@ -8,6 +8,7 @@ import '../hooks/infinite_scroll.dart'; import '../hooks/memo_future.dart'; import '../hooks/stores.dart'; import '../l10n/l10n.dart'; +import '../stores/config_store.dart'; import '../util/goto.dart'; import '../widgets/bottom_modal.dart'; import '../widgets/cached_network_image.dart'; @@ -25,7 +26,7 @@ class HomeTab extends HookWidget { Widget build(BuildContext context) { final accStore = useAccountsStore(); final defaultListingType = - useConfigStoreSelect((configStore) => configStore.defaultListingType); + useStore((ConfigStore store) => store.defaultListingType); final selectedList = useState(_SelectedList( listingType: accStore.hasNoAccount && defaultListingType == PostListingType.subscribed diff --git a/lib/pages/log_console_page/log_console_page.dart b/lib/pages/log_console_page/log_console_page.dart index 20e0243..dceea4b 100644 --- a/lib/pages/log_console_page/log_console_page.dart +++ b/lib/pages/log_console_page/log_console_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; -import 'package:provider/provider.dart'; import '../../util/observer_consumers.dart'; import '../../widgets/bottom_safe.dart'; diff --git a/lib/pages/settings/blocks/block_tile.dart b/lib/pages/settings/blocks/block_tile.dart index 12bffec..7fd0cc0 100644 --- a/lib/pages/settings/blocks/block_tile.dart +++ b/lib/pages/settings/blocks/block_tile.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:provider/provider.dart'; import '../../../util/async_store_listener.dart'; import '../../../util/extensions/api.dart'; diff --git a/lib/pages/settings/blocks/blocks.dart b/lib/pages/settings/blocks/blocks.dart index 02b9e44..89b22ee 100644 --- a/lib/pages/settings/blocks/blocks.dart +++ b/lib/pages/settings/blocks/blocks.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:provider/provider.dart'; import '../../../hooks/stores.dart'; import '../../../l10n/l10n_from_string.dart'; diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index a87c84d..0f1a95e 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -6,7 +6,10 @@ import 'package:lemmy_api_client/v3.dart'; import '../../hooks/stores.dart'; import '../../l10n/l10n.dart'; +import '../../stores/config_store.dart'; +import '../../util/async_store_listener.dart'; import '../../util/goto.dart'; +import '../../util/observer_consumers.dart'; import '../../widgets/about_tile.dart'; import '../../widgets/bottom_modal.dart'; import '../../widgets/radio_picker.dart'; @@ -66,112 +69,112 @@ class SettingsPage extends HookWidget { } /// Settings for theme color, AMOLED switch -class AppearanceConfigPage extends HookWidget { +class AppearanceConfigPage extends StatelessWidget { const AppearanceConfigPage(); @override Widget build(BuildContext context) { - final configStore = useConfigStore(); - return Scaffold( appBar: AppBar(title: const Text('Appearance')), - body: ListView( - children: [ - const _SectionHeading('Theme'), - for (final theme in ThemeMode.values) - RadioListTile( - value: theme, - title: Text(describeEnum(theme)), - groupValue: configStore.theme, - onChanged: (selected) { - if (selected != null) configStore.theme = selected; + body: ObserverBuilder( + builder: (context, store) => ListView( + children: [ + const _SectionHeading('Theme'), + for (final theme in ThemeMode.values) + RadioListTile( + value: theme, + title: Text(describeEnum(theme)), + groupValue: store.theme, + onChanged: (selected) { + if (selected != null) store.theme = selected; + }, + ), + SwitchListTile.adaptive( + title: const Text('AMOLED dark mode'), + value: store.amoledDarkMode, + onChanged: (checked) { + store.amoledDarkMode = checked; }, ), - SwitchListTile.adaptive( - title: const Text('AMOLED dark mode'), - value: configStore.amoledDarkMode, - onChanged: (checked) { - configStore.amoledDarkMode = checked; - }, - ), - const SizedBox(height: 12), - const _SectionHeading('Other'), - SwitchListTile.adaptive( - title: Text(L10n.of(context)!.show_avatars), - value: configStore.showAvatars, - onChanged: (checked) { - configStore.showAvatars = checked; - }, - ), - SwitchListTile.adaptive( - title: const Text('Show scores'), - value: configStore.showScores, - onChanged: (checked) { - configStore.showScores = checked; - }, - ), - ], + const SizedBox(height: 12), + const _SectionHeading('Other'), + SwitchListTile.adaptive( + title: Text(L10n.of(context)!.show_avatars), + value: store.showAvatars, + onChanged: (checked) { + store.showAvatars = checked; + }, + ), + SwitchListTile.adaptive( + title: const Text('Show scores'), + value: store.showScores, + onChanged: (checked) { + store.showScores = checked; + }, + ), + ], + ), ), ); } } /// General settings -class GeneralConfigPage extends HookWidget { +class GeneralConfigPage extends StatelessWidget { const GeneralConfigPage(); @override Widget build(BuildContext context) { - final configStore = useConfigStore(); - return Scaffold( appBar: AppBar(title: const Text('General')), - body: ListView( - children: [ - ListTile( - title: Text(L10n.of(context)!.sort_type), - trailing: SizedBox( - width: 120, - child: RadioPicker( - values: SortType.values, - groupValue: configStore.defaultSortType, - onChanged: (value) => configStore.defaultSortType = value, - mapValueToString: (value) => value.value, + body: ObserverBuilder( + builder: (context, store) => ListView( + children: [ + ListTile( + title: Text(L10n.of(context)!.sort_type), + trailing: SizedBox( + width: 120, + child: RadioPicker( + values: SortType.values, + groupValue: store.defaultSortType, + onChanged: (value) => store.defaultSortType = value, + mapValueToString: (value) => value.value, + ), ), ), - ), - ListTile( - title: Text(L10n.of(context)!.type), - trailing: SizedBox( - width: 120, - child: RadioPicker( - values: const [ - PostListingType.all, - PostListingType.local, - PostListingType.subscribed, - ], - groupValue: configStore.defaultListingType, - onChanged: (value) => configStore.defaultListingType = value, - mapValueToString: (value) => value.value, + ListTile( + title: Text(L10n.of(context)!.type), + trailing: SizedBox( + width: 120, + child: RadioPicker( + values: const [ + PostListingType.all, + PostListingType.local, + PostListingType.subscribed, + ], + groupValue: store.defaultListingType, + onChanged: (value) => store.defaultListingType = value, + mapValueToString: (value) => value.value, + ), ), ), - ), - ListTile( - title: Text(L10n.of(context)!.language), - trailing: SizedBox( - width: 120, - child: RadioPicker( - title: 'Choose language', - groupValue: configStore.locale, - values: L10n.supportedLocales, - mapValueToString: (locale) => locale.languageName, - onChanged: (selected) { - configStore.locale = selected; - }, + ListTile( + title: Text(L10n.of(context)!.language), + trailing: SizedBox( + width: 120, + child: RadioPicker( + title: 'Choose language', + groupValue: store.locale, + values: L10n.supportedLocales, + mapValueToString: (locale) => locale.languageName, + onChanged: (selected) { + store.locale = selected; + }, + ), ), ), - ), - ], + ], + ), ), ); } @@ -191,8 +194,6 @@ class _AccountOptions extends HookWidget { @override Widget build(BuildContext context) { final accountsStore = useAccountsStore(); - final configStore = useConfigStore(); - final importLoading = useState(false); Future removeUserDialog(String instanceHost, String username) async { if (await showDialog( @@ -235,34 +236,28 @@ class _AccountOptions extends HookWidget { title: const Text('Remove account'), onTap: () => removeUserDialog(instanceHost, username), ), - ListTile( - leading: importLoading.value - ? const SizedBox( - height: 25, - width: 25, - child: CircularProgressIndicator.adaptive(), - ) - : const Icon(Icons.cloud_download), - title: const Text('Import settings to lemmur'), - onTap: () async { - importLoading.value = true; - try { - await configStore.importLemmyUserSettings( - accountsStore.userDataFor(instanceHost, username)!.jwt, - ); - - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('Import successful'), - )); - } on Exception catch (err) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(err.toString()), - )); - } finally { + AsyncStoreListener( + asyncStore: context.read().lemmyImportState, + successMessageBuilder: (context, data) => 'Import successful', + child: ObserverBuilder( + builder: (context, store) => ListTile( + leading: store.lemmyImportState.isLoading + ? const SizedBox( + height: 25, + width: 25, + child: CircularProgressIndicator.adaptive(), + ) + : const Icon(Icons.cloud_download), + title: const Text('Import settings to lemmur'), + onTap: () async { + await context.read().importLemmyUserSettings( + accountsStore.userDataFor(instanceHost, username)!.jwt, + ); Navigator.of(context).pop(); - importLoading.value = false; - } - }), + }, + ), + ), + ), ], ); } diff --git a/lib/stores/config_store.dart b/lib/stores/config_store.dart index 70599b6..a684aea 100644 --- a/lib/stores/config_store.dart +++ b/lib/stores/config_store.dart @@ -1,89 +1,84 @@ import 'dart:convert'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:lemmy_api_client/v3.dart'; +import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/l10n.dart'; +import '../util/async_store.dart'; part 'config_store.g.dart'; /// Store managing user-level configuration such as theme or language @JsonSerializable() -class ConfigStore extends ChangeNotifier { - static const prefsKey = 'v1:ConfigStore'; - static final _prefs = SharedPreferences.getInstance(); +@LocaleConverter() +class ConfigStore extends _ConfigStore with _$ConfigStore { + static const _prefsKey = 'v1:ConfigStore'; + late final SharedPreferences _sharedPrefs; + late final ReactionDisposer _saveDisposer; - late ThemeMode _theme; + @visibleForTesting + ConfigStore(); + + factory ConfigStore.load(SharedPreferences sharedPrefs) { + final store = _$ConfigStoreFromJson( + jsonDecode(sharedPrefs.getString(_prefsKey) ?? '{}') + as Map, + ).._sharedPrefs = sharedPrefs; + + store._saveDisposer = autorun((_) => store.save()); + + return store; + } + + Future save() async { + final serialized = jsonEncode(_$ConfigStoreToJson(this)); + + await _sharedPrefs.setString(_prefsKey, serialized); + } + + void dispose() { + _saveDisposer(); + } +} + +abstract class _ConfigStore with Store { + @observable @JsonKey(defaultValue: ThemeMode.system) - ThemeMode get theme => _theme; - set theme(ThemeMode theme) { - _theme = theme; - notifyListeners(); - save(); - } + ThemeMode theme = ThemeMode.system; - late bool _amoledDarkMode; + @observable @JsonKey(defaultValue: false) - bool get amoledDarkMode => _amoledDarkMode; - set amoledDarkMode(bool amoledDarkMode) { - _amoledDarkMode = amoledDarkMode; - notifyListeners(); - save(); - } + bool amoledDarkMode = false; - late Locale _locale; - // default value is set in the `LocaleSerde.fromJson` method because json_serializable does - // not accept non-literals as defaultValue - @JsonKey(fromJson: LocaleSerde.fromJson, toJson: LocaleSerde.toJson) - Locale get locale => _locale; - set locale(Locale locale) { - _locale = locale; - notifyListeners(); - save(); - } + // default value is set in the `LocaleConverter.fromJson` + @observable + Locale locale = const Locale('en'); - late bool _showAvatars; + @observable @JsonKey(defaultValue: true) - bool get showAvatars => _showAvatars; - set showAvatars(bool showAvatars) { - _showAvatars = showAvatars; - notifyListeners(); - save(); - } + bool showAvatars = true; - late bool _showScores; + @observable @JsonKey(defaultValue: true) - bool get showScores => _showScores; - set showScores(bool showScores) { - _showScores = showScores; - notifyListeners(); - save(); - } + bool showScores = true; - late SortType _defaultSortType; // default is set in fromJson + @observable @JsonKey(fromJson: _sortTypeFromJson) - SortType get defaultSortType => _defaultSortType; - set defaultSortType(SortType defaultSortType) { - _defaultSortType = defaultSortType; - notifyListeners(); - save(); - } + SortType defaultSortType = SortType.hot; - late PostListingType _defaultListingType; // default is set in fromJson + @observable @JsonKey(fromJson: _postListingTypeFromJson) - PostListingType get defaultListingType => _defaultListingType; - set defaultListingType(PostListingType defaultListingType) { - _defaultListingType = defaultListingType; - notifyListeners(); - save(); - } + PostListingType defaultListingType = PostListingType.all; + + final lemmyImportState = AsyncStore(); /// Copies over settings from lemmy to [ConfigStore] + @action void copyLemmyUserSettings(LocalUserSettings localUserSettings) { // themes from lemmy-ui that are dark mode const darkModeLemmyUiThemes = { @@ -94,8 +89,8 @@ class ConfigStore extends ChangeNotifier { 'i386', }; - _showAvatars = localUserSettings.showAvatars; - _theme = () { + showAvatars = localUserSettings.showAvatars; + theme = () { if (localUserSettings.theme == 'browser') return ThemeMode.system; if (darkModeLemmyUiThemes.contains(localUserSettings.theme)) { @@ -104,36 +99,27 @@ class ConfigStore extends ChangeNotifier { return ThemeMode.light; }(); - _locale = L10n.supportedLocales.contains(Locale(localUserSettings.lang)) - ? Locale(localUserSettings.lang) - : _locale; - _showScores = localUserSettings.showScores; - _defaultSortType = localUserSettings.defaultSortType; - _defaultListingType = localUserSettings.defaultListingType; - notifyListeners(); - save(); + if (L10n.supportedLocales.contains(Locale(localUserSettings.lang))) { + locale = Locale(localUserSettings.lang); + } + + showScores = localUserSettings.showScores; + defaultSortType = localUserSettings.defaultSortType; + defaultListingType = localUserSettings.defaultListingType; } /// Fetches [LocalUserSettings] and imports them with [.copyLemmyUserSettings] + @action Future importLemmyUserSettings(Jwt token) async { - final site = - await LemmyApiV3(token.payload.iss).run(GetSite(auth: token.raw)); - copyLemmyUserSettings(site.myUser!.localUserView.localUser); - } - - static Future load() async { - final prefs = await _prefs; - - return _$ConfigStoreFromJson( - jsonDecode(prefs.getString(prefsKey) ?? '{}') as Map, + final site = await lemmyImportState.runLemmy( + token.payload.iss, + GetSite(auth: token.raw), ); - } - Future save() async { - final prefs = await _prefs; - - await prefs.setString(prefsKey, jsonEncode(_$ConfigStoreToJson(this))); + if (site != null) { + copyLemmyUserSettings(site.myUser!.localUserView.localUser); + } } } diff --git a/lib/stores/config_store.g.dart b/lib/stores/config_store.g.dart index 63510ea..cfd865b 100644 --- a/lib/stores/config_store.g.dart +++ b/lib/stores/config_store.g.dart @@ -10,7 +10,7 @@ ConfigStore _$ConfigStoreFromJson(Map json) => ConfigStore() ..theme = $enumDecodeNullable(_$ThemeModeEnumMap, json['theme']) ?? ThemeMode.system ..amoledDarkMode = json['amoledDarkMode'] as bool? ?? false - ..locale = LocaleSerde.fromJson(json['locale'] as String?) + ..locale = const LocaleConverter().fromJson(json['locale'] as String?) ..showAvatars = json['showAvatars'] as bool? ?? true ..showScores = json['showScores'] as bool? ?? true ..defaultSortType = _sortTypeFromJson(json['defaultSortType'] as String?) @@ -21,7 +21,7 @@ Map _$ConfigStoreToJson(ConfigStore instance) => { 'theme': _$ThemeModeEnumMap[instance.theme], 'amoledDarkMode': instance.amoledDarkMode, - 'locale': LocaleSerde.toJson(instance.locale), + 'locale': const LocaleConverter().toJson(instance.locale), 'showAvatars': instance.showAvatars, 'showScores': instance.showScores, 'defaultSortType': instance.defaultSortType, @@ -33,3 +33,152 @@ const _$ThemeModeEnumMap = { ThemeMode.light: 'light', ThemeMode.dark: 'dark', }; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic + +mixin _$ConfigStore on _ConfigStore, Store { + final _$themeAtom = Atom(name: '_ConfigStore.theme'); + + @override + ThemeMode get theme { + _$themeAtom.reportRead(); + return super.theme; + } + + @override + set theme(ThemeMode value) { + _$themeAtom.reportWrite(value, super.theme, () { + super.theme = value; + }); + } + + final _$amoledDarkModeAtom = Atom(name: '_ConfigStore.amoledDarkMode'); + + @override + bool get amoledDarkMode { + _$amoledDarkModeAtom.reportRead(); + return super.amoledDarkMode; + } + + @override + set amoledDarkMode(bool value) { + _$amoledDarkModeAtom.reportWrite(value, super.amoledDarkMode, () { + super.amoledDarkMode = value; + }); + } + + final _$localeAtom = Atom(name: '_ConfigStore.locale'); + + @override + Locale get locale { + _$localeAtom.reportRead(); + return super.locale; + } + + @override + set locale(Locale value) { + _$localeAtom.reportWrite(value, super.locale, () { + super.locale = value; + }); + } + + final _$showAvatarsAtom = Atom(name: '_ConfigStore.showAvatars'); + + @override + bool get showAvatars { + _$showAvatarsAtom.reportRead(); + return super.showAvatars; + } + + @override + set showAvatars(bool value) { + _$showAvatarsAtom.reportWrite(value, super.showAvatars, () { + super.showAvatars = value; + }); + } + + final _$showScoresAtom = Atom(name: '_ConfigStore.showScores'); + + @override + bool get showScores { + _$showScoresAtom.reportRead(); + return super.showScores; + } + + @override + set showScores(bool value) { + _$showScoresAtom.reportWrite(value, super.showScores, () { + super.showScores = value; + }); + } + + final _$defaultSortTypeAtom = Atom(name: '_ConfigStore.defaultSortType'); + + @override + SortType get defaultSortType { + _$defaultSortTypeAtom.reportRead(); + return super.defaultSortType; + } + + @override + set defaultSortType(SortType value) { + _$defaultSortTypeAtom.reportWrite(value, super.defaultSortType, () { + super.defaultSortType = value; + }); + } + + final _$defaultListingTypeAtom = + Atom(name: '_ConfigStore.defaultListingType'); + + @override + PostListingType get defaultListingType { + _$defaultListingTypeAtom.reportRead(); + return super.defaultListingType; + } + + @override + set defaultListingType(PostListingType value) { + _$defaultListingTypeAtom.reportWrite(value, super.defaultListingType, () { + super.defaultListingType = value; + }); + } + + final _$importLemmyUserSettingsAsyncAction = + AsyncAction('_ConfigStore.importLemmyUserSettings'); + + @override + Future importLemmyUserSettings(Jwt token) { + return _$importLemmyUserSettingsAsyncAction + .run(() => super.importLemmyUserSettings(token)); + } + + final _$_ConfigStoreActionController = ActionController(name: '_ConfigStore'); + + @override + void copyLemmyUserSettings(LocalUserSettings localUserSettings) { + final _$actionInfo = _$_ConfigStoreActionController.startAction( + name: '_ConfigStore.copyLemmyUserSettings'); + try { + return super.copyLemmyUserSettings(localUserSettings); + } finally { + _$_ConfigStoreActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +theme: ${theme}, +amoledDarkMode: ${amoledDarkMode}, +locale: ${locale}, +showAvatars: ${showAvatars}, +showScores: ${showScores}, +defaultSortType: ${defaultSortType}, +defaultListingType: ${defaultListingType} + '''; + } +} diff --git a/lib/util/observer_consumers.dart b/lib/util/observer_consumers.dart index f7dbd65..56f645f 100644 --- a/lib/util/observer_consumers.dart +++ b/lib/util/observer_consumers.dart @@ -4,6 +4,8 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:mobx/mobx.dart'; import 'package:provider/provider.dart'; +export 'package:provider/provider.dart'; + typedef MobxBuilder = Widget Function(BuildContext, T); typedef MobxListener = void Function(BuildContext, T); diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index d812c6b..21ad949 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import '../hooks/stores.dart'; +import '../stores/config_store.dart'; import 'cached_network_image.dart'; /// User's avatar. Respects the `showAvatars` setting from configStore @@ -26,8 +27,7 @@ class Avatar extends HookWidget { @override Widget build(BuildContext context) { final showAvatars = - useConfigStoreSelect((configStore) => configStore.showAvatars) || - alwaysShow; + useStore((ConfigStore store) => store.showAvatars) || alwaysShow; final blankWidget = () { if (noBlank) return const SizedBox.shrink(); diff --git a/lib/widgets/comment/comment.dart b/lib/widgets/comment/comment.dart index bf42abc..1be3b4f 100644 --- a/lib/widgets/comment/comment.dart +++ b/lib/widgets/comment/comment.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:lemmy_api_client/v3.dart'; -import 'package:provider/provider.dart'; import '../../comment_tree.dart'; import '../../l10n/l10n.dart'; diff --git a/lib/widgets/comment/comment_actions.dart b/lib/widgets/comment/comment_actions.dart index 5a2e8d1..f026fdf 100644 --- a/lib/widgets/comment/comment_actions.dart +++ b/lib/widgets/comment/comment_actions.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:lemmy_api_client/v3.dart'; -import 'package:provider/provider.dart'; import '../../hooks/logged_in_action.dart'; import '../../l10n/l10n.dart'; diff --git a/lib/widgets/post/post_actions.dart b/lib/widgets/post/post_actions.dart index 5449554..091e396 100644 --- a/lib/widgets/post/post_actions.dart +++ b/lib/widgets/post/post_actions.dart @@ -1,7 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:provider/provider.dart'; import '../../l10n/l10n.dart'; import '../../util/icons.dart'; diff --git a/lib/widgets/post/post_body.dart b/lib/widgets/post/post_body.dart index c30174e..de6a580 100644 --- a/lib/widgets/post/post_body.dart +++ b/lib/widgets/post/post_body.dart @@ -1,6 +1,5 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../../util/observer_consumers.dart'; import '../markdown_text.dart'; diff --git a/lib/widgets/post/post_info_section.dart b/lib/widgets/post/post_info_section.dart index eb6f508..c543da1 100644 --- a/lib/widgets/post/post_info_section.dart +++ b/lib/widgets/post/post_info_section.dart @@ -1,7 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../../l10n/l10n.dart'; import '../../util/extensions/api.dart'; diff --git a/lib/widgets/post/post_more_menu.dart b/lib/widgets/post/post_more_menu.dart index b3c3321..9156d8c 100644 --- a/lib/widgets/post/post_more_menu.dart +++ b/lib/widgets/post/post_more_menu.dart @@ -1,7 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart' as ul; import '../../hooks/logged_in_action.dart'; diff --git a/lib/widgets/post/post_voting.dart b/lib/widgets/post/post_voting.dart index 46a3746..2b2db0f 100644 --- a/lib/widgets/post/post_voting.dart +++ b/lib/widgets/post/post_voting.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:lemmy_api_client/v3.dart'; -import 'package:provider/provider.dart'; import '../../hooks/logged_in_action.dart'; import '../../hooks/stores.dart'; +import '../../stores/config_store.dart'; import '../../util/intl.dart'; import '../../util/observer_consumers.dart'; import 'post_store.dart'; @@ -15,8 +15,7 @@ class PostVoting extends HookWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final showScores = - useConfigStoreSelect((configStore) => configStore.showScores); + final showScores = useStore((ConfigStore store) => store.showScores); final loggedInAction = useLoggedInAction(context .select((store) => store.postView.instanceHost)); diff --git a/lib/widgets/post/save_post_button.dart b/lib/widgets/post/save_post_button.dart index d99fca1..ce6fd92 100644 --- a/lib/widgets/post/save_post_button.dart +++ b/lib/widgets/post/save_post_button.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:provider/provider.dart'; import '../../hooks/logged_in_action.dart'; import '../../util/observer_consumers.dart'; diff --git a/lib/widgets/sortable_infinite_list.dart b/lib/widgets/sortable_infinite_list.dart index 7c4edeb..4991bfd 100644 --- a/lib/widgets/sortable_infinite_list.dart +++ b/lib/widgets/sortable_infinite_list.dart @@ -5,6 +5,7 @@ import 'package:lemmy_api_client/v3.dart'; import '../comment_tree.dart'; import '../hooks/infinite_scroll.dart'; import '../hooks/stores.dart'; +import '../stores/config_store.dart'; import 'comment/comment.dart'; import 'infinite_scroll.dart'; import 'post/post.dart'; @@ -39,7 +40,7 @@ class SortableInfiniteList extends HookWidget { @override Widget build(BuildContext context) { final defaultSortType = - useConfigStoreSelect((store) => store.defaultSortType); + useStore((ConfigStore store) => store.defaultSortType); final defaultController = useInfiniteScrollController(); final isc = controller ?? defaultController; diff --git a/test/blank_test.dart b/test/blank_test.dart deleted file mode 100644 index fa939fa..0000000 --- a/test/blank_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('blank', () { - expect(true, true); - }); -} diff --git a/test/stores/config_store_test.dart b/test/stores/config_store_test.dart new file mode 100644 index 0000000..c427e1c --- /dev/null +++ b/test/stores/config_store_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lemmur/stores/config_store.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _lemmyUserSettings = LocalUserSettings( + id: 1, + personId: 1, + showNsfw: true, + theme: 'browser', + defaultSortType: SortType.active, + defaultListingType: PostListingType.local, + lang: 'en', + showAvatars: true, + showScores: true, + sendNotificationsToEmail: true, + showReadPosts: true, + showBotAccounts: true, + showNewPostNotifs: true, + instanceHost: '', +); + +void main() { + group('ConfigStore', () { + late SharedPreferences prefs; + late ConfigStore store; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + setUp(() async { + store = ConfigStore.load(prefs); + await prefs.clear(); + }); + + test('Store defaults match json defaults', () { + final store = ConfigStore(); + final loaded = ConfigStore.load(prefs); + + expect(store.theme, loaded.theme); + expect(store.amoledDarkMode, loaded.amoledDarkMode); + expect(store.locale, loaded.locale); + expect(store.showAvatars, loaded.showAvatars); + expect(store.showScores, loaded.showScores); + expect(store.defaultSortType, loaded.defaultSortType); + expect(store.defaultListingType, loaded.defaultListingType); + }); + + test('Changes are saved', () { + store.amoledDarkMode = false; + var loaded = ConfigStore.load(prefs); + expect(loaded.amoledDarkMode, false); + + store.amoledDarkMode = true; + loaded = ConfigStore.load(prefs); + expect(loaded.amoledDarkMode, true); + }); + + test('Changes are not saved after disposing', () { + store.amoledDarkMode = false; + var loaded = ConfigStore.load(prefs); + expect(loaded.amoledDarkMode, false); + + store + ..dispose() + ..amoledDarkMode = true; + loaded = ConfigStore.load(prefs); + expect(loaded.amoledDarkMode, false); + }); + + group('Copying LemmyUserSettings', () { + test('works', () { + store + ..theme = ThemeMode.dark + ..amoledDarkMode = false + ..locale = const Locale('pl') + ..showAvatars = false + ..showScores = false + ..defaultSortType = SortType.topYear + ..defaultListingType = PostListingType.all + ..copyLemmyUserSettings(_lemmyUserSettings); + + expect(store.theme, ThemeMode.system); + expect(store.amoledDarkMode, false); + expect(store.locale, const Locale('en')); + expect(store.showAvatars, true); + expect(store.showScores, true); + expect(store.defaultSortType, SortType.active); + expect(store.defaultListingType, PostListingType.local); + }); + + test('detects dark theme', () { + store + ..theme = ThemeMode.light + ..copyLemmyUserSettings(_lemmyUserSettings.copyWith(theme: 'darkly')); + + expect(store.theme, ThemeMode.dark); + }); + + test('lang ignores unrecognized', () { + store + ..locale = const Locale('en') + ..copyLemmyUserSettings( + _lemmyUserSettings.copyWith(lang: 'qweqweqwe')); + + expect(store.locale, const Locale('en')); + }); + + test('detects browser theme', () { + store + ..theme = ThemeMode.light + ..copyLemmyUserSettings( + _lemmyUserSettings.copyWith(theme: 'browser')); + + expect(store.theme, ThemeMode.system); + }); + }); + }); +}