diff --git a/lib/authentication/model/user.dart b/lib/authentication/model/user.dart index 01a1a0756..c379d1e5d 100644 --- a/lib/authentication/model/user.dart +++ b/lib/authentication/model/user.dart @@ -19,7 +19,7 @@ class User { int permissionLevel; - bool get canAddPublicWebsite => permissionLevel >= 3; + bool get canAddPublicInfo => permissionLevel >= 3; - bool get canEditPublicWebsite => permissionLevel >= 3; + bool get canEditPublicInfo => permissionLevel >= 3; } diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index accafad7a..a594388a2 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -78,9 +78,11 @@ class MessageLookup extends MessageLookupByLibrary { "buttonSend" : MessageLookupByLibrary.simpleMessage("Send"), "buttonSet" : MessageLookupByLibrary.simpleMessage("Set"), "errorAccountDisabled" : MessageLookupByLibrary.simpleMessage("The account has been disabled."), + "errorClassCannotBeEmpty" : MessageLookupByLibrary.simpleMessage("Class cannot be empty."), "errorCouldNotLaunchURL" : m1, "errorEmailInUse" : MessageLookupByLibrary.simpleMessage("There is already an account associated with this e-mail address"), "errorEmailNotFound" : MessageLookupByLibrary.simpleMessage("An account associated with that e-mail could not be found. Please sign up instead."), + "errorEventTypeCannotBeEmpty" : MessageLookupByLibrary.simpleMessage("Event type cannot be empty."), "errorIncorrectPassword" : MessageLookupByLibrary.simpleMessage("The password you entered is incorrect."), "errorInvalidEmail" : MessageLookupByLibrary.simpleMessage("You need to provide a valid e-mail address."), "errorMissingFirstName" : MessageLookupByLibrary.simpleMessage("Please provide your first name(s)."), @@ -123,10 +125,12 @@ class MessageLookup extends MessageLookupByLibrary { "labelConfirmNewPassword" : MessageLookupByLibrary.simpleMessage("Confirm new password"), "labelConfirmPassword" : MessageLookupByLibrary.simpleMessage("Confirm password"), "labelCustom" : MessageLookupByLibrary.simpleMessage("Custom"), + "labelDay" : MessageLookupByLibrary.simpleMessage("Day"), "labelDescription" : MessageLookupByLibrary.simpleMessage("Description"), "labelEmail" : MessageLookupByLibrary.simpleMessage("Email"), "labelEnd" : MessageLookupByLibrary.simpleMessage("End"), "labelEvaluation" : MessageLookupByLibrary.simpleMessage("Evaluation"), + "labelEven" : MessageLookupByLibrary.simpleMessage("Even"), "labelFirstName" : MessageLookupByLibrary.simpleMessage("First name"), "labelLastName" : MessageLookupByLibrary.simpleMessage("Last name"), "labelLastUpdated" : MessageLookupByLibrary.simpleMessage("Last updated"), @@ -134,6 +138,7 @@ class MessageLookup extends MessageLookupByLibrary { "labelLocation" : MessageLookupByLibrary.simpleMessage("Location"), "labelName" : MessageLookupByLibrary.simpleMessage("Name"), "labelNewPassword" : MessageLookupByLibrary.simpleMessage("New password"), + "labelOdd" : MessageLookupByLibrary.simpleMessage("Odd"), "labelOldPassword" : MessageLookupByLibrary.simpleMessage("Old password"), "labelPassword" : MessageLookupByLibrary.simpleMessage("Password"), "labelPermissionsConsent" : MessageLookupByLibrary.simpleMessage("consent for editing rights"), @@ -146,7 +151,9 @@ class MessageLookup extends MessageLookupByLibrary { "labelStart" : MessageLookupByLibrary.simpleMessage("Start"), "labelTeam" : m3, "labelType" : MessageLookupByLibrary.simpleMessage("Type"), + "labelUniversityYear" : MessageLookupByLibrary.simpleMessage("University year"), "labelUnknown" : MessageLookupByLibrary.simpleMessage("Unknown"), + "labelWeek" : MessageLookupByLibrary.simpleMessage("Week"), "labelYear" : MessageLookupByLibrary.simpleMessage("Year"), "messageAccountCreated" : MessageLookupByLibrary.simpleMessage("Account created successfully."), "messageAccountDeleted" : MessageLookupByLibrary.simpleMessage("Account deleted successfully."), @@ -166,6 +173,9 @@ class MessageLookup extends MessageLookupByLibrary { "messageEditProfileSuccess" : MessageLookupByLibrary.simpleMessage("Profile updated successfully."), "messageEmailNotVerified" : MessageLookupByLibrary.simpleMessage("Account is not verified."), "messageEmailNotVerifiedToPerformAction" : MessageLookupByLibrary.simpleMessage("Your account needs to be verified to perform this action."), + "messageEventAdded" : MessageLookupByLibrary.simpleMessage("Event added successfully."), + "messageEventDeleted" : MessageLookupByLibrary.simpleMessage("Event deleted successfully."), + "messageEventEdited" : MessageLookupByLibrary.simpleMessage("Event modified successfully."), "messageGetStartedByPressing" : MessageLookupByLibrary.simpleMessage("Get started by pressing the"), "messageIAgreeToThe" : MessageLookupByLibrary.simpleMessage("I agree to the "), "messageNewUser" : MessageLookupByLibrary.simpleMessage("New user?"), @@ -263,6 +273,7 @@ class MessageLookup extends MessageLookupByLibrary { "warningTryAgainLater" : MessageLookupByLibrary.simpleMessage("Please try again later."), "warningUseProvider" : m9, "warningWebsiteNameExists" : MessageLookupByLibrary.simpleMessage("A website with the same name already exists."), + "warningYouNeedToSelectAtLeastOne" : MessageLookupByLibrary.simpleMessage("You need to select at least one option."), "websiteCategoryAdministrative" : MessageLookupByLibrary.simpleMessage("Administrative"), "websiteCategoryAssociations" : MessageLookupByLibrary.simpleMessage("Associations"), "websiteCategoryLearning" : MessageLookupByLibrary.simpleMessage("Learning"), diff --git a/lib/generated/intl/messages_ro.dart b/lib/generated/intl/messages_ro.dart index 08504a69d..8a4325288 100644 --- a/lib/generated/intl/messages_ro.dart +++ b/lib/generated/intl/messages_ro.dart @@ -78,9 +78,11 @@ class MessageLookup extends MessageLookupByLibrary { "buttonSend" : MessageLookupByLibrary.simpleMessage("Trimitere"), "buttonSet" : MessageLookupByLibrary.simpleMessage("Setează"), "errorAccountDisabled" : MessageLookupByLibrary.simpleMessage("Contul a fost dezactivat."), + "errorClassCannotBeEmpty" : MessageLookupByLibrary.simpleMessage("Materia trebuie precizată."), "errorCouldNotLaunchURL" : m1, "errorEmailInUse" : MessageLookupByLibrary.simpleMessage("Există deja un cont asociat acestui e-mail."), "errorEmailNotFound" : MessageLookupByLibrary.simpleMessage("Nu am putut găsi un cont asociat cu adresa de mail. Vă rugăm să vă înregistrați."), + "errorEventTypeCannotBeEmpty" : MessageLookupByLibrary.simpleMessage("Tipul de eveniment trebuie precizat."), "errorIncorrectPassword" : MessageLookupByLibrary.simpleMessage("Parola introdusă nu este corectă."), "errorInvalidEmail" : MessageLookupByLibrary.simpleMessage("Trebuie să introduceți un e-mail valid."), "errorMissingFirstName" : MessageLookupByLibrary.simpleMessage("Introduceți prenumele."), @@ -123,10 +125,12 @@ class MessageLookup extends MessageLookupByLibrary { "labelConfirmNewPassword" : MessageLookupByLibrary.simpleMessage("Confirmare parolă nouă"), "labelConfirmPassword" : MessageLookupByLibrary.simpleMessage("Confirmare parolă"), "labelCustom" : MessageLookupByLibrary.simpleMessage("Alta"), + "labelDay" : MessageLookupByLibrary.simpleMessage("Zi"), "labelDescription" : MessageLookupByLibrary.simpleMessage("Descriere"), "labelEmail" : MessageLookupByLibrary.simpleMessage("Email"), "labelEnd" : MessageLookupByLibrary.simpleMessage("Sfârșit"), "labelEvaluation" : MessageLookupByLibrary.simpleMessage("Evaluare"), + "labelEven" : MessageLookupByLibrary.simpleMessage("Pară"), "labelFirstName" : MessageLookupByLibrary.simpleMessage("Prenume"), "labelLastName" : MessageLookupByLibrary.simpleMessage("Nume"), "labelLastUpdated" : MessageLookupByLibrary.simpleMessage("Ultima modificare"), @@ -134,6 +138,7 @@ class MessageLookup extends MessageLookupByLibrary { "labelLocation" : MessageLookupByLibrary.simpleMessage("Locație"), "labelName" : MessageLookupByLibrary.simpleMessage("Nume"), "labelNewPassword" : MessageLookupByLibrary.simpleMessage("Parolă nouă"), + "labelOdd" : MessageLookupByLibrary.simpleMessage("Impară"), "labelOldPassword" : MessageLookupByLibrary.simpleMessage("Parolă veche"), "labelPassword" : MessageLookupByLibrary.simpleMessage("Parolă"), "labelPermissionsConsent" : MessageLookupByLibrary.simpleMessage("consimțământul pentru drepturi de editare"), @@ -146,7 +151,9 @@ class MessageLookup extends MessageLookupByLibrary { "labelStart" : MessageLookupByLibrary.simpleMessage("Început"), "labelTeam" : m3, "labelType" : MessageLookupByLibrary.simpleMessage("Tip"), + "labelUniversityYear" : MessageLookupByLibrary.simpleMessage("An universitar"), "labelUnknown" : MessageLookupByLibrary.simpleMessage("Necunoscut"), + "labelWeek" : MessageLookupByLibrary.simpleMessage("Săptămână"), "labelYear" : MessageLookupByLibrary.simpleMessage("Anul"), "messageAccountCreated" : MessageLookupByLibrary.simpleMessage("Contul a fost creat cu succes."), "messageAccountDeleted" : MessageLookupByLibrary.simpleMessage("Contul a fost șters cu succes."), @@ -166,6 +173,9 @@ class MessageLookup extends MessageLookupByLibrary { "messageEditProfileSuccess" : MessageLookupByLibrary.simpleMessage("Profilul a fost actualizat cu succes."), "messageEmailNotVerified" : MessageLookupByLibrary.simpleMessage("Contul nu este verificat."), "messageEmailNotVerifiedToPerformAction" : MessageLookupByLibrary.simpleMessage("Contul trebuie să fie verificat pentru a realiza această acțiune."), + "messageEventAdded" : MessageLookupByLibrary.simpleMessage("Eveniment adăugat cu succes."), + "messageEventDeleted" : MessageLookupByLibrary.simpleMessage("Eveniment șters cu succes."), + "messageEventEdited" : MessageLookupByLibrary.simpleMessage("Eveniment modificat cu succes."), "messageGetStartedByPressing" : MessageLookupByLibrary.simpleMessage("Începeți prin a apăsa butonul"), "messageIAgreeToThe" : MessageLookupByLibrary.simpleMessage("Sunt de acord cu "), "messageNewUser" : MessageLookupByLibrary.simpleMessage("Utilizator nou?"), @@ -263,6 +273,7 @@ class MessageLookup extends MessageLookupByLibrary { "warningTryAgainLater" : MessageLookupByLibrary.simpleMessage("Încercați mai târziu."), "warningUseProvider" : m9, "warningWebsiteNameExists" : MessageLookupByLibrary.simpleMessage("Există deja un site cu același nume."), + "warningYouNeedToSelectAtLeastOne" : MessageLookupByLibrary.simpleMessage("Trebuie să selectați cel puțin o opțiune."), "websiteCategoryAdministrative" : MessageLookupByLibrary.simpleMessage("Administrativ"), "websiteCategoryAssociations" : MessageLookupByLibrary.simpleMessage("Asociații"), "websiteCategoryLearning" : MessageLookupByLibrary.simpleMessage("Cursuri"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 353df6b0c..afa6d4173 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -413,6 +413,56 @@ class S { ); } + /// `University year` + String get labelUniversityYear { + return Intl.message( + 'University year', + name: 'labelUniversityYear', + desc: '', + args: [], + ); + } + + /// `Week` + String get labelWeek { + return Intl.message( + 'Week', + name: 'labelWeek', + desc: '', + args: [], + ); + } + + /// `Even` + String get labelEven { + return Intl.message( + 'Even', + name: 'labelEven', + desc: '', + args: [], + ); + } + + /// `Odd` + String get labelOdd { + return Intl.message( + 'Odd', + name: 'labelOdd', + desc: '', + args: [], + ); + } + + /// `Day` + String get labelDay { + return Intl.message( + 'Day', + name: 'labelDay', + desc: '', + args: [], + ); + } + /// `Shortcuts` String get sectionShortcuts { return Intl.message( @@ -1033,6 +1083,26 @@ class S { ); } + /// `Event type cannot be empty.` + String get errorEventTypeCannotBeEmpty { + return Intl.message( + 'Event type cannot be empty.', + name: 'errorEventTypeCannotBeEmpty', + desc: '', + args: [], + ); + } + + /// `Class cannot be empty.` + String get errorClassCannotBeEmpty { + return Intl.message( + 'Class cannot be empty.', + name: 'errorClassCannotBeEmpty', + desc: '', + args: [], + ); + } + /// `Request already exists` String get warningRequestExists { return Intl.message( @@ -1303,6 +1373,16 @@ class S { ); } + /// `You need to select at least one option.` + String get warningYouNeedToSelectAtLeastOne { + return Intl.message( + 'You need to select at least one option.', + name: 'warningYouNeedToSelectAtLeastOne', + desc: '', + args: [], + ); + } + /// `Ask for permissions` String get navigationAskPermissions { return Intl.message( @@ -1973,6 +2053,36 @@ class S { ); } + /// `Event deleted successfully.` + String get messageEventDeleted { + return Intl.message( + 'Event deleted successfully.', + name: 'messageEventDeleted', + desc: '', + args: [], + ); + } + + /// `Event added successfully.` + String get messageEventAdded { + return Intl.message( + 'Event added successfully.', + name: 'messageEventAdded', + desc: '', + args: [], + ); + } + + /// `Event modified successfully.` + String get messageEventEdited { + return Intl.message( + 'Event modified successfully.', + name: 'messageEventEdited', + desc: '', + args: [], + ); + } + /// `Are you sure you want to delete "{shortcutName}"?` String messageDeleteShortcut(Object shortcutName) { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index ba086afeb..e9df94d32 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -38,6 +38,11 @@ "labelPersonalInformation": "Personal information", "labelPermissionsConsent": "consent for editing rights", "labelLastUpdated": "Last updated", + "labelUniversityYear": "University year", + "labelWeek": "Week", + "labelEven": "Even", + "labelOdd": "Odd", + "labelDay": "Day", "sectionShortcuts": "Shortcuts", "sectionEvents": "Events", @@ -105,6 +110,8 @@ "errorTooManyRequests": "There have been too many requests from this device.", "errorCouldNotLaunchURL": "Could not launch '{url}'.", "errorPermissionDenied": "You do not have permission to do that.", + "errorEventTypeCannotBeEmpty": "Event type cannot be empty.", + "errorClassCannotBeEmpty": "Class cannot be empty.", "warningRequestExists": "Request already exists", "warningInternetConnection": "Please make sure you are connected to the internet.", @@ -133,6 +140,7 @@ "warningRequestEmpty": "The request must not be empty", "warningOnlyNOptionsAtATime": "Only {n} options can be selected at a time.", "warningNoEvents": "No events to show", + "warningYouNeedToSelectAtLeastOne": "You need to select at least one option.", "navigationAskPermissions": "Ask for permissions", "navigationHome": "Home", @@ -208,6 +216,9 @@ "messageDeleteWebsite": "Are you sure you want to delete this website?", "messageWebsiteDeleted": "Website deleted successfully.", "messageDeleteEvent": "Are you sure you want to delete this event?", + "messageEventDeleted": "Event deleted successfully.", + "messageEventAdded": "Event added successfully.", + "messageEventEdited": "Event modified successfully.", "messageDeleteShortcut": "Are you sure you want to delete \"{shortcutName}\"?", "messageThisCouldAffectOtherStudents": "This could affect other students.", "messageShortcutDeleted": "Shortcut deleted successfully.", diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index a39402326..8450e150c 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -38,6 +38,11 @@ "labelPersonalInformation": "Informații personale", "labelPermissionsConsent": "consimțământul pentru drepturi de editare", "labelLastUpdated": "Ultima modificare", + "labelUniversityYear": "An universitar", + "labelWeek": "Săptămână", + "labelEven": "Pară", + "labelOdd": "Impară", + "labelDay": "Zi", "sectionShortcuts": "Scurtături", "sectionEvents": "Evenimente", @@ -105,6 +110,8 @@ "errorTooManyRequests": "Au fost trimise prea multe cereri de pe acest dispozitiv.", "errorCouldNotLaunchURL": "Nu s-a putut deschide '{url}'.", "errorPermissionDenied": "Nu aveți suficiente permisiuni.", + "errorEventTypeCannotBeEmpty": "Tipul de eveniment trebuie precizat.", + "errorClassCannotBeEmpty": "Materia trebuie precizată.", "warningRequestExists": "O cerere deja există", "warningInternetConnection": "Asigurați-vă că sunteți conectat la internet.", @@ -133,6 +140,7 @@ "warningAgreeTo": "Trebuie să fiți de acord cu ", "warningOnlyNOptionsAtATime": "Doar {n} opțiuni pot fi selectate la un moment dat.", "warningNoEvents": "Nu există evenimente de afișat", + "warningYouNeedToSelectAtLeastOne": "Trebuie să selectați cel puțin o opțiune.", "navigationAskPermissions": "Cere permisiuni", "navigationHome": "Acasă", @@ -208,6 +216,9 @@ "messageDeleteWebsite": "Sunteți sigur că doriți să ștergeți acest website?", "messageWebsiteDeleted": "Website-ul a fost șters cu succes.", "messageDeleteEvent": "Sunteți sigur că doriți să ștergeți acest eveniment?", + "messageEventDeleted": "Eveniment șters cu succes.", + "messageEventAdded": "Eveniment adăugat cu succes.", + "messageEventEdited": "Eveniment modificat cu succes.", "messageDeleteShortcut": "Sunteți sigur că doriți să ștergeți \"{shortcutName}\"?", "messageThisCouldAffectOtherStudents": "Alți studenți ar putea fi afectați.", "messageShortcutDeleted": "Scurtătura a fost ștearsă cu succes.", diff --git a/lib/pages/classes/model/class.dart b/lib/pages/classes/model/class.dart index f711431af..b241fc150 100644 --- a/lib/pages/classes/model/class.dart +++ b/lib/pages/classes/model/class.dart @@ -36,6 +36,17 @@ class ClassHeader { final String name; final String acronym; final String category; + + @override + int get hashCode => id.hashCode; + + @override + bool operator ==(Object other) { + if (other is ClassHeader) { + return other.id == id; + } + return false; + } } class Class { diff --git a/lib/pages/filter/service/filter_provider.dart b/lib/pages/filter/service/filter_provider.dart index 1f36360a6..a6f895978 100644 --- a/lib/pages/filter/service/filter_provider.dart +++ b/lib/pages/filter/service/filter_provider.dart @@ -126,13 +126,12 @@ class FilterProvider with ChangeNotifier { root: FilterNodeExtension.fromMap(root, 'All'), ); - if (_relevantNodes == null && defaultRelevance != null) { - _relevantNodes = defaultRelevance; + if (_relevantNodes != null) { + _relevantNodes ??= defaultRelevance; for (final node in _relevantNodes) { _relevanceFilter.setRelevantUpToRoot(node, defaultDegree); } - } else if (_relevantNodes != null) { - _relevanceFilter.setRelevantNodes(_relevantNodes); + _relevantNodes = _relevanceFilter.relevantNodes; } else { // No previous setting or defaults => set the user's group if (authProvider.isAuthenticatedFromCache) { diff --git a/lib/pages/filter/view/filter_page.dart b/lib/pages/filter/view/filter_page.dart index a25310f2b..05594c315 100644 --- a/lib/pages/filter/view/filter_page.dart +++ b/lib/pages/filter/view/filter_page.dart @@ -16,7 +16,8 @@ class FilterPage extends StatefulWidget { this.info, this.hint, this.buttonText, - this.onSubmit}) + this.onSubmit, + this.canBeForEveryone = true}) : super(key: key); static const String routeName = '/filter'; @@ -36,6 +37,9 @@ class FilterPage extends StatefulWidget { /// Callback after the user submits the page final void Function() onSubmit; + /// Whether the user has to select at least one node (no nodes mean that it is for everyone) + final bool canBeForEveryone; + @override State createState() => FilterPageState(); } @@ -153,13 +157,17 @@ class FilterPageState extends State { AppScaffoldAction( text: widget.buttonText ?? S.of(context).buttonApply, onPressed: () { - filterProvider - ..enableFilter() - ..updateFilter(filter); - if (widget.onSubmit != null) { - widget.onSubmit(); + if (filter.relevantNodes.length > 1 || widget.canBeForEveryone) { + filterProvider + ..enableFilter() + ..updateFilter(filter); + if (widget.onSubmit != null) { + widget.onSubmit(); + } + Navigator.of(context).pop(); + } else { + AppToast.show(S.of(context).warningYouNeedToSelectAtLeastOne); } - Navigator.of(context).pop(); }, ) ], diff --git a/lib/pages/filter/view/relevance_picker.dart b/lib/pages/filter/view/relevance_picker.dart index 21c381dd5..767d4d6d0 100644 --- a/lib/pages/filter/view/relevance_picker.dart +++ b/lib/pages/filter/view/relevance_picker.dart @@ -11,10 +11,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class RelevanceController { + RelevanceController({this.onChanged}); + _RelevancePickerState _state; + void Function() onChanged; - String get degree => - _state?._filterApplied == true ? _state?._filter?.baseNode : null; + String get degree => _state?._filter?.baseNode; bool get private => _state?._onlyMeController?.isSelected ?? @@ -23,7 +25,8 @@ class RelevanceController { bool get anyone => _state?._anyoneController?.isSelected ?? - _state?.widget != null && _state.widget.defaultRelevance == null; + _state?.widget != null && + _state.widget.filterProvider.defaultRelevance == null; List get customRelevance { final relevance = []; @@ -42,18 +45,23 @@ class RelevanceController { class RelevancePicker extends StatefulWidget { const RelevancePicker( {@required this.filterProvider, - this.defaultPrivate = true, - this.defaultRelevance, - this.controller}); + this.canBePrivate = true, + this.canBeForEveryone = true, + bool defaultPrivate, + this.controller}) + : defaultPrivate = (defaultPrivate ?? true) && canBePrivate; final FilterProvider filterProvider; + /// Whether 'Only me' is an option (this overrides [defaultPrivate]) + final bool canBePrivate; + + /// Whether 'Anyone' is an option + final bool canBeForEveryone; + /// Whether the 'Only me' option should be enabled by default final bool defaultPrivate; - /// This is only used if [defaultPrivate] is `false` - final List defaultRelevance; - final RelevanceController controller; @override @@ -74,12 +82,16 @@ class _RelevancePickerState extends State { Future _fetchUser() async { final authProvider = Provider.of(context, listen: false); _user = await authProvider.currentUser; - setState(() {}); + if (mounted) { + setState(() {}); + } } Future _fetchFilter() async { _filter = await widget.filterProvider.fetchFilter(context: context); - setState(() {}); + if (mounted) { + setState(() {}); + } } @override @@ -90,14 +102,14 @@ class _RelevancePickerState extends State { } Widget _customRelevanceButton() { - final buttonColor = _user?.canAddPublicWebsite ?? false + final buttonColor = _user?.canAddPublicInfo ?? false ? Theme.of(context).accentColor : Theme.of(context).hintColor; return IntrinsicWidth( child: GestureDetector( onTap: () { - if (_user?.canAddPublicWebsite ?? false) { + if (_user?.canAddPublicInfo ?? false) { Navigator.of(context) .push(MaterialPageRoute( builder: (_) => ChangeNotifierProvider.value( @@ -105,6 +117,7 @@ class _RelevancePickerState extends State { child: FilterPage( title: S.of(context).labelRelevance, buttonText: S.of(context).buttonSet, + canBeForEveryone: widget.canBeForEveryone, info: '${S.of(context).infoRelevanceNothingSelected} ${S.of(context).infoRelevance}', hint: S.of(context).infoRelevanceExample, @@ -148,10 +161,11 @@ class _RelevancePickerState extends State { } void _onCustomSelected(bool selected) => setState(() { - if (_user?.canAddPublicWebsite ?? false) { + if (_user?.canAddPublicInfo ?? false) { if (selected) { _onlyMeController.deselect(); _anyoneController.deselect(); + widget.controller?.onChanged(); } } else { AppToast.show(S.of(context).warningNoPermissionToAddPublicWebsite); @@ -162,54 +176,54 @@ class _RelevancePickerState extends State { final widgets = []; _customControllers = {}; - if (_filterApplied || widget.defaultPrivate) { - // Add strings from the filter options - for (final node in _filter?.relevantLocalizedLeaves(context) ?? []) { - if (node != 'All') { - // The "All" case (when nothing is selected in the filter) is handled - // separately using [_anyoneController] - final controller = SelectableController(); - _customControllers[node] = controller; - - widgets - ..add(const SizedBox(width: 8)) - ..add(Selectable( - label: node, - controller: controller, - initiallySelected: _filterApplied, - onSelected: (selected) => setState(() { - if (_user?.canAddPublicWebsite ?? false) { - if (selected) { - _onlyMeController.deselect(); - _anyoneController.deselect(); - } - } else { - AppToast.show( - S.of(context).warningNoPermissionToAddPublicWebsite); + // Add strings from the filter options + for (final node in _filter?.relevantLocalizedLeaves(context) ?? []) { + if (node != 'All') { + // The "All" case (when nothing is selected in the filter) is handled + // separately using [_anyoneController] + final controller = SelectableController(); + _customControllers[node] = controller; + + widgets + ..add(Selectable( + label: node, + controller: controller, + initiallySelected: _filterApplied || + (!widget.canBePrivate && !widget.canBeForEveryone), + onSelected: (selected) => setState(() { + if (_user?.canAddPublicInfo ?? false) { + if (selected) { + _onlyMeController.deselect(); + _anyoneController.deselect(); } - }), - disabled: !(_user?.canAddPublicWebsite ?? false), - )); - } + widget.controller?.onChanged(); + } else { + AppToast.show( + S.of(context).warningNoPermissionToAddPublicWebsite); + } + }), + disabled: !(_user?.canAddPublicInfo ?? false), + )) + ..add(const SizedBox(width: 8)); } - } else { - // Add the provided website relevance strings, if applicable - // These are selected by default - for (final node in widget.defaultRelevance ?? []) { - if (!_customControllers.containsKey(node)) { - final controller = SelectableController(); - _customControllers[node] = controller; - - widgets - ..add(const SizedBox(width: 8)) - ..add(Selectable( - label: node, - controller: controller, - initiallySelected: true, - onSelected: _onCustomSelected, - disabled: !(_user?.canAddPublicWebsite ?? false), - )); - } + } + + // Add the provided website relevance strings, if applicable + // These are selected by default + for (final node in widget.filterProvider.defaultRelevance ?? []) { + if (!_customControllers.containsKey(node)) { + final controller = SelectableController(); + _customControllers[node] = controller; + + widgets + ..add(const SizedBox(width: 8)) + ..add(Selectable( + label: node, + controller: controller, + initiallySelected: true, + onSelected: _onCustomSelected, + disabled: !(_user?.canAddPublicInfo ?? false), + )); } } @@ -259,54 +273,65 @@ class _RelevancePickerState extends State { child: ListView( scrollDirection: Axis.horizontal, children: [ - Selectable( - label: S.of(context).relevanceOnlyMe, - initiallySelected: - widget.defaultPrivate ?? true, - onSelected: (selected) => setState(() { - if (_user?.canAddPublicWebsite ?? false) { - if (selected) { - _anyoneController.deselect(); - for (final controller - in _customControllers.values) { - controller.deselect(); - } - } else { - _anyoneController.select(); - } - } else { - _onlyMeController.select(); - } - }), - controller: _onlyMeController, - ), - const SizedBox(width: 8), - Selectable( - label: S.of(context).relevanceAnyone, - initiallySelected: !widget.defaultPrivate && - widget.defaultRelevance == null, - onSelected: (selected) => setState(() { - if (_user?.canAddPublicWebsite ?? false) { - if (selected) { - // Deselect all controllers - _onlyMeController.deselect(); - for (final controller - in _customControllers.values) { - controller.deselect(); + if (widget.canBePrivate) + Row( + children: [ + Selectable( + label: S.of(context).relevanceOnlyMe, + initiallySelected: + widget.defaultPrivate ?? true, + onSelected: (selected) => setState(() { + if (_user?.canAddPublicInfo ?? + false) { + if (selected) { + _anyoneController.deselect(); + for (final controller + in _customControllers + .values) { + controller.deselect(); + } + } else { + _anyoneController.select(); + } + } else { + _onlyMeController.select(); + } + widget.controller?.onChanged(); + }), + controller: _onlyMeController, + ), + const SizedBox(width: 8), + ], + ), + if (widget.canBeForEveryone) + Selectable( + label: S.of(context).relevanceAnyone, + initiallySelected: !widget.defaultPrivate && + widget.filterProvider + .defaultRelevance == + null, + onSelected: (selected) => setState(() { + if (_user?.canAddPublicInfo ?? false) { + if (selected) { + // Deselect all controllers + _onlyMeController.deselect(); + for (final controller + in _customControllers.values) { + controller.deselect(); + } + } else { + _onlyMeController.select(); } } else { - _onlyMeController.select(); + AppToast.show(S + .of(context) + .warningNoPermissionToAddPublicWebsite); } - } else { - AppToast.show(S - .of(context) - .warningNoPermissionToAddPublicWebsite); - } - }), - controller: _anyoneController, - disabled: - !(_user?.canAddPublicWebsite ?? false), - ), + }), + controller: _anyoneController, + disabled: + !(_user?.canAddPublicInfo ?? false), + ), _customRelevanceSelectables(), ], ), diff --git a/lib/pages/portal/service/website_provider.dart b/lib/pages/portal/service/website_provider.dart index a6737d487..bc7f6047a 100644 --- a/lib/pages/portal/service/website_provider.dart +++ b/lib/pages/portal/service/website_provider.dart @@ -13,7 +13,7 @@ extension UserExtension on User { /// Check if there is at least one website that the [User] has permission to edit Future get hasEditableWebsites async { // We assume there is at least one public website in the database - if (canEditPublicWebsite) return true; + if (canEditPublicInfo) return true; return hasPrivateWebsites; } diff --git a/lib/pages/portal/view/portal_page.dart b/lib/pages/portal/view/portal_page.dart index a2a7dc965..664fe80a2 100644 --- a/lib/pages/portal/view/portal_page.dart +++ b/lib/pages/portal/view/portal_page.dart @@ -75,7 +75,7 @@ class _PortalPageState extends State { Widget websiteCircle(Website website, double size) { final bool canEdit = editingEnabled && - (website.isPrivate || (user.canEditPublicWebsite ?? false)); + (website.isPrivate || (user.canEditPublicInfo ?? false)); return Padding( padding: const EdgeInsets.all(8), child: WebsiteIcon( diff --git a/lib/pages/portal/view/website_view.dart b/lib/pages/portal/view/website_view.dart index 1290a40c7..094855092 100644 --- a/lib/pages/portal/view/website_view.dart +++ b/lib/pages/portal/view/website_view.dart @@ -292,7 +292,6 @@ class _WebsiteViewState extends State { RelevancePicker( filterProvider: Provider.of(context), defaultPrivate: widget.website?.isPrivate ?? true, - defaultRelevance: widget.website?.relevance, controller: _relevanceController, ), TextFormField( diff --git a/lib/pages/timetable/model/academic_calendar.dart b/lib/pages/timetable/model/academic_calendar.dart index db5df4099..a39cc1f5d 100644 --- a/lib/pages/timetable/model/academic_calendar.dart +++ b/lib/pages/timetable/model/academic_calendar.dart @@ -1,13 +1,16 @@ import 'package:acs_upb_mobile/pages/timetable/model/events/all_day_event.dart'; import 'package:acs_upb_mobile/resources/utils.dart'; +import 'package:flutter/foundation.dart'; import 'package:time_machine/time_machine.dart'; class AcademicCalendar { AcademicCalendar( - {this.semesters = const [], + {@required this.id, + this.semesters = const [], this.holidays = const [], this.exams = const []}); + String id; List semesters; List holidays; List exams; diff --git a/lib/pages/timetable/model/events/all_day_event.dart b/lib/pages/timetable/model/events/all_day_event.dart index 2a0283c0c..f593e1dad 100644 --- a/lib/pages/timetable/model/events/all_day_event.dart +++ b/lib/pages/timetable/model/events/all_day_event.dart @@ -17,6 +17,7 @@ class AllDayUniEvent extends UniEvent { AcademicCalendar calendar, List relevance, String degree, + String addedBy, }) : startDate = start, endDate = end, super( @@ -30,7 +31,8 @@ class AllDayUniEvent extends UniEvent { classHeader: classHeader, calendar: calendar, relevance: relevance, - degree: degree); + degree: degree, + addedBy: addedBy); LocalDate startDate; LocalDate endDate; diff --git a/lib/pages/timetable/model/events/recurring_event.dart b/lib/pages/timetable/model/events/recurring_event.dart index d19765237..e4ca2ab7b 100644 --- a/lib/pages/timetable/model/events/recurring_event.dart +++ b/lib/pages/timetable/model/events/recurring_event.dart @@ -56,6 +56,7 @@ class RecurringUniEvent extends UniEvent { UniEventType type, ClassHeader classHeader, AcademicCalendar calendar, + String addedBy, }) : super( name: name, location: location, @@ -67,7 +68,8 @@ class RecurringUniEvent extends UniEvent { color: color, type: type, classHeader: classHeader, - calendar: calendar); + calendar: calendar, + addedBy: addedBy); final RecurrenceRule rrule; diff --git a/lib/pages/timetable/model/events/uni_event.dart b/lib/pages/timetable/model/events/uni_event.dart index 4f38943a4..41dad54b8 100644 --- a/lib/pages/timetable/model/events/uni_event.dart +++ b/lib/pages/timetable/model/events/uni_event.dart @@ -39,6 +39,55 @@ extension UniEventTypeExtension on UniEventType { return S.of(context).uniEventTypeOther; } } + + static List get classTypes => [ + UniEventType.lecture, + UniEventType.lab, + UniEventType.seminar, + UniEventType.sports + ]; + + static UniEventType fromString(String string) { + switch (string) { + case 'lab': + return UniEventType.lab; + case 'lecture': + return UniEventType.lecture; + case 'seminar': + return UniEventType.seminar; + case 'sports': + return UniEventType.sports; + case 'semester': + return UniEventType.semester; + case 'holiday': + return UniEventType.holiday; + case 'examSession': + return UniEventType.examSession; + default: + return UniEventType.other; + } + } + + Color get color { + switch (this) { + case UniEventType.lecture: + return Colors.pinkAccent; + case UniEventType.lab: + return Colors.blueAccent; + case UniEventType.seminar: + return Colors.orangeAccent; + case UniEventType.sports: + return Colors.greenAccent; + case UniEventType.semester: + return Colors.transparent; + case UniEventType.holiday: + return Colors.yellow; + case UniEventType.examSession: + return Colors.red; + default: + return Colors.white; + } + } } class UniEvent { @@ -54,6 +103,7 @@ class UniEvent { this.calendar, this.relevance, this.degree, + this.addedBy, }); final String id; @@ -67,6 +117,7 @@ class UniEvent { final AcademicCalendar calendar; final String degree; final List relevance; + final String addedBy; Iterable generateInstances( {DateInterval intersectingInterval}) sync* { diff --git a/lib/pages/timetable/service/uni_event_provider.dart b/lib/pages/timetable/service/uni_event_provider.dart index 656e3af54..92c1a5d9a 100644 --- a/lib/pages/timetable/service/uni_event_provider.dart +++ b/lib/pages/timetable/service/uni_event_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:acs_upb_mobile/authentication/service/auth_provider.dart'; +import 'package:acs_upb_mobile/generated/l10n.dart'; import 'package:acs_upb_mobile/pages/classes/model/class.dart'; import 'package:acs_upb_mobile/pages/classes/service/class_provider.dart'; import 'package:acs_upb_mobile/pages/filter/model/filter.dart'; @@ -9,6 +10,7 @@ import 'package:acs_upb_mobile/pages/timetable/model/academic_calendar.dart'; import 'package:acs_upb_mobile/pages/timetable/model/events/all_day_event.dart'; import 'package:acs_upb_mobile/pages/timetable/model/events/recurring_event.dart'; import 'package:acs_upb_mobile/pages/timetable/model/events/uni_event.dart'; +import 'package:acs_upb_mobile/widgets/toast.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:rrule/rrule.dart'; @@ -16,50 +18,6 @@ import 'package:synchronized/synchronized.dart'; import 'package:time_machine/time_machine.dart'; import 'package:timetable/timetable.dart'; -extension UniEventTypeExtension on UniEventType { - static UniEventType fromString(String string) { - switch (string) { - case 'lab': - return UniEventType.lab; - case 'lecture': - return UniEventType.lecture; - case 'seminar': - return UniEventType.seminar; - case 'sports': - return UniEventType.sports; - case 'semester': - return UniEventType.semester; - case 'holiday': - return UniEventType.holiday; - case 'examSession': - return UniEventType.examSession; - default: - return UniEventType.other; - } - } - - Color get color { - switch (this) { - case UniEventType.lecture: - return Colors.pinkAccent; - case UniEventType.lab: - return Colors.blueAccent; - case UniEventType.seminar: - return Colors.orangeAccent; - case UniEventType.sports: - return Colors.greenAccent; - case UniEventType.semester: - return Colors.transparent; - case UniEventType.holiday: - return Colors.yellow; - case UniEventType.examSession: - return Colors.red; - default: - return Colors.white; - } - } -} - extension PeriodExtension on Period { static Period fromJSON(Map json) { return Period( @@ -75,6 +33,27 @@ extension PeriodExtension on Period { nanoseconds: json['nanoseconds'] ?? 0, ); } + + Map toJSON() { + final json = { + 'years': years, + 'months': months, + 'weeks': weeks, + 'days': days, + 'hours': hours, + 'minutes': minutes, + 'seconds': seconds, + 'milliseconds': milliseconds, + 'microseconds': microseconds, + 'nanoseconds': nanoseconds + }; + + return json..removeWhere((key, value) => value == 0); + } +} + +extension LocalDateTimeExtension on LocalDateTime { + Timestamp toTimestamp() => Timestamp.fromDate(toDateTimeLocal()); } extension UniEventExtension on UniEvent { @@ -102,6 +81,7 @@ extension UniEventExtension on UniEvent { relevance: json['relevance'] == null ? null : List.from(json['relevance']), + addedBy: json['addedBy'], ); } else if (json['rrule'] != null) { return RecurringUniEvent( @@ -121,6 +101,7 @@ extension UniEventExtension on UniEvent { relevance: json['relevance'] == null ? null : List.from(json['relevance']), + addedBy: json['addedBy'], ); } else { return UniEvent( @@ -139,9 +120,37 @@ extension UniEventExtension on UniEvent { relevance: json['relevance'] == null ? null : List.from(json['relevance']), + addedBy: json['addedBy'], ); } } + + Map toData() { + final type = this.type.toString().split('.').last; + + final json = { + 'type': type, + 'name': name, + 'start': start.toTimestamp(), + 'duration': duration.toJSON(), + 'location': location, + 'class': classHeader.id, + 'degree': degree, + 'relevance': relevance, + 'calendar': calendar.id, + 'addedBy': addedBy, + }; + + if (this is RecurringUniEvent) { + json['rrule'] = (this as RecurringUniEvent).rrule.toString(); + } + + if (this is AllDayUniEvent) { + json['end'] = (this as AllDayUniEvent).endDate.atMidnight().toTimestamp(); + } + + return json; + } } extension AcademicCalendarExtension on AcademicCalendar { @@ -155,6 +164,7 @@ extension AcademicCalendarExtension on AcademicCalendar { static AcademicCalendar fromSnap(DocumentSnapshot snap) { return AcademicCalendar( + id: snap.documentID, semesters: _eventsFromMapList(snap.data['semesters'], 'semester'), holidays: _eventsFromMapList(snap.data['holidays'], 'holiday'), exams: _eventsFromMapList(snap.data['exams'], 'examSession'), @@ -179,13 +189,15 @@ class UniEventProvider extends EventProvider var cacheLock = Lock(); - Future fetchCalendars() async { + Future> fetchCalendars() async { final QuerySnapshot query = await Firestore.instance.collection('calendars').getDocuments(); for (final doc in query.documents) { _calendars[doc.documentID] = AcademicCalendarExtension.fromSnap(doc); } + notifyListeners(); + return _calendars; } Future get empty async { @@ -297,9 +309,65 @@ class UniEventProvider extends EventProvider }); } + Future addEvent(UniEvent event, {BuildContext context}) async { + try { + await Firestore.instance.collection('events').add(event.toData()); + eventsCache = null; + notifyListeners(); + return true; + } catch (e) { + _errorHandler(e, context); + return false; + } + } + + Future updateEvent(UniEvent event, {BuildContext context}) async { + try { + final ref = Firestore.instance.collection('events').document(event.id); + + if ((await ref.get()).data == null) { + print('Event not found.'); + return false; + } + + await ref.updateData(event.toData()); + eventsCache = null; + notifyListeners(); + return true; + } catch (e) { + _errorHandler(e, context); + return false; + } + } + + Future deleteEvent(UniEvent event, {BuildContext context}) async { + try { + DocumentReference ref; + ref = Firestore.instance.collection('events').document(event.id); + await ref.delete(); + eventsCache = null; + notifyListeners(); + return true; + } catch (e) { + _errorHandler(e, context); + return false; + } + } + @override // ignore: must_call_super void dispose() { // TODO(IoanaAlexandru): Find a better way to prevent Timetable from calling dispose on this provider } + + void _errorHandler(dynamic e, BuildContext context) { + print(e.message); + if (context != null) { + if (e.message.contains('PERMISSION_DENIED')) { + AppToast.show(S.of(context).errorPermissionDenied); + } else { + AppToast.show(S.of(context).errorSomethingWentWrong); + } + } + } } diff --git a/lib/pages/timetable/view/events/add_event_view.dart b/lib/pages/timetable/view/events/add_event_view.dart new file mode 100644 index 000000000..896b6b465 --- /dev/null +++ b/lib/pages/timetable/view/events/add_event_view.dart @@ -0,0 +1,682 @@ +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/classes/model/class.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/relevance_picker.dart'; +import 'package:acs_upb_mobile/pages/timetable/model/academic_calendar.dart'; +import 'package:acs_upb_mobile/pages/timetable/model/events/all_day_event.dart'; +import 'package:acs_upb_mobile/pages/timetable/model/events/recurring_event.dart'; +import 'package:acs_upb_mobile/pages/timetable/model/events/uni_event.dart'; +import 'package:acs_upb_mobile/pages/timetable/service/uni_event_provider.dart'; +import 'package:acs_upb_mobile/resources/custom_icons.dart'; +import 'package:acs_upb_mobile/resources/locale_provider.dart'; +import 'package:acs_upb_mobile/widgets/button.dart'; +import 'package:acs_upb_mobile/widgets/dialog.dart'; +import 'package:acs_upb_mobile/widgets/scaffold.dart'; +import 'package:acs_upb_mobile/widgets/selectable.dart'; +import 'package:acs_upb_mobile/widgets/toast.dart'; +import 'package:dotted_line/dotted_line.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; +import 'package:rrule/rrule.dart'; +import 'package:time_machine/time_machine.dart' as time_machine show DayOfWeek; +import 'package:time_machine/time_machine.dart' hide DayOfWeek; +import 'package:time_machine/time_machine_text_patterns.dart'; + +class AddEventView extends StatefulWidget { + /// If the `id` of [initialEvent] is not null, this acts like an "Edit event" + /// page starting from the info in [initialEvent]. Otherwise, it acts like an + /// "Add event" page with optional default values based on [initialEvent]. + const AddEventView({Key key, this.initialEvent}) : super(key: key); + + final UniEvent initialEvent; + + @override + _AddEventViewState createState() => _AddEventViewState(); +} + +class _AddEventViewState extends State { + final formKey = GlobalKey(); + + TextEditingController locationController; + RelevanceController relevanceController = RelevanceController(); + + UniEventType selectedEventType; + ClassHeader selectedClass; + String selectedCalendar; + LocalTime startTime; + Period duration; + Map weekSelected = { + WeekType.odd: null, + WeekType.even: null, + }; + Map weekDaySelected = { + DayOfWeek.monday: false, + DayOfWeek.tuesday: false, + DayOfWeek.wednesday: false, + DayOfWeek.thursday: false, + DayOfWeek.friday: false, + DayOfWeek.saturday: false, + DayOfWeek.sunday: false, + }; + + // TODO(IoanaAlexandru): Make default semester the one closest to now + int selectedSemester = 1; + + AllDayUniEvent get semester => + calendars[selectedCalendar]?.semesters?.elementAt(selectedSemester - 1); + + List classHeaders = []; + User user; + Map calendars = {}; + + @override + void initState() { + super.initState(); + + user = + Provider.of(context, listen: false).currentUserFromCache; + Provider.of(context, listen: false) + .fetchClassHeaders(uid: user.uid) + .then((headers) => setState(() => classHeaders = headers)); + Provider.of(context, listen: false) + .fetchCalendars() + .then((calendars) { + setState(() { + this.calendars = calendars; + // TODO(IoanaAlexandru): Make the default calendar the one closest + // to now and extract calendar/semester from [widget.initialEvent] + selectedCalendar = calendars.keys.first; + }); + + if (widget.initialEvent != null && + widget.initialEvent is RecurringUniEvent) { + final RecurringUniEvent event = widget.initialEvent; + if (event.rrule.interval != 1) { + final rule = WeekYearRules.iso; + if (rule.getWeekOfWeekYear(semester.start.calendarDate) == + rule.getWeekOfWeekYear(event.start.calendarDate)) { + // Week is odd + weekSelected[WeekType.even] = false; + weekSelected[WeekType.odd] = true; + } else { + // Week is even + weekSelected[WeekType.even] = true; + weekSelected[WeekType.odd] = false; + } + } + } + + setState(() { + weekSelected[WeekType.even] ??= true; + weekSelected[WeekType.odd] ??= true; + }); + }); + + selectedEventType = widget.initialEvent?.type; + selectedClass = widget.initialEvent?.classHeader; + locationController = + TextEditingController(text: widget.initialEvent?.location ?? ''); + + final startHour = widget.initialEvent?.start?.hourOfDay ?? 8; + duration = widget.initialEvent?.duration ?? const Period(hours: 2); + startTime = LocalTime(startHour, 0, 0); + + var initialWeekDays = [ + DayOfWeek.from(widget.initialEvent?.start?.dayOfWeek) ?? DayOfWeek.monday + ]; + if (widget.initialEvent != null && + widget.initialEvent is RecurringUniEvent) { + initialWeekDays = (widget.initialEvent as RecurringUniEvent) + .rrule + .byWeekDays + .map((entry) => DayOfWeek.from(entry.day)) + .toList(); + } + for (final initialWeekDay in initialWeekDays) { + weekDaySelected[initialWeekDay] = true; + } + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + title: Text(widget.initialEvent?.id == null + ? S.of(context).actionAddEvent + : S.of(context).actionEditEvent), + actions: widget.initialEvent?.id == null + ? [_saveButton()] + : [ + _saveButton(), + _deleteButton(), + ], + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: DropdownButtonFormField( + decoration: InputDecoration( + labelText: S.of(context).labelUniversityYear, + prefixIcon: const Icon(Icons.calendar_today), + ), + value: selectedCalendar, + items: calendars.keys.map((key) { + final year = int.tryParse(key); + return DropdownMenuItem( + value: key, + child: Text( + year != null ? '$year-${year + 1}' : key), + ); + }).toList(), + onChanged: (selection) => + selectedCalendar = selection, + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + decoration: InputDecoration( + labelText: S.of(context).labelSemester, + prefixIcon: const Icon(Icons.calendar_view_day), + ), + value: selectedSemester, + items: [1, 2] + .map((semester) => DropdownMenuItem( + value: semester, + child: Text(semester.toString()), + )) + .toList(), + onChanged: (selection) => + selectedSemester = selection, + ), + ), + ], + ), + RelevanceFormField( + controller: relevanceController, + validator: (_) { + if (relevanceController.customRelevance?.isEmpty ?? + true) { + return S.of(context).warningYouNeedToSelectAtLeastOne; + } + return null; + }, + ), + DropdownButtonFormField( + decoration: InputDecoration( + labelText: S.of(context).labelType, + prefixIcon: const Icon(Icons.category), + ), + value: selectedEventType, + items: UniEventTypeExtension.classTypes + .map( + (type) => DropdownMenuItem( + value: type, + child: Text(type.toLocalizedString(context)), + ), + ) + .toList(), + onChanged: (selection) { + formKey.currentState.validate(); + setState(() => selectedEventType = selection); + }, + validator: (selection) { + if (selection == null) { + return S.of(context).errorEventTypeCannotBeEmpty; + } + return null; + }, + ), + if (selectedEventType != null) + Column( + children: [ + if (classHeaders.isNotEmpty) + DropdownButtonFormField( + isExpanded: true, + decoration: InputDecoration( + labelText: S.of(context).labelClass, + prefixIcon: const Icon(Icons.class_), + ), + value: selectedClass, + items: classHeaders + .map( + (header) => DropdownMenuItem( + value: header, child: Text(header.name)), + ) + .toList(), + onChanged: (selection) { + formKey.currentState.validate(); + setState(() => selectedClass = selection); + }, + validator: (selection) { + if (selection == null) { + return S.of(context).errorClassCannotBeEmpty; + } + return null; + }, + ), + timeIntervalPicker(), + if (weekSelected[WeekType.odd] != null && + weekSelected[WeekType.even]) + SelectableFormField( + icon: Icons.calendar_today, + label: S.of(context).labelWeek, + initialValues: weekSelected, + validator: (selection) { + if (selection.values + .where((e) => e != false) + .isEmpty) { + return S + .of(context) + .warningYouNeedToSelectAtLeastOne; + } + return null; + }, + ), + SelectableFormField( + icon: Icons.today, + label: S.of(context).labelDay, + initialValues: weekDaySelected, + validator: (selection) { + if (selection.values + .where((e) => e != false) + .isEmpty) { + return S + .of(context) + .warningYouNeedToSelectAtLeastOne; + } + return null; + }, + ), + TextFormField( + controller: locationController, + decoration: InputDecoration( + labelText: S.of(context).labelLocation, + prefixIcon: const Icon(Icons.location_on), + ), + onChanged: (_) => setState(() {}), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + AppDialog _deletionConfirmationDialog(BuildContext context) => AppDialog( + icon: const Icon(Icons.delete), + title: S.of(context).actionDeleteEvent, + info: S.of(context).messageThisCouldAffectOtherStudents, + message: S.of(context).messageDeleteEvent, + actions: [ + AppButton( + text: S.of(context).actionDeleteEvent, + width: 130, + onTap: () async { + final res = + await Provider.of(context, listen: false) + .deleteEvent(widget.initialEvent); + if (res) { + Navigator.of(context) + .popUntil(ModalRoute.withName(Routes.home)); + AppToast.show(S.of(context).messageEventDeleted); + } + }, + ) + ], + ); + + AppScaffoldAction _saveButton() => AppScaffoldAction( + text: S.of(context).buttonSave, + onPressed: () async { + if (!formKey.currentState.validate()) return; + + LocalDateTime start = semester.startDate.at(startTime); + if (weekSelected[WeekType.even] && !weekSelected[WeekType.odd]) { + // Event is every even week, add a week to start date + start = start.add(const Period(weeks: 1)); + } + + final rrule = RecurrenceRule( + frequency: Frequency.weekly, + byWeekDays: (Map.from(weekDaySelected) + ..removeWhere((key, value) => !value)) + .keys + .map((weekDay) => ByWeekDayEntry(weekDay)) + .toSet(), + interval: + weekSelected[WeekType.odd] != weekSelected[WeekType.even] + ? 2 + : 1, + until: semester.endDate.add(const Period(days: 1)).atMidnight()); + + final event = RecurringUniEvent( + rrule: rrule, + start: start, + duration: duration, + id: widget.initialEvent?.id, + relevance: relevanceController.customRelevance, + degree: relevanceController.degree, + location: locationController.text, + type: selectedEventType, + classHeader: selectedClass, + calendar: calendars[selectedCalendar], + addedBy: Provider.of(context, listen: false) + .currentUserFromCache + .uid); + + if (widget.initialEvent?.id == null) { + final res = + await Provider.of(context, listen: false) + .addEvent(event); + if (res) { + Navigator.of(context).pop(); + AppToast.show(S.of(context).messageEventAdded); + } + } else { + final res = + await Provider.of(context, listen: false) + .updateEvent(event); + if (res) { + Navigator.of(context).popUntil(ModalRoute.withName(Routes.home)); + AppToast.show(S.of(context).messageEventEdited); + } + } + }, + ); + + AppScaffoldAction _deleteButton() => AppScaffoldAction( + icon: Icons.more_vert, + items: { + S.of(context).actionDeleteEvent: () => showDialog( + context: context, child: _deletionConfirmationDialog(context)) + }, + onPressed: () => showDialog( + context: context, child: _deletionConfirmationDialog(context)), + ); + + Widget timeIntervalPicker() { + final endTime = startTime.add(duration); + final textColor = Theme.of(context).textTheme.headline4.color; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + const SizedBox(width: 12), + Icon( + Icons.access_time, + color: CustomIcons.formIconColor(Theme.of(context)), + ), + FlatButton( + onPressed: () async { + final TimeOfDay start = await showTimePicker( + context: context, + initialTime: startTime.toTimeOfDay(), + ); + setState(() => startTime = start.toLocalTime()); + }, + child: Text( + startTime.toString('HH:mm'), + style: Theme.of(context).textTheme.headline4, + ), + ), + Expanded( + child: Column( + children: [ + Text( + duration.toString().replaceAll(RegExp(r'[PT]'), ''), + style: Theme.of(context) + .textTheme + .bodyText1 + .copyWith(color: textColor), + ), + DottedLine( + lineThickness: 4, + dashRadius: 2, + dashColor: textColor, + ), + // Text-sized box so that the line is centered + SizedBox( + height: Theme.of(context).textTheme.bodyText1.fontSize), + ], + ), + ), + FlatButton( + onPressed: () async { + final TimeOfDay end = await showTimePicker( + context: context, + initialTime: startTime.add(duration).toTimeOfDay(), + ); + setState(() => duration = + Period.differenceBetweenTimes(startTime, end.toLocalTime())); + }, + child: Text( + endTime.toString('HH:mm'), + style: Theme.of(context).textTheme.headline4, + ), + ), + ], + ), + ); + } +} + +class RelevanceFormField extends FormField> { + RelevanceFormField({ + @required this.controller, + String Function(List) validator, + Key key, + }) : super( + key: key, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: validator, + builder: (FormFieldState> state) { + controller.onChanged = () { + state.didChange(controller.customRelevance); + }; + final context = state.context; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RelevancePicker( + canBePrivate: false, + canBeForEveryone: false, + filterProvider: Provider.of(context), + controller: controller, + ), + if (state.hasError) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + state.errorText, + style: Theme.of(context) + .textTheme + .caption + .copyWith(color: Theme.of(context).errorColor), + ), + ), + ], + ); + }, + ); + + final RelevanceController controller; +} + +class SelectableFormField extends FormField> { + SelectableFormField({ + @required Map initialValues, + @required IconData icon, + @required String label, + String Function(Map) validator, + Key key, + }) : super( + autovalidateMode: AutovalidateMode.onUserInteraction, + initialValue: initialValues, + key: key, + validator: validator, + builder: (state) { + final context = state.context; + final labels = state.value.keys.toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.only(top: 12, left: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, + color: + CustomIcons.formIconColor(Theme.of(context))), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context) + .textTheme + .caption + .apply( + color: Theme.of(context).hintColor), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Container( + height: 40, + child: ListView.builder( + itemCount: labels.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + return Row( + children: [ + Selectable( + label: labels[index] + .toLocalizedString(context), + initiallySelected: + state.value[labels[index]], + onSelected: (selected) { + state.value[labels[index]] = + selected; + state.didChange(state.value); + }, + ), + const SizedBox(width: 8), + ], + ); + }, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + if (state.hasError) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + state.errorText, + style: Theme.of(context) + .textTheme + .caption + .copyWith(color: Theme.of(context).errorColor), + ), + ), + ], + ); + }, + ); +} + +class DayOfWeek extends time_machine.DayOfWeek with Localizable { + const DayOfWeek(int value) : super(value); + + DayOfWeek.from(time_machine.DayOfWeek dayOfWeek) : super(dayOfWeek.value); + + @override + String toLocalizedString(BuildContext context) { + final helperDate = LocalDate.today().next(this); + return LocalDatePattern.createWithCurrentCulture('ddd') + .format(helperDate) + .substring(0, 3); + } + + static const DayOfWeek none = DayOfWeek(0); + static const DayOfWeek monday = DayOfWeek(1); + static const DayOfWeek tuesday = DayOfWeek(2); + static const DayOfWeek wednesday = DayOfWeek(3); + static const DayOfWeek thursday = DayOfWeek(4); + static const DayOfWeek friday = DayOfWeek(5); + static const DayOfWeek saturday = DayOfWeek(6); + static const DayOfWeek sunday = DayOfWeek(7); +} + +class WeekType with Localizable { + const WeekType(this._value); + + final int _value; + + int get value => _value; + + static const WeekType odd = WeekType(0); + static const WeekType even = WeekType(1); + + @override + int get hashCode => _value.hashCode; + + @override + bool operator ==(dynamic other) => + other is WeekType && other._value == _value || + other is int && other == _value; + + @override + String toLocalizedString(BuildContext context) { + switch (_value) { + case 0: + return S.of(context).labelOdd; + case 1: + return S.of(context).labelEven; + default: + return ''; + } + } +} + +extension LocalTimeConversion on LocalTime { + TimeOfDay toTimeOfDay() => TimeOfDay(hour: hourOfDay, minute: minuteOfHour); +} + +extension TimeOfDayConversion on TimeOfDay { + LocalTime toLocalTime() => LocalTime(hour, minute, 0); +} diff --git a/lib/pages/timetable/view/events/event_view.dart b/lib/pages/timetable/view/events/event_view.dart index 4138f8a2b..564a1236e 100644 --- a/lib/pages/timetable/view/events/event_view.dart +++ b/lib/pages/timetable/view/events/event_view.dart @@ -1,12 +1,16 @@ +import 'package:acs_upb_mobile/authentication/service/auth_provider.dart'; import 'package:acs_upb_mobile/generated/l10n.dart'; import 'package:acs_upb_mobile/pages/classes/service/class_provider.dart'; import 'package:acs_upb_mobile/pages/classes/view/class_view.dart'; import 'package:acs_upb_mobile/pages/classes/view/classes_page.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/timetable/model/events/recurring_event.dart'; import 'package:acs_upb_mobile/pages/timetable/model/events/uni_event.dart'; +import 'package:acs_upb_mobile/pages/timetable/view/events/add_event_view.dart'; import 'package:acs_upb_mobile/resources/locale_provider.dart'; import 'package:acs_upb_mobile/widgets/scaffold.dart'; +import 'package:acs_upb_mobile/widgets/toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; @@ -64,6 +68,29 @@ class _EventViewState extends State { Widget build(BuildContext context) { return AppScaffold( title: Text(S.of(context).navigationEventDetails), + actions: [ + AppScaffoldAction( + icon: Icons.edit, + onPressed: () { + final user = Provider.of(context, listen: false) + .currentUserFromCache; + if (user.canAddPublicInfo) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ChangeNotifierProvider( + create: (_) => FilterProvider( + defaultDegree: widget.event.mainEvent.degree, + defaultRelevance: widget.event.mainEvent.relevance, + ), + child: AddEventView( + initialEvent: widget.event.mainEvent, + ), + ), + )); + } else { + AppToast.show(S.of(context).errorPermissionDenied); + } + }) + ], body: SafeArea( child: ListView(children: [ Padding( diff --git a/lib/pages/timetable/view/timetable_page.dart b/lib/pages/timetable/view/timetable_page.dart index 7be864a98..6a90efe8f 100644 --- a/lib/pages/timetable/view/timetable_page.dart +++ b/lib/pages/timetable/view/timetable_page.dart @@ -10,6 +10,7 @@ import 'package:acs_upb_mobile/pages/settings/view/request_permissions.dart'; import 'package:acs_upb_mobile/pages/timetable/model/events/uni_event.dart'; import 'package:acs_upb_mobile/pages/timetable/service/uni_event_provider.dart'; import 'package:acs_upb_mobile/pages/timetable/view/date_header.dart'; +import 'package:acs_upb_mobile/pages/timetable/view/events/add_event_view.dart'; import 'package:acs_upb_mobile/pages/timetable/view/events/all_day_event_widget.dart'; import 'package:acs_upb_mobile/pages/timetable/view/events/event_widget.dart'; import 'package:acs_upb_mobile/resources/custom_icons.dart'; @@ -97,6 +98,27 @@ class _TimetablePageState extends State { event, info: info, ), + onEventBackgroundTap: (dateTime, isAllDay) { + if (!isAllDay) { + final user = Provider.of(context, listen: false) + .currentUserFromCache; + if (user.canAddPublicInfo) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ChangeNotifierProvider( + create: (_) => FilterProvider(), + child: AddEventView( + initialEvent: UniEvent( + start: dateTime, + duration: const Period(hours: 2), + id: null), + ), + ), + )); + } else { + AppToast.show(S.of(context).errorPermissionDenied); + } + } + }, ), if (eventProvider.eventsCache == null) const Center(child: CircularProgressIndicator()), diff --git a/lib/resources/locale_provider.dart b/lib/resources/locale_provider.dart index b43216c26..173da51e3 100644 --- a/lib/resources/locale_provider.dart +++ b/lib/resources/locale_provider.dart @@ -4,6 +4,10 @@ import 'package:preferences/preference_service.dart'; import 'package:rrule/rrule.dart'; import 'package:time_machine/time_machine.dart'; +mixin Localizable { + String toLocalizedString(BuildContext context); +} + class LocaleProvider { LocaleProvider._(); diff --git a/lib/widgets/selectable.dart b/lib/widgets/selectable.dart index 32601914a..f8462d5d7 100644 --- a/lib/widgets/selectable.dart +++ b/lib/widgets/selectable.dart @@ -6,10 +6,12 @@ class SelectableController { bool get isSelected => _selectableState?._isSelected; void select() { + if (_selectableState == null) return; if (!isSelected) _selectableState.isSelected = true; } void deselect() { + if (_selectableState == null) return; if (isSelected) _selectableState.isSelected = false; } } diff --git a/pubspec.lock b/pubspec.lock index bdd2e9612..4448221d7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,6 +218,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + dotted_line: + dependency: "direct main" + description: + name: dotted_line + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" dynamic_text_highlighting: dependency: "direct main" description: @@ -469,6 +476,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.1" + interval_time_picker: + dependency: "direct main" + description: + name: interval_time_picker + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0+1" intl: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a9ee5b199..f6edc7eab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,10 +13,10 @@ description: A mobile application for students at ACS UPB. # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # # ACS UPB Mobile uses semantic versioning. You can read more in the CONTRIBUTING.md file. -version: 1.0.1+2 +version: 1.1.0+1 environment: - sdk: ">=2.6.0 <3.0.0" + sdk: ">=2.7.0 <3.0.0" dependencies: flutter: @@ -87,8 +87,9 @@ dependencies: # Color picker widget flutter_colorpicker: ^0.3.4 - # Date & time picker widget + # Date & time picker widgets datetime_picker_formfield: ^1.0.0 + interval_time_picker: ^0.1.0 # Allows getting the position of taps positioned_tap_detector: ^1.0.3 @@ -105,6 +106,9 @@ dependencies: # Web scraper web_scraper: ^0.0.6 + # Dotted line painter + dotted_line: ^2.0.1 + # Support lock/mutex synchronized: ^2.2.0