diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 30c4c19ed..63381cbca 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -97,6 +97,7 @@ class MessageLookup extends MessageLookupByLibrary { "infoPasswordResetEmailSent" : MessageLookupByLibrary.simpleMessage("Please check your inbox for the password reset e-mail."), "infoRelevance" : MessageLookupByLibrary.simpleMessage("Try to choose the most restrictive category."), "infoRelevanceExample" : MessageLookupByLibrary.simpleMessage("For instance, if something is only relevant for \"314CB\" and \"315CB\", don\'t just set \"CB\"."), + "infoRelevanceNothingSelected" : MessageLookupByLibrary.simpleMessage("If this is relevant for everyone, don\'t select anything ."), "labelCategory" : MessageLookupByLibrary.simpleMessage("Category"), "labelClass" : MessageLookupByLibrary.simpleMessage("Class"), "labelColor" : MessageLookupByLibrary.simpleMessage("Color"), diff --git a/lib/generated/intl/messages_ro.dart b/lib/generated/intl/messages_ro.dart index 8896ca6d6..887946233 100644 --- a/lib/generated/intl/messages_ro.dart +++ b/lib/generated/intl/messages_ro.dart @@ -97,6 +97,7 @@ class MessageLookup extends MessageLookupByLibrary { "infoPasswordResetEmailSent" : MessageLookupByLibrary.simpleMessage("Please check your inbox for the password reset e-mail."), "infoRelevance" : MessageLookupByLibrary.simpleMessage("Încercați să selectați cea mai restrictivă categorie."), "infoRelevanceExample" : MessageLookupByLibrary.simpleMessage("De exemplu, dacă ceva este relevant doar pentru \"314CB\" și \"315CB\", nu setați direct \"CB\"."), + "infoRelevanceNothingSelected" : MessageLookupByLibrary.simpleMessage("Nu selectați nimic dacă este relevant pentru toată lumea."), "labelCategory" : MessageLookupByLibrary.simpleMessage("Categorie"), "labelClass" : MessageLookupByLibrary.simpleMessage("Materie"), "labelColor" : MessageLookupByLibrary.simpleMessage("Culoare"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 916a4b83f..1749a82d1 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -1,6 +1,7 @@ // GENERATED CODE - DO NOT MODIFY BY HAND import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; + import 'intl/messages_all.dart'; // ************************************************************************** @@ -1773,6 +1774,16 @@ class S { ); } + /// `If this is relevant for everyone, don't select anything .` + String get infoRelevanceNothingSelected { + return Intl.message( + 'If this is relevant for everyone, don\'t select anything .', + name: 'infoRelevanceNothingSelected', + desc: '', + args: [], + ); + } + /// `For instance, if something is only relevant for "314CB" and "315CB", don't just set "CB".` String get infoRelevanceExample { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index d3434508b..2aa383815 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -188,6 +188,7 @@ "infoPasswordResetEmailSent": "Please check your inbox for the password reset e-mail.", "infoRelevance": "Try to choose the most restrictive category.", + "infoRelevanceNothingSelected": "If this is relevant for everyone, don't select anything .", "infoRelevanceExample": "For instance, if something is only relevant for \"314CB\" and \"315CB\", don't just set \"CB\".", "infoPassword": "It must contain lower and uppercase letters, one number and one special character, and have a minimum length of 8.", "infoAppIsOpenSource": "ACS UPB Mobile is open source.", diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index d1ba2ef28..421e90b6c 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -188,6 +188,7 @@ "infoPasswordResetEmailSent": "Please check your inbox for the password reset e-mail.", "infoRelevance": "Încercați să selectați cea mai restrictivă categorie.", + "infoRelevanceNothingSelected": "Nu selectați nimic dacă este relevant pentru toată lumea.", "infoRelevanceExample": "De exemplu, dacă ceva este relevant doar pentru \"314CB\" și \"315CB\", nu setați direct \"CB\".", "infoPassword": "Aceasta trebuie să conțină majuscule, minuscule și cel puțin un număr sau un simbol, având minimum 8 caractere.", "infoAppIsOpenSource": "ACS UPB Mobile este open source.", diff --git a/lib/main.dart b/lib/main.dart index 3b4954a8f..50beae596 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,6 @@ import 'package:acs_upb_mobile/navigation/bottom_navigation_bar.dart'; import 'package:acs_upb_mobile/navigation/routes.dart'; import 'package:acs_upb_mobile/pages/classes/service/class_provider.dart'; import 'package:acs_upb_mobile/pages/filter/service/filter_provider.dart'; -import 'package:acs_upb_mobile/pages/filter/view/filter_page.dart'; import 'package:acs_upb_mobile/pages/people/service/person_provider.dart'; import 'package:acs_upb_mobile/pages/portal/service/website_provider.dart'; import 'package:acs_upb_mobile/pages/settings/settings_page.dart'; @@ -68,7 +67,6 @@ class _MyAppState extends State { Routes.root: (_) => AppLoadingScreen(), Routes.home: (_) => AppBottomNavigationBar(), Routes.settings: (_) => SettingsPage(), - Routes.filter: (_) => FilterPage(), Routes.login: (_) => LoginView(), Routes.signUp: (_) => SignUpView(), }, diff --git a/lib/pages/filter/model/filter.dart b/lib/pages/filter/model/filter.dart index 9a4e7ef50..feabd5102 100644 --- a/lib/pages/filter/model/filter.dart +++ b/lib/pages/filter/model/filter.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/cupertino.dart'; /// Filter represented in the form of a tree with named levels /// @@ -27,21 +27,16 @@ class Filter { /// Name of each level of the tree. /// /// **Note:** There should be at least as many names as there are levels in the tree. - List> localizedLevelNames; + final List> localizedLevelNames; - Filter({this.root, this.localizedLevelNames, void Function() listener}) { + Filter({this.root, this.localizedLevelNames}) { this.root.value = true; // root value is true by default - _addListener(listener ?? () {}, this.root); } - static _addListener(void Function() listener, FilterNode node) { - node._valueNotifier.addListener(listener); - if (node.children != null) { - for (var child in node.children) { - _addListener(listener, child); - } - } - } + Filter clone() => Filter( + root: this.root.clone(), + localizedLevelNames: this.localizedLevelNames, + ); void _relevantNodesHelper(List list, FilterNode node) { if (node.value) { @@ -167,4 +162,12 @@ class FilterNode { get value => _valueNotifier.value; set value(bool value) => _valueNotifier.value = value; + + set listener(Function() listener) => _valueNotifier.addListener(listener); + + FilterNode clone() => FilterNode( + name: name, + value: value, + children: children?.map((c) => c.clone())?.toList(), + ); } diff --git a/lib/pages/filter/service/filter_provider.dart b/lib/pages/filter/service/filter_provider.dart index 2c187acc0..07fd8cb0f 100644 --- a/lib/pages/filter/service/filter_provider.dart +++ b/lib/pages/filter/service/filter_provider.dart @@ -29,15 +29,16 @@ class FilterProvider with ChangeNotifier { bool _enabled; List _relevantNodes; + final List defaultRelevance; FilterProvider( {this.global = false, bool filterEnabled, this.defaultDegree, - List defaultRelevance}) - : _enabled = filterEnabled ?? PrefService.get('relevance_filter') ?? true, - _relevantNodes = defaultRelevance { - if (defaultRelevance != null) { + this.defaultRelevance}) + : _enabled = + filterEnabled ?? PrefService.get('relevance_filter') ?? true { + if (defaultRelevance != null && !defaultRelevance.contains('All')) { if (this.defaultDegree == null) { throw ArgumentError( 'If the relevance is not null, the degree cannot be null.'); @@ -76,13 +77,22 @@ class FilterProvider with ChangeNotifier { notifyListeners(); } + void updateFilter(Filter filter) { + _relevanceFilter = filter; + if (global) { + PrefService.setStringList( + 'relevant_nodes', _relevanceFilter.relevantNodes); + } + notifyListeners(); + } + bool get filterEnabled => _enabled; - Filter get cachedFilter => _relevanceFilter; + Filter get cachedFilter => _relevanceFilter.clone(); Future fetchFilter(BuildContext context) async { if (_relevanceFilter != null) { - return _relevanceFilter; + return cachedFilter; } try { @@ -106,19 +116,16 @@ class FilterProvider with ChangeNotifier { Map root = data['root']; _relevanceFilter = Filter( - localizedLevelNames: levelNames, - root: FilterNodeExtension.fromMap(root, 'All'), - listener: () { - if (global) { - PrefService.setStringList( - 'relevant_nodes', _relevanceFilter.relevantNodes); - } - notifyListeners(); - }); - - if (_relevantNodes != null && defaultDegree != null) { + localizedLevelNames: levelNames, + root: FilterNodeExtension.fromMap(root, 'All'), + ); + + if (_relevantNodes == null && defaultRelevance != null) { + _relevantNodes = defaultRelevance; _relevantNodes.forEach((node) => _relevanceFilter.setRelevantUpToRoot(node, defaultDegree)); + } else if (_relevantNodes != null) { + _relevanceFilter.setRelevantNodes(_relevantNodes); } else { // No previous setting or defaults => set the user's group AuthProvider authProvider = @@ -138,7 +145,7 @@ class FilterProvider with ChangeNotifier { } } - return _relevanceFilter; + return cachedFilter; } catch (e, stackTrace) { print(e); print(stackTrace); diff --git a/lib/pages/filter/view/filter_page.dart b/lib/pages/filter/view/filter_page.dart index 60ba1fd70..237e876f7 100644 --- a/lib/pages/filter/view/filter_page.dart +++ b/lib/pages/filter/view/filter_page.dart @@ -40,48 +40,29 @@ class FilterPage extends StatefulWidget { } class FilterPageState extends State { - Future filterFuture; - - void _onSelected(bool selection, FilterNode node) => setState(() { - node.value = selection; - if (node.children != null) { - for (var child in node.children) { - // Deselect all children - _onSelected(false, child); - } - } - }); + Filter filter; + Map nodeControllers = {}; + + void _onSelected(bool selection, FilterNode node) { + if (selection != node.value) node.value = selection; + if (node.children != null) { + for (var child in node.children) { + // Deselect all children + _onSelected(false, child); + } + } + } void _onSelectedExclusive( bool selection, FilterNode node, List nodesOnLevel) { - _onSelected(selection, node); - // Only one node on level can be selected if (selection) { - for (var otherNode in nodesOnLevel) { - if (otherNode != node) { - _onSelected(false, otherNode); - } + for (var otherNode in nodesOnLevel.where((n) => n != node)) { + _onSelected(false, otherNode); } - - // For some reason, it doesn't deselect the other nodes unless the entire - // page is reloaded (curious, since `setState` should technically work). - // As a workaround, re-push the same page, but without an animation so it - // looks seamless. - // TODO: Find a way to fix this properly, since it's still buggy. - Navigator.pushReplacement( - context, - PageRouteBuilder( - pageBuilder: (context, animation1, animation2) => FilterPage( - title: widget.title, - info: widget.info, - hint: widget.hint, - buttonText: widget.buttonText, - onSubmit: widget.onSubmit, - ), - ), - ); } + + _onSelected(selection, node); } void _buildTree( @@ -94,32 +75,44 @@ class FilterPageState extends State { // Add list of options List listItems = [SizedBox(width: 10)]; - optionsByLevel[level].add( - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Container( - height: 40, - child: ListView( - scrollDirection: Axis.horizontal, - children: listItems, - ), - ), - ), - ); for (var child in node.children) { // Add option + nodeControllers.putIfAbsent(child, () => SelectableController()); listItems.add(Selectable( label: child.name, initiallySelected: child.value, + controller: nodeControllers[child], onSelected: (selection) => level != 0 ? _onSelected(selection, child) : _onSelectedExclusive(selection, child, node.children), )); + child.listener = () { + if (child.value) + nodeControllers[child].select(); + else + nodeControllers[child].deselect(); + setState(() {}); + }; // Add padding listItems.add(SizedBox(width: 10)); + } + optionsByLevel[level].add( + Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: Container( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + children: listItems, + ), + ), + ), + ); + + for (var child in node.children) { // Display children if selected if (child.value == true) { _buildTree( @@ -132,37 +125,27 @@ class FilterPageState extends State { Widget build(BuildContext context) { var filterProvider = Provider.of(context); - List widgets = [SizedBox(height: 10.0)]; - - // Only fetch the filter once. - // This is a tradeoff, since it makes the UI a bit buggy in some cases (for - // instance if a row shows up before a row that has an option selected, it - // will have that option selected as well). However, it avoids the page - // being rebuilt completely every single time something is pressed (which - // looks really bad and scrolls all the rows back to the beginning). - if (filterFuture == null) { - filterFuture = filterProvider.fetchFilter(context); - } - return AppScaffold( title: widget.title ?? S.of(context).navigationFilter, actions: [ AppScaffoldAction( text: widget.buttonText ?? S.of(context).buttonApply, onPressed: () { + filterProvider.enableFilter(); + filterProvider.updateFilter(filter); if (widget.onSubmit != null) { widget.onSubmit(); } - filterProvider.enableFilter(); Navigator.of(context).pop(); }, ) ], - body: FutureBuilder( - future: filterFuture, - builder: (BuildContext context, AsyncSnapshot snap) { - if (snap.connectionState == ConnectionState.done && snap.hasData) { - Filter filter = snap.data; + body: FutureBuilder( + future: Provider.of(context).fetchFilter(context), + builder: (context, snapshot) { + if (snapshot.hasData) { + filter ??= snapshot.data; + List widgets = [SizedBox(height: 10.0)]; Map> optionsByLevel = {}; _buildTree(node: filter.root, optionsByLevel: optionsByLevel); @@ -183,38 +166,34 @@ class FilterPageState extends State { // Level options widgets.addAll(optionsByLevel[i]); } - } else if (snap.hasError) { - print(snap.error); - // TODO: Show error toast - return Container(); - } else if (snap.connectionState != ConnectionState.done) { - return Center(child: CircularProgressIndicator()); - } - return ListView( - children: [ - if (widget.info != null) - Padding( - padding: const EdgeInsets.only( - left: 10.0, right: 10.0, top: 10.0), - child: IconText( - icon: Icons.info, - text: widget.info, - style: Theme.of(context).textTheme.bodyText1, - ), - ), - if (widget.hint != null) - Padding( - padding: const EdgeInsets.only( - left: 10.0, right: 10.0, top: 5.0), - child: Text( - widget.hint, - style: - TextStyle(color: Theme.of(context).hintColor), + return ListView( + children: [ + if (widget.info != null) + Padding( + padding: const EdgeInsets.only( + left: 10.0, right: 10.0, top: 10.0), + child: IconText( + icon: Icons.info, + text: widget.info, + style: Theme.of(context).textTheme.bodyText1, + ), ), - ) - ] + - widgets); + if (widget.hint != null) + Padding( + padding: const EdgeInsets.only( + left: 10.0, right: 10.0, top: 5.0), + child: Text( + widget.hint, + style: + TextStyle(color: Theme.of(context).hintColor), + ), + ) + ] + + widgets); + } else { + return Center(child: CircularProgressIndicator()); + } }), ); } diff --git a/lib/pages/filter/view/relevance_picker.dart b/lib/pages/filter/view/relevance_picker.dart index 8b99efd84..86d7f4394 100644 --- a/lib/pages/filter/view/relevance_picker.dart +++ b/lib/pages/filter/view/relevance_picker.dart @@ -105,7 +105,9 @@ class _RelevancePickerState extends State { child: FilterPage( title: S.of(context).labelRelevance, buttonText: S.of(context).buttonSet, - info: S.of(context).infoRelevance, + info: S.of(context).infoRelevanceNothingSelected + + ' ' + + S.of(context).infoRelevance, hint: S.of(context).infoRelevanceExample, onSubmit: () async { // Deselect all options diff --git a/lib/pages/portal/model/website.dart b/lib/pages/portal/model/website.dart index 1c4e2c52f..8b2f858d0 100644 --- a/lib/pages/portal/model/website.dart +++ b/lib/pages/portal/model/website.dart @@ -59,7 +59,7 @@ class Website { this.label = toString(label).isEmpty ? labelFromLink(link) : label, this.link = link, this.infoByLocale = infoByLocale ?? {} { - if (this.relevance != null) { + if (this.relevance != null && !this.relevance.contains('All')) { if (this.degree == null) { throw ArgumentError( 'If the relevance is not null, the degree cannot be null.'); diff --git a/lib/pages/portal/view/portal_page.dart b/lib/pages/portal/view/portal_page.dart index c14fb5baf..7edd0d761 100644 --- a/lib/pages/portal/view/portal_page.dart +++ b/lib/pages/portal/view/portal_page.dart @@ -4,9 +4,9 @@ import 'dart:math'; import 'package:acs_upb_mobile/authentication/model/user.dart'; import 'package:acs_upb_mobile/authentication/service/auth_provider.dart'; import 'package:acs_upb_mobile/generated/l10n.dart'; -import 'package:acs_upb_mobile/navigation/routes.dart'; import 'package:acs_upb_mobile/pages/filter/model/filter.dart'; import 'package:acs_upb_mobile/pages/filter/service/filter_provider.dart'; +import 'package:acs_upb_mobile/pages/filter/view/filter_page.dart'; import 'package:acs_upb_mobile/pages/portal/model/website.dart'; import 'package:acs_upb_mobile/pages/portal/service/website_provider.dart'; import 'package:acs_upb_mobile/pages/portal/view/website_view.dart'; @@ -33,6 +33,7 @@ class _PortalPageState extends State with AutomaticKeepAliveClientMixin { Filter filterCache; List websites = []; + FilterProvider filterProvider; // Only show user-added websites bool userOnly = false; @@ -64,7 +65,7 @@ class _PortalPageState extends State updating = true; } - FilterProvider filterProvider = + FilterProvider filterProvider = this.filterProvider ?? Provider.of(context, listen: false); filterCache = await filterProvider.fetchFilter(context); @@ -103,7 +104,7 @@ class _PortalPageState extends State builder: (_) => ChangeNotifierProvider( create: (_) => FilterProvider( defaultDegree: website.degree, - defaultRelevance: website.relevance), + defaultRelevance: website.relevance ?? ['All']), child: WebsiteView( website: website, updateExisting: true, @@ -212,10 +213,8 @@ class _PortalPageState extends State @override Widget build(BuildContext context) { - super.build(context); - WebsiteProvider websiteProvider = Provider.of(context); - FilterProvider filterProvider = Provider.of(context); + filterProvider = Provider.of(context); AuthProvider authProvider = Provider.of(context); CircularProgressIndicator progressIndicator = CircularProgressIndicator(); @@ -254,9 +253,15 @@ class _PortalPageState extends State tooltip: S.of(context).navigationFilter, items: { S.of(context).filterMenuRelevance: () { - _updateFilter(); userOnly = false; - Navigator.pushNamed(context, Routes.filter); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => FilterPage( + onSubmit: _updateFilter, + ), + ), + ); }, S.of(context).filterMenuShowMine: () { if (authProvider.isAuthenticatedFromCache && diff --git a/lib/widgets/selectable.dart b/lib/widgets/selectable.dart index 16425d33c..1552dd48d 100644 --- a/lib/widgets/selectable.dart +++ b/lib/widgets/selectable.dart @@ -3,14 +3,14 @@ import 'package:flutter/material.dart'; class SelectableController { _SelectableState _selectableState; - bool get isSelected => _selectableState?.isSelected; + bool get isSelected => _selectableState?._isSelected; void select() { - _selectableState.isSelected = true; + if (!isSelected) _selectableState.isSelected = true; } void deselect() { - _selectableState.isSelected = false; + if (isSelected) _selectableState.isSelected = false; } } @@ -33,12 +33,17 @@ class Selectable extends StatefulWidget { } class _SelectableState extends State { - bool isSelected; + bool _isSelected; + + set isSelected(bool newValue) { + _isSelected = newValue; + setState(() {}); + } @override void initState() { super.initState(); - isSelected = widget.initiallySelected; + _isSelected = widget.initiallySelected; } @override @@ -47,7 +52,7 @@ class _SelectableState extends State { return Container( decoration: BoxDecoration( - color: isSelected + color: _isSelected ? (widget.disabled ? Theme.of(context).disabledColor : Theme.of(context).accentColor) @@ -67,9 +72,9 @@ class _SelectableState extends State { setState( () { if (!widget.disabled) { - isSelected = !isSelected; + _isSelected = !_isSelected; } - widget.onSelected(isSelected); + widget.onSelected(_isSelected); }, ); }, @@ -84,7 +89,7 @@ class _SelectableState extends State { fontWeight: FontWeight.w600, fontSize: 12, letterSpacing: 0.27, - color: isSelected + color: _isSelected ? Colors.white : widget.disabled ? Theme.of(context).disabledColor diff --git a/pubspec.yaml b/pubspec.yaml index aa36f287f..1860439d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: A mobile application for students at ACS UPB. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.6.0+1 +version: 0.6.1+1 environment: sdk: ">=2.6.0 <3.0.0"