diff --git a/assets/translations/de.json b/assets/translations/de.json index a07c527..ee30f61 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -199,6 +199,14 @@ "downloadNotifDescription": "Notifies the user of the progress in downloading an App", "noAPKFound": "No APK found", "noVersionDetection": "No version detection", + "categorize": "Categorize", + "categories": "Categories", + "category": "Category", + "noCategory": "No Category", + "deleteCategoryQuestion": "Delete Category?", + "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "addCategory": "Add Category", + "label": "Label", "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 27255cf..a8854a9 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -199,6 +199,14 @@ "downloadNotifDescription": "Notifies the user of the progress in downloading an App", "noAPKFound": "No APK found", "noVersionDetection": "No version detection", + "categorize": "Categorize", + "categories": "Categories", + "category": "Category", + "noCategory": "No Category", + "deleteCategoryQuestion": "Delete Category?", + "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "addCategory": "Add Category", + "label": "Label", "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 993cce9..43de967 100644 --- a/assets/translations/hu.json +++ b/assets/translations/hu.json @@ -199,6 +199,14 @@ "downloadNotifDescription": "Notifies the user of the progress in downloading an App", "noAPKFound": "No APK found", "noVersionDetection": "No version detection", + "categorize": "Categorize", + "categories": "Categories", + "category": "Category", + "noCategory": "No Category", + "deleteCategoryQuestion": "Delete Category?", + "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "addCategory": "Add Category", + "label": "Label", "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 f4d065e..3e49f44 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -199,6 +199,14 @@ "downloadNotifDescription": "Notifies the user of the progress in downloading an App", "noAPKFound": "No APK found", "noVersionDetection": "No version detection", + "categorize": "Categorize", + "categories": "Categories", + "category": "Category", + "noCategory": "No Category", + "deleteCategoryQuestion": "Delete Category?", + "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "addCategory": "Add Category", + "label": "Label", "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 5a89d0e..c96b615 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -199,6 +199,14 @@ "downloadNotifDescription": "アプリのダウンロード状況を通知する", "noAPKFound": "APKが見つかりません", "noVersionDetection": "バージョン検出を行わない", + "categorize": "Categorize", + "categories": "Categories", + "category": "Category", + "noCategory": "No Category", + "deleteCategoryQuestion": "Delete Category?", + "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "addCategory": "Add Category", + "label": "Label", "tooManyRequestsTryAgainInMinutes": { "one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください", "other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください" diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 8d97102..2d968ca 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -199,6 +199,14 @@ "downloadNotifDescription": "通知用户下载进度", "noAPKFound": "未找到安装包", "noVersionDetection": "无版本检测", + "categorize": "Categorize", + "categories": "Categories", + "category": "Category", + "noCategory": "No Category", + "deleteCategoryQuestion": "Delete Category?", + "categoryDeleteWarning": "All Apps in {} will be set to uncategorized.", + "addCategory": "Add Category", + "label": "Label", "tooManyRequestsTryAgainInMinutes": { "one": "请求过多 (API 限制) - 在 {} 分钟后重试", "other": "请求过多 (API 限制) - 在 {} 分钟后重试" diff --git a/lib/main.dart b/lib/main.dart index 7be3433..2404ed1 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.8.23'; +const String currentVersion = '0.9.0'; 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 0434964..28a6277 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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'; @@ -33,6 +34,7 @@ class _AppPageState extends State { }); } + var categories = settingsProvider.categories; var sourceProvider = SourceProvider(); AppInMemory? app = appsProvider.apps[widget.appId]; var source = app != null ? sourceProvider.getSource(app.app.url) : null; @@ -148,7 +150,51 @@ class _AppPageState extends State { textAlign: TextAlign.center, style: const TextStyle( fontStyle: FontStyle.italic, fontSize: 12), - ) + ), + const SizedBox( + height: 32, + ), + 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'))) + ]) ], )), ], diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index 71f3cb9..d479b61 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -80,28 +80,31 @@ class AppsPageState extends State { !(filter!.includeNonInstalled)) { return false; } - if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) { - return true; - } - List nameTokens = filter!.nameFilter - .split(' ') - .where((element) => element.trim().isNotEmpty) - .toList(); - List authorTokens = filter!.authorFilter - .split(' ') - .where((element) => element.trim().isNotEmpty) - .toList(); + if (filter!.nameFilter.isNotEmpty || filter!.authorFilter.isNotEmpty) { + List nameTokens = filter!.nameFilter + .split(' ') + .where((element) => element.trim().isNotEmpty) + .toList(); + List authorTokens = filter!.authorFilter + .split(' ') + .where((element) => element.trim().isNotEmpty) + .toList(); - for (var t in nameTokens) { - var name = app.installedInfo?.name ?? app.app.name; - if (!name.toLowerCase().contains(t.toLowerCase())) { - return false; + for (var t in nameTokens) { + var name = app.installedInfo?.name ?? app.app.name; + if (!name.toLowerCase().contains(t.toLowerCase())) { + return false; + } + } + for (var t in authorTokens) { + if (!app.app.author.toLowerCase().contains(t.toLowerCase())) { + return false; + } } } - for (var t in authorTokens) { - if (!app.app.author.toLowerCase().contains(t.toLowerCase())) { - return false; - } + if (filter!.categoryFilter.isNotEmpty && + filter!.categoryFilter != app.app.category) { + return false; } return true; }).toList(); @@ -225,96 +228,114 @@ class AppsPageState extends State { String? changesUrl = SourceProvider() .getSource(sortedApps[index].app.url) .changeLogPageFromStandardUrl(sortedApps[index].app.url); - return ListTile( - tileColor: sortedApps[index].app.pinned - ? Colors.grey.withOpacity(0.1) - : Colors.transparent, - selectedTileColor: Theme.of(context) - .colorScheme - .primary - .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1), - selected: selectedApps.contains(sortedApps[index].app), - onLongPress: () { - toggleAppSelected(sortedApps[index].app); - }, - leading: sortedApps[index].installedInfo != null - ? Image.memory( - sortedApps[index].installedInfo!.icon!, - gaplessPlayback: true, - ) - : null, - title: Text( - sortedApps[index].installedInfo?.name ?? - sortedApps[index].app.name, - style: TextStyle( - fontWeight: sortedApps[index].app.pinned - ? FontWeight.bold - : FontWeight.normal), - ), - subtitle: Text(tr('byX', args: [sortedApps[index].app.author]), - style: TextStyle( + return Container( + decoration: BoxDecoration( + border: Border.symmetric( + vertical: BorderSide( + width: 3, + color: Color(settingsProvider.categories[ + sortedApps[index].app.category] ?? + const Color.fromARGB(0, 0, 0, 0).value)))), + child: ListTile( + tileColor: sortedApps[index].app.pinned + ? Colors.grey.withOpacity(0.1) + : Colors.transparent, + selectedTileColor: Theme.of(context) + .colorScheme + .primary + .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1), + selected: selectedApps.contains(sortedApps[index].app), + onLongPress: () { + toggleAppSelected(sortedApps[index].app); + }, + leading: sortedApps[index].installedInfo != null + ? Image.memory( + sortedApps[index].installedInfo!.icon!, + gaplessPlayback: true, + ) + : null, + title: Text( + sortedApps[index].installedInfo?.name ?? + sortedApps[index].app.name, + style: TextStyle( fontWeight: sortedApps[index].app.pinned ? FontWeight.bold - : FontWeight.normal)), - trailing: SingleChildScrollView( - reverse: true, - child: sortedApps[index].downloadProgress != null - ? Text(tr('percentProgress', args: [ - sortedApps[index] - .downloadProgress - ?.toInt() - .toString() ?? - '100' - ])) - : (Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox( - width: 100, - child: Text( - '${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.additionalSettings['trackOnly'] == 'true' ? ' ${tr('estimateInBrackets')}' : ''}', - overflow: TextOverflow.fade, - textAlign: TextAlign.end, - )), - sortedApps[index].app.installedVersion != null && - sortedApps[index].app.installedVersion != - sortedApps[index].app.latestVersion - ? GestureDetector( - onTap: changesUrl == null - ? null - : () { - launchUrlString(changesUrl, - mode: LaunchMode - .externalApplication); - }, - child: appsProvider.areDownloadsRunning() - ? Text(tr('pleaseWait')) - : Text( - '${tr('updateAvailable')}${sortedApps[index].app.additionalSettings['trackOnly'] == 'true' ? ' ${tr('estimateInBracketsShort')}' : ''}', - style: TextStyle( - fontStyle: FontStyle.italic, - decoration: changesUrl == null - ? TextDecoration.none - : TextDecoration - .underline), - )) - : const SizedBox(), - ], - ))), - onTap: () { - if (selectedApps.isNotEmpty) { - toggleAppSelected(sortedApps[index].app); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - AppPage(appId: sortedApps[index].app.id)), - ); - } - }, - ); + : FontWeight.normal, + ), + ), + subtitle: Text( + tr('byX', args: [sortedApps[index].app.author]), + style: TextStyle( + fontWeight: sortedApps[index].app.pinned + ? FontWeight.bold + : FontWeight.normal)), + trailing: SingleChildScrollView( + reverse: true, + child: sortedApps[index].downloadProgress != null + ? Text(tr('percentProgress', args: [ + sortedApps[index] + .downloadProgress + ?.toInt() + .toString() ?? + '100' + ])) + : (Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox( + width: 100, + child: Text( + '${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.additionalSettings['trackOnly'] == 'true' ? ' ${tr('estimateInBrackets')}' : ''}', + overflow: TextOverflow.fade, + textAlign: TextAlign.end, + )), + sortedApps[index].app.installedVersion != + null && + sortedApps[index] + .app + .installedVersion != + sortedApps[index] + .app + .latestVersion + ? GestureDetector( + onTap: changesUrl == null + ? null + : () { + launchUrlString(changesUrl, + mode: LaunchMode + .externalApplication); + }, + child: appsProvider + .areDownloadsRunning() + ? Text(tr('pleaseWait')) + : Text( + '${tr('updateAvailable')}${sortedApps[index].app.additionalSettings['trackOnly'] == 'true' ? ' ${tr('estimateInBracketsShort')}' : ''}', + style: TextStyle( + fontStyle: + FontStyle.italic, + decoration: changesUrl == + null + ? TextDecoration.none + : TextDecoration + .underline), + )) + : const SizedBox(), + ], + ))), + onTap: () { + if (selectedApps.isNotEmpty) { + toggleAppSelected(sortedApps[index].app); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + AppPage(appId: sortedApps[index].app.id)), + ); + } + }, + )); }, childCount: sortedApps.length)) ])), persistentFooterButtons: [ @@ -708,6 +729,10 @@ class AppsPageState extends State { label: tr('nonInstalledApps'), type: FormItemType.bool, defaultValue: vals['nonInstalledApps']) + ], + [ + settingsProvider.getCategoryFormItem( + initCategory: vals['category'] ?? '') ] ]); }).then((values) { @@ -734,19 +759,22 @@ class AppsFilter { late String authorFilter; late bool includeUptodate; late bool includeNonInstalled; + late String categoryFilter; AppsFilter( {this.nameFilter = '', this.authorFilter = '', this.includeUptodate = true, - this.includeNonInstalled = true}); + this.includeNonInstalled = true, + this.categoryFilter = ''}); Map toValuesMap() { return { 'appName': nameFilter, 'author': authorFilter, 'upToDateApps': includeUptodate ? 'true' : '', - 'nonInstalledApps': includeNonInstalled ? 'true' : '' + 'nonInstalledApps': includeNonInstalled ? 'true' : '', + 'category': categoryFilter }; } @@ -755,11 +783,13 @@ class AppsFilter { authorFilter = values['author']!; includeUptodate = values['upToDateApps'] == 'true'; includeNonInstalled = values['nonInstalledApps'] == 'true'; + categoryFilter = values['category']!; } bool isIdenticalTo(AppsFilter other) => authorFilter.trim() == other.authorFilter.trim() && nameFilter.trim() == other.nameFilter.trim() && includeUptodate == other.includeUptodate && - includeNonInstalled == other.includeNonInstalled; + includeNonInstalled == other.includeNonInstalled && + categoryFilter.trim() == other.categoryFilter.trim(); } diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 279f6f9..2327d5a 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -1,8 +1,12 @@ +import 'dart:math'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; 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/providers/apps_provider.dart'; import 'package:obtainium/providers/logs_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -17,11 +21,27 @@ class SettingsPage extends StatefulWidget { State createState() => _SettingsPageState(); } +// 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(); +} + class _SettingsPageState extends State { @override Widget build(BuildContext context) { SettingsProvider settingsProvider = context.watch(); SourceProvider sourceProvider = SourceProvider(); + AppsProvider appsProvider = context.read(); if (settingsProvider.prefs == null) { settingsProvider.initializeSettings(); } @@ -157,6 +177,8 @@ class _SettingsPageState extends State { height: 16, ); + var categories = settingsProvider.categories; + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ @@ -232,6 +254,94 @@ class _SettingsPageState extends State { color: Theme.of(context).colorScheme.primary), ), ...sourceSpecificFields, + intervalDropdown, + const Divider( + height: 48, + ), + Text( + tr('categories'), + style: TextStyle( + 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: [ + [ + GeneratedFormItem('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'), + )) + ], + ) ], ))), SliverToBoxAdapter( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 02bdb54..c9781ef 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,9 +1,12 @@ // Exposes functions used to save/load app settings +import 'dart:convert'; + import 'package:easy_localization/easy_localization.dart'; 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:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -144,4 +147,20 @@ class SettingsProvider with ChangeNotifier { prefs?.setString(settingId, value); notifyListeners(); } + + Map get categories => + Map.from(jsonDecode(prefs?.getString('categories') ?? '{}')); + + set categories(Map cats) { + prefs?.setString('categories', jsonEncode(cats)); + } + + getCategoryFormItem({String initCategory = ''}) => + GeneratedFormItem('category', + label: tr('category'), + opts: [ + MapEntry('', tr('noCategory')), + ...categories.entries.map((e) => MapEntry(e.key, e.key)).toList() + ], + defaultValue: initCategory); } diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 5966456..7d2fc4a 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -19,6 +19,7 @@ import 'package:obtainium/app_sources/steammobile.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart'; +import 'package:obtainium/providers/settings_provider.dart'; class AppNames { late String author; @@ -47,6 +48,7 @@ class App { late Map additionalSettings; late DateTime? lastUpdateCheck; bool pinned = false; + String? category; App( this.id, this.url, @@ -58,7 +60,8 @@ class App { this.preferredApkIndex, this.additionalSettings, this.lastUpdateCheck, - this.pinned); + this.pinned, + {this.category}); @override String toString() { @@ -107,7 +110,8 @@ class App { json['lastUpdateCheck'] == null ? null : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), - json['pinned'] ?? false); + json['pinned'] ?? false, + category: json['category']); } Map toJson() => { @@ -121,7 +125,8 @@ class App { 'preferredApkIndex': preferredApkIndex, 'additionalSettings': jsonEncode(additionalSettings), 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, - 'pinned': pinned + 'pinned': pinned, + 'category': category }; } @@ -350,7 +355,8 @@ class SourceProvider { apk.apkUrls.length - 1, additionalSettings, DateTime.now(), - currentApp?.pinned ?? false); + currentApp?.pinned ?? false, + category: currentApp?.category); } // Returns errors in [results, errors] instead of throwing them diff --git a/pubspec.yaml b/pubspec.yaml index 4355a3f..33f6ff6 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.8.23+87 # When changing this, update the tag in main() accordingly +version: 0.9.0+88 # When changing this, update the tag in main() accordingly environment: sdk: '>=2.18.2 <3.0.0'