diff --git a/assets/translations/de.json b/assets/translations/de.json index aea452e..97576c4 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -203,10 +203,12 @@ "categories": "Categories", "category": "Category", "noCategory": "No Category", - "deleteCategoryQuestion": "Delete Category?", - "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "noCategories": "No Categories", + "deleteCategoriesQuestion": "Delete Categories?", + "categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.", "addCategory": "Add Category", "label": "Label", + "language": "Language", "tooManyRequestsTryAgainInMinutes": { "one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut", "other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut" diff --git a/assets/translations/en.json b/assets/translations/en.json index 99b57e7..8231108 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -203,10 +203,12 @@ "categories": "Categories", "category": "Category", "noCategory": "No Category", - "deleteCategoryQuestion": "Delete Category?", - "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "noCategories": "No Categories", + "deleteCategoriesQuestion": "Delete Categories?", + "categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.", "addCategory": "Add Category", "label": "Label", + "language": "Language", "tooManyRequestsTryAgainInMinutes": { "one": "Too many requests (rate limited) - try again in {} minute", "other": "Too many requests (rate limited) - try again in {} minutes" diff --git a/assets/translations/hu.json b/assets/translations/hu.json index fec9af3..19fcf8c 100644 --- a/assets/translations/hu.json +++ b/assets/translations/hu.json @@ -203,10 +203,12 @@ "categories": "Categories", "category": "Category", "noCategory": "No Category", - "deleteCategoryQuestion": "Delete Category?", - "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "noCategories": "No Categories", + "deleteCategoriesQuestion": "Delete Categories?", + "categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.", "addCategory": "Add Category", "label": "Label", + "language": "Language", "tooManyRequestsTryAgainInMinutes": { "one": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva", "other": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva" diff --git a/assets/translations/it.json b/assets/translations/it.json index d4a660b..2499dfb 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -203,10 +203,12 @@ "categories": "Categories", "category": "Category", "noCategory": "No Category", - "deleteCategoryQuestion": "Delete Category?", - "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "noCategories": "No Categories", + "deleteCategoriesQuestion": "Delete Categories?", + "categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.", "addCategory": "Add Category", "label": "Label", + "language": "Language", "tooManyRequestsTryAgainInMinutes": { "one": "Troppe richieste (traffico limitato) - riprova tra {} minuto", "other": "Troppe richieste (traffico limitato) - riprova tra {} minuti" diff --git a/assets/translations/ja.json b/assets/translations/ja.json index b500a4e..f84efab 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -203,10 +203,12 @@ "categories": "カテゴリ", "category": "カテゴリ", "noCategory": "カテゴリなし", - "deleteCategoryQuestion": "カテゴリを削除しますか?", - "categoryDeleteWarning": "「{}」内のすべてのアプリは未分類に設定されます。", + "noCategories": "No Categories", + "deleteCategoriesQuestion": "Delete Categories?", + "categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.", "addCategory": "カテゴリを追加", "label": "ラベル", + "language": "Language", "tooManyRequestsTryAgainInMinutes": { "one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください", "other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください" diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 2d968ca..36cf8c2 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -203,10 +203,12 @@ "categories": "Categories", "category": "Category", "noCategory": "No Category", - "deleteCategoryQuestion": "Delete Category?", - "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "noCategories": "No Categories", + "deleteCategoriesQuestion": "Delete Categories?", + "categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.", "addCategory": "Add Category", "label": "Label", + "language": "Language", "tooManyRequestsTryAgainInMinutes": { "one": "请求过多 (API 限制) - 在 {} 分钟后重试", "other": "请求过多 (API 限制) - 在 {} 分钟后重试" diff --git a/lib/components/generated_form.dart b/lib/components/generated_form.dart index f1a845a..bc0e237 100644 --- a/lib/components/generated_form.dart +++ b/lib/components/generated_form.dart @@ -1,5 +1,9 @@ +import 'dart:math'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:obtainium/components/generated_form_modal.dart'; +import 'package:obtainium/providers/settings_provider.dart'; abstract class GeneratedFormItem { late String key; @@ -82,6 +86,33 @@ class GeneratedFormSwitch extends GeneratedFormItem { } } +class GeneratedFormTagInput extends GeneratedFormItem { + late MapEntry? deleteConfirmationMessage; + late bool singleSelect; + late WrapAlignment alignment; + late String emptyMessage; + GeneratedFormTagInput(String key, + {String label = 'Input', + List belowWidgets = const [], + Map> defaultValue = const {}, + List> value)> + additionalValidators = const [], + this.deleteConfirmationMessage, + this.singleSelect = false, + this.alignment = WrapAlignment.start, + this.emptyMessage = 'Input'}) + : super(key, + label: label, + belowWidgets: belowWidgets, + defaultValue: defaultValue, + additionalValidators: additionalValidators); + + @override + Map> ensureType(val) { + return val is Map> ? val : {}; + } +} + typedef OnValueChanges = void Function( Map values, bool valid, bool isBuilding); @@ -120,6 +151,21 @@ class _GeneratedFormState extends State { widget.onValueChanges(returnValues, valid, isBuilding); } + // Generates a random light color +// Courtesy of ChatGPT 😭 (with a bugfix 🥳) + Color generateRandomLightColor() { + // Create a random number generator + final Random random = Random(); + + // Generate random hue, saturation, and value values + final double hue = random.nextDouble() * 360; + final double saturation = 0.5 + random.nextDouble() * 0.5; + final double value = 0.9 + random.nextDouble() * 0.1; + + // Create a HSV color with the random values + return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor(); + } + @override void initState() { super.initState(); @@ -212,6 +258,158 @@ class _GeneratedFormState extends State { }) ], ); + } else if (widget.items[r][e] is GeneratedFormTagInput) { + formInputs[r][e] = Wrap( + alignment: (widget.items[r][e] as GeneratedFormTagInput).alignment, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + (values[widget.items[r][e].key] + as Map>?) + ?.isEmpty == + true + ? Text( + (widget.items[r][e] as GeneratedFormTagInput) + .emptyMessage, + style: const TextStyle(fontWeight: FontWeight.bold), + ) + : const SizedBox.shrink(), + ...(values[widget.items[r][e].key] + as Map>?) + ?.entries + .map((e2) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ChoiceChip( + label: Text(e2.key), + backgroundColor: Color(e2.value.key).withAlpha(50), + selectedColor: Color(e2.value.key), + visualDensity: VisualDensity.compact, + selected: e2.value.value, + onSelected: (value) { + setState(() { + (values[widget.items[r][e].key] as Map>)[e2.key] = + MapEntry( + (values[widget.items[r][e].key] as Map< + String, + MapEntry>)[e2.key]! + .key, + value); + if ((widget.items[r][e] as GeneratedFormTagInput) + .singleSelect && + value == true) { + for (var key in (values[widget.items[r][e].key] + as Map>) + .keys) { + if (key != e2.key) { + (values[widget.items[r][e].key] as Map< + String, + MapEntry>)[key] = MapEntry( + (values[widget.items[r][e].key] as Map< + String, + MapEntry>)[key]! + .key, + false); + } + } + } + someValueChanged(); + }); + }, + )); + }) ?? + [const SizedBox.shrink()], + (values[widget.items[r][e].key] + as Map>?) + ?.values + .where((e) => e.value) + .isNotEmpty == + true + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IconButton( + onPressed: () { + fn() { + setState(() { + var temp = values[widget.items[r][e].key] + as Map>; + temp.removeWhere((key, value) => value.value); + values[widget.items[r][e].key] = temp; + someValueChanged(); + }); + } + + if ((widget.items[r][e] as GeneratedFormTagInput) + .deleteConfirmationMessage != + null) { + var message = + (widget.items[r][e] as GeneratedFormTagInput) + .deleteConfirmationMessage!; + showDialog?>( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: message.key, + message: message.value, + items: const []); + }).then((value) { + if (value != null) { + fn(); + } + }); + } else { + fn(); + } + }, + icon: const Icon(Icons.remove), + visualDensity: VisualDensity.compact, + tooltip: tr('remove'), + )) + : const SizedBox.shrink(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IconButton( + onPressed: () { + showDialog?>( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: widget.items[r][e].label, + items: [ + [ + GeneratedFormTextField('label', + label: tr('label')) + ] + ]); + }).then((value) { + String? label = value?['label']; + if (label != null) { + setState(() { + var temp = values[widget.items[r][e].key] + as Map>?; + temp ??= {}; + var singleSelect = + (widget.items[r][e] as GeneratedFormTagInput) + .singleSelect; + var someSelected = temp.entries + .where((element) => element.value.value) + .isNotEmpty; + temp[label] = MapEntry( + generateRandomLightColor().value, + !(someSelected && singleSelect)); + values[widget.items[r][e].key] = temp; + someValueChanged(); + }); + } + }); + }, + icon: const Icon(Icons.add), + visualDensity: VisualDensity.compact, + tooltip: tr('add'), + )), + ], + ); } } } diff --git a/lib/main.dart b/lib/main.dart index 6614be9..8c4cd51 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart'; // ignore: implementation_imports import 'package:easy_localization/src/localization.dart'; -const String currentVersion = '0.9.2'; +const String currentVersion = '0.9.3'; const String currentReleaseTag = 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES diff --git a/lib/pages/app.dart b/lib/pages/app.dart index ae6c070..3315569 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -5,6 +5,7 @@ import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/main.dart'; +import 'package:obtainium/pages/settings.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -152,49 +153,22 @@ class _AppPageState extends State { fontStyle: FontStyle.italic, fontSize: 12), ), const SizedBox( - height: 32, + height: 48, ), - app?.app.category != null - ? Chip( - label: Text(app!.app.category!), - backgroundColor: - Color(categories[app.app.category!] ?? 0x0), - onDeleted: () { - app.app.category = null; - appsProvider.saveApps([app.app]); - }, - visualDensity: VisualDensity.compact, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - onPressed: () { - showDialog?>( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: 'Pick a Category', - items: [ - [ - settingsProvider - .getCategoryFormItem() - ] - ]); - }).then((value) { - if (value != null && app != null) { - String? cat = (value['category'] - ?.isNotEmpty ?? - false) - ? value['category'] - : null; - app.app.category = cat; - appsProvider.saveApps([app.app]); - } - }); - }, - child: Text(tr('categorize'))) - ]) + CategoryEditorSelector( + alignment: WrapAlignment.center, + singleSelect: true, + preselected: app?.app.category != null + ? {app!.app.category!} + : {}, + onSelected: (categories) { + if (app != null) { + app.app.category = categories.isNotEmpty + ? categories[0] + : null; + appsProvider.saveApps([app.app]); + } + }) ], )), ], diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index bc0e70e..2e107ba 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -6,6 +6,7 @@ import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/main.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/logs_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; @@ -41,7 +42,6 @@ class _SettingsPageState extends State { Widget build(BuildContext context) { SettingsProvider settingsProvider = context.watch(); SourceProvider sourceProvider = SourceProvider(); - AppsProvider appsProvider = context.read(); if (settingsProvider.prefs == null) { settingsProvider.initializeSettings(); } @@ -130,6 +130,25 @@ class _SettingsPageState extends State { } }); + var localeDropdown = DropdownButtonFormField( + decoration: InputDecoration(labelText: tr('language')), + value: settingsProvider.forcedLocale, + items: [ + DropdownMenuItem( + value: null, + child: Text(tr('followSystem')), + ), + ...supportedLocales.map((e) => DropdownMenuItem( + value: e.toLanguageTag(), + child: Text(e.toLanguageTag().toUpperCase()), + )) + ], + onChanged: (value) { + settingsProvider.forcedLocale = value; + context.setLocale(Locale(settingsProvider.forcedLocale ?? + context.fallbackLocale!.languageCode)); + }); + var intervalDropdown = DropdownButtonFormField( decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')), value: settingsProvider.updateInterval, @@ -178,8 +197,6 @@ class _SettingsPageState extends State { height: 16, ); - var categories = settingsProvider.categories; - return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ @@ -213,6 +230,8 @@ class _SettingsPageState extends State { ], ), height16, + localeDropdown, + height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -264,85 +283,7 @@ class _SettingsPageState extends State { color: Theme.of(context).colorScheme.primary), ), height16, - Wrap( - children: [ - ...categories.entries.toList().map((e) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4), - child: Chip( - label: Text(e.key), - backgroundColor: Color(e.value), - visualDensity: VisualDensity.compact, - onDeleted: () { - showDialog?>( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: tr( - 'deleteCategoryQuestion'), - message: tr( - 'categoryDeleteWarning', - args: [e.key]), - items: []); - }).then((value) { - if (value != null) { - setState(() { - categories.remove(e.key); - settingsProvider.categories = - categories; - }); - appsProvider.saveApps(appsProvider - .apps.values - .where((element) => - element.app.category == - e.key) - .map((e) { - var a = e.app; - a.category = null; - return a; - }).toList()); - } - }); - }, - )); - }), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4), - child: IconButton( - onPressed: () { - showDialog?>( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: tr('addCategory'), - items: [ - [ - GeneratedFormTextField( - 'label', - label: tr('label')) - ] - ]); - }).then((value) { - String? label = value?['label']; - if (label != null) { - setState(() { - categories[label] = - generateRandomLightColor() - .value; - settingsProvider.categories = - categories; - }); - } - }); - }, - icon: const Icon(Icons.add), - visualDensity: VisualDensity.compact, - tooltip: tr('add'), - )) - ], - ) + const CategoryEditorSelector() ], ))), SliverToBoxAdapter( @@ -457,3 +398,59 @@ class _LogsDialogState extends State { ); } } + +class CategoryEditorSelector extends StatefulWidget { + final void Function(List categories)? onSelected; + final bool singleSelect; + final Set preselected; + final WrapAlignment alignment; + const CategoryEditorSelector( + {super.key, + this.onSelected, + this.singleSelect = false, + this.preselected = const {}, + this.alignment = WrapAlignment.start}); + + @override + State createState() => _CategoryEditorSelectorState(); +} + +class _CategoryEditorSelectorState extends State { + Map> storedValues = {}; + + @override + Widget build(BuildContext context) { + var settingsProvider = context.watch(); + storedValues = settingsProvider.categories.map((key, value) => MapEntry( + key, + MapEntry(value, + storedValues[key]?.value ?? widget.preselected.contains(key)))); + return GeneratedForm( + items: [ + [ + GeneratedFormTagInput('categories', + label: tr('category'), + emptyMessage: tr('noCategories'), + defaultValue: storedValues, + alignment: widget.alignment, + deleteConfirmationMessage: MapEntry( + tr('deleteCategoriesQuestion'), + tr('categoryDeleteWarning')), + singleSelect: widget.singleSelect) + ] + ], + onValueChanges: ((values, valid, isBuilding) { + if (!isBuilding) { + storedValues = + values['categories'] as Map>; + settingsProvider.categories = + storedValues.map((key, value) => MapEntry(key, value.key)); + if (widget.onSelected != null) { + widget.onSelected!(storedValues.keys + .where((k) => storedValues[k]!.value) + .toList()); + } + } + })); + } +} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 9e887b0..07fcbee 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/main.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -153,6 +154,7 @@ class SettingsProvider with ChangeNotifier { set categories(Map cats) { prefs?.setString('categories', jsonEncode(cats)); + notifyListeners(); } getCategoryFormItem({String initCategory = ''}) => GeneratedFormDropdown( @@ -163,4 +165,24 @@ class SettingsProvider with ChangeNotifier { ...categories.entries.map((e) => MapEntry(e.key, e.key)).toList() ], defaultValue: initCategory); + + String? get forcedLocale { + var fl = prefs?.getString('forcedLocale'); + return supportedLocales + .where((element) => element.toLanguageTag() == fl) + .isNotEmpty + ? fl + : null; + } + + set forcedLocale(String? fl) { + if (fl == null) { + prefs?.remove('forcedLocale'); + } else if (supportedLocales + .where((element) => element.toLanguageTag() == fl) + .isNotEmpty) { + prefs?.setString('forcedLocale', fl); + } + notifyListeners(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 4e85d02..6bd4ff3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.2+90 # When changing this, update the tag in main() accordingly +version: 0.9.3+91 # When changing this, update the tag in main() accordingly environment: sdk: '>=2.18.2 <3.0.0'