From 3d6c9bbf98f379928288463d7fbf70e55c046be8 Mon Sep 17 00:00:00 2001 From: Imran Remtulla Date: Sun, 25 Dec 2022 21:41:51 -0500 Subject: [PATCH] Added category multi-select to Apps filter + UI tweaks and bugfixes --- lib/components/generated_form.dart | 324 ++++++++++++----------- lib/components/generated_form_modal.dart | 7 +- lib/pages/add_app.dart | 34 ++- lib/pages/app.dart | 4 +- lib/pages/apps.dart | 170 ++++++------ lib/pages/settings.dart | 13 +- 6 files changed, 294 insertions(+), 258 deletions(-) diff --git a/lib/components/generated_form.dart b/lib/components/generated_form.dart index 37ce543..7698dd0 100644 --- a/lib/components/generated_form.dart +++ b/lib/components/generated_form.dart @@ -91,6 +91,7 @@ class GeneratedFormTagInput extends GeneratedFormItem { late bool singleSelect; late WrapAlignment alignment; late String emptyMessage; + late bool showLabelWhenNotEmpty; GeneratedFormTagInput(String key, {String label = 'Input', List belowWidgets = const [], @@ -100,7 +101,8 @@ class GeneratedFormTagInput extends GeneratedFormItem { this.deleteConfirmationMessage, this.singleSelect = false, this.alignment = WrapAlignment.start, - this.emptyMessage = 'Input'}) + this.emptyMessage = 'Input', + this.showLabelWhenNotEmpty = true}) : super(key, label: label, belowWidgets: belowWidgets, @@ -140,11 +142,11 @@ class _GeneratedFormState extends State { for (int r = 0; r < widget.items.length; r++) { for (int i = 0; i < widget.items[r].length; i++) { if (formInputs[r][i] is TextFormField) { - valid = valid && - ((formInputs[r][i].key as GlobalKey) - .currentState - ?.isValid ?? - false); + var fieldState = + (formInputs[r][i].key as GlobalKey).currentState; + if (fieldState != null) { + valid = valid && fieldState.isValid; + } } } } @@ -259,159 +261,185 @@ 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( + formInputs[r][e] = + Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if ((values[widget.items[r][e].key] + as Map>?) + ?.isNotEmpty == + true && + (widget.items[r][e] as GeneratedFormTagInput) + .showLabelWhenNotEmpty) + Column( + crossAxisAlignment: + (widget.items[r][e] as GeneratedFormTagInput).alignment == + WrapAlignment.center + ? CrossAxisAlignment.center + : CrossAxisAlignment.stretch, + children: [ + Text(widget.items[r][e].label), + const SizedBox( + height: 8, + ), + ], + ), + 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, + ) + : 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>)[key]! + MapEntry>)[e2.key]! .key, - false); + 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(); - }); + 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(); + } }, - )); - }) ?? - [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() { + 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.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(); + as Map>?; + temp ??= {}; + if (temp[label] == null) { + 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(); } }); - } 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 ??= {}; - if (temp[label] == null) { - 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'), - )), - ], - ); + }); + }, + icon: const Icon(Icons.add), + visualDensity: VisualDensity.compact, + tooltip: tr('add'), + )), + ], + ) + ]); } } } diff --git a/lib/components/generated_form_modal.dart b/lib/components/generated_form_modal.dart index 498b20c..b961a9c 100644 --- a/lib/components/generated_form_modal.dart +++ b/lib/components/generated_form_modal.dart @@ -9,12 +9,14 @@ class GeneratedFormModal extends StatefulWidget { required this.title, required this.items, this.initValid = false, - this.message = ''}); + this.message = '', + this.additionalWidgets = const []}); final String title; final String message; final List> items; final bool initValid; + final List additionalWidgets; @override State createState() => _GeneratedFormModalState(); @@ -54,7 +56,8 @@ class _GeneratedFormModalState extends State { this.valid = valid; }); } - }) + }), + if (widget.additionalWidgets.isNotEmpty) ...widget.additionalWidgets ]), actions: [ TextButton( diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index 9b53dcc..d39f034 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -39,25 +39,19 @@ class _AddAppPageState extends State { changeUserInput(String input, bool valid, bool isBuilding) { userInput = input; - fn() { - var source = valid ? sourceProvider.getSource(userInput) : null; - if (pickedSource.runtimeType != source.runtimeType) { - pickedSource = source; - additionalSettings = source != null - ? getDefaultValuesFromFormItems( - source.combinedAppSpecificSettingFormItems) - : {}; - additionalSettingsValid = source != null - ? !sourceProvider.ifRequiredAppSpecificSettingsExist(source) - : true; - } - } - - if (isBuilding) { - fn(); - } else { + if (!isBuilding) { setState(() { - fn(); + var source = valid ? sourceProvider.getSource(userInput) : null; + if (pickedSource.runtimeType != source.runtimeType) { + pickedSource = source; + additionalSettings = source != null + ? getDefaultValuesFromFormItems( + source.combinedAppSpecificSettingFormItems) + : {}; + additionalSettingsValid = source != null + ? !sourceProvider.ifRequiredAppSpecificSettingsExist(source) + : true; + } }); } } @@ -243,7 +237,9 @@ class _AddAppPageState extends State { ] ], onValueChanges: (values, valid, isBuilding) { - if (values.isNotEmpty && valid) { + if (values.isNotEmpty && + valid && + !isBuilding) { setState(() { searchQuery = values['searchSomeSources']!.trim(); diff --git a/lib/pages/app.dart b/lib/pages/app.dart index aa069cd..12d654c 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -75,7 +75,7 @@ class _AppPageState extends State { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 100), + const SizedBox(height: 150), app?.installedInfo != null ? Row( mainAxisAlignment: MainAxisAlignment.center, @@ -168,7 +168,7 @@ class _AppPageState extends State { appsProvider.saveApps([app.app]); } }), - const SizedBox(height: 100) + const SizedBox(height: 150) ], )), ], diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index d1ff221..7623149 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -7,6 +7,7 @@ import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/main.dart'; import 'package:obtainium/pages/app.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'; @@ -22,7 +23,8 @@ class AppsPage extends StatefulWidget { } class AppsPageState extends State { - AppsFilter? filter; + AppsFilter filter = AppsFilter(); + final AppsFilter neutralFilter = AppsFilter(); var updatesOnlyFilter = AppsFilter(includeUptodate: false, includeNonInstalled: false); Set selectedApps = {}; @@ -53,8 +55,7 @@ class AppsPageState extends State { var appsProvider = context.watch(); var settingsProvider = context.watch(); var sortedApps = appsProvider.apps.values.toList(); - var currentFilterIsUpdatesOnly = - filter?.isIdenticalTo(updatesOnlyFilter) ?? false; + var currentFilterIsUpdatesOnly = filter.isIdenticalTo(updatesOnlyFilter); selectedApps = selectedApps .where((element) => sortedApps.map((e) => e.app).contains(element)) @@ -70,45 +71,42 @@ class AppsPageState extends State { }); } - if (filter != null) { - sortedApps = sortedApps.where((app) { - if (app.app.installedVersion == app.app.latestVersion && - !(filter!.includeUptodate)) { - return false; - } - if (app.app.installedVersion == null && - !(filter!.includeNonInstalled)) { - return false; - } - 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(); + sortedApps = sortedApps.where((app) { + if (app.app.installedVersion == app.app.latestVersion && + !(filter.includeUptodate)) { + return false; + } + if (app.app.installedVersion == null && !(filter.includeNonInstalled)) { + return false; + } + 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 authorTokens) { - if (!app.app.author.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; } } - if (filter!.categoryFilter.isNotEmpty && - filter!.categoryFilter != app.app.category) { - return false; + for (var t in authorTokens) { + if (!app.app.author.toLowerCase().contains(t.toLowerCase())) { + return false; + } } - return true; - }).toList(); - } + } + if (filter.categoryFilter.isNotEmpty && + !filter.categoryFilter.contains(app.app.category)) { + return false; + } + return true; + }).toList(); sortedApps.sort((a, b) { var nameA = a.installedInfo?.name ?? a.app.name; @@ -663,7 +661,7 @@ class AppsPageState extends State { onPressed: () { setState(() { if (currentFilterIsUpdatesOnly) { - filter = null; + filter = AppsFilter(); } else { filter = updatesOnlyFilter; } @@ -683,9 +681,11 @@ class AppsPageState extends State { ? const SizedBox() : TextButton.icon( label: Text( - filter == null ? tr('filter') : tr('filterActive'), + filter.isIdenticalTo(neutralFilter) + ? tr('filter') + : tr('filterActive'), style: TextStyle( - fontWeight: filter == null + fontWeight: filter.isIdenticalTo(neutralFilter) ? FontWeight.normal : FontWeight.bold), ), @@ -693,44 +693,48 @@ class AppsPageState extends State { showDialog?>( context: context, builder: (BuildContext ctx) { - var vals = filter == null - ? AppsFilter().toValuesMap() - : filter!.toValuesMap(); + var vals = filter.toFormValuesMap(); return GeneratedFormModal( - title: tr('filterApps'), - items: [ - [ - GeneratedFormTextField('appName', - label: tr('appName'), - required: false, - defaultValue: vals['appName']), - GeneratedFormTextField('author', - label: tr('author'), - required: false, - defaultValue: vals['author']) - ], - [ - GeneratedFormSwitch('upToDateApps', - label: tr('upToDateApps'), - defaultValue: vals['upToDateApps']) - ], - [ - GeneratedFormSwitch('nonInstalledApps', - label: tr('nonInstalledApps'), - defaultValue: vals['nonInstalledApps']) - ], - [ - settingsProvider.getCategoryFormItem( - initCategory: vals['category'] ?? '') - ] - ]); + initValid: true, + title: tr('filterApps'), + items: [ + [ + GeneratedFormTextField('appName', + label: tr('appName'), + required: false, + defaultValue: vals['appName']), + GeneratedFormTextField('author', + label: tr('author'), + required: false, + defaultValue: vals['author']) + ], + [ + GeneratedFormSwitch('upToDateApps', + label: tr('upToDateApps'), + defaultValue: vals['upToDateApps']) + ], + [ + GeneratedFormSwitch('nonInstalledApps', + label: tr('nonInstalledApps'), + defaultValue: vals['nonInstalledApps']) + ] + ], + additionalWidgets: [ + const SizedBox( + height: 16, + ), + CategoryEditorSelector( + preselected: filter.categoryFilter, + onSelected: (categories) { + filter.categoryFilter = categories.toSet(); + }, + ) + ], + ); }).then((values) { if (values != null) { setState(() { - filter = AppsFilter.fromValuesMap(values); - if (AppsFilter().isIdenticalTo(filter!)) { - filter = null; - } + filter.setFormValuesFromMap(values); }); } }); @@ -748,31 +752,29 @@ class AppsFilter { late String authorFilter; late bool includeUptodate; late bool includeNonInstalled; - late String categoryFilter; + late Set categoryFilter; AppsFilter( {this.nameFilter = '', this.authorFilter = '', this.includeUptodate = true, this.includeNonInstalled = true, - this.categoryFilter = ''}); + this.categoryFilter = const {}}); - Map toValuesMap() { + Map toFormValuesMap() { return { 'appName': nameFilter, 'author': authorFilter, 'upToDateApps': includeUptodate, - 'nonInstalledApps': includeNonInstalled, - 'category': categoryFilter + 'nonInstalledApps': includeNonInstalled }; } - AppsFilter.fromValuesMap(Map values) { + setFormValuesFromMap(Map values) { nameFilter = values['appName']!; authorFilter = values['author']!; includeUptodate = values['upToDateApps']; includeNonInstalled = values['nonInstalledApps']; - categoryFilter = values['category']!; } bool isIdenticalTo(AppsFilter other) => @@ -780,5 +782,7 @@ class AppsFilter { nameFilter.trim() == other.nameFilter.trim() && includeUptodate == other.includeUptodate && includeNonInstalled == other.includeNonInstalled && - categoryFilter.trim() == other.categoryFilter.trim(); + categoryFilter.length == other.categoryFilter.length && + categoryFilter.union(other.categoryFilter).length == + categoryFilter.length; } diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index ecf1d5b..a198016 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -185,7 +185,7 @@ class _SettingsPageState extends State { return [e]; }).toList(), onValueChanges: (values, valid, isBuilding) { - if (valid) { + if (valid && !isBuilding) { values.forEach((key, value) { settingsProvider.setSettingString(key, value); }); @@ -286,7 +286,9 @@ class _SettingsPageState extends State { color: Theme.of(context).colorScheme.primary), ), height16, - const CategoryEditorSelector() + const CategoryEditorSelector( + showLabelWhenNotEmpty: false, + ) ], ))), SliverToBoxAdapter( @@ -407,12 +409,14 @@ class CategoryEditorSelector extends StatefulWidget { final bool singleSelect; final Set preselected; final WrapAlignment alignment; + final bool showLabelWhenNotEmpty; const CategoryEditorSelector( {super.key, this.onSelected, this.singleSelect = false, this.preselected = const {}, - this.alignment = WrapAlignment.start}); + this.alignment = WrapAlignment.start, + this.showLabelWhenNotEmpty = true}); @override State createState() => _CategoryEditorSelectorState(); @@ -439,7 +443,8 @@ class _CategoryEditorSelectorState extends State { deleteConfirmationMessage: MapEntry( tr('deleteCategoriesQuestion'), tr('categoryDeleteWarning')), - singleSelect: widget.singleSelect) + singleSelect: widget.singleSelect, + showLabelWhenNotEmpty: widget.showLabelWhenNotEmpty) ] ], onValueChanges: ((values, valid, isBuilding) {