From cfe2332bc985a09c4b8269e2d93848a8af47ed0b Mon Sep 17 00:00:00 2001 From: Ioana Alexandru Date: Thu, 8 Oct 2020 00:48:36 +0300 Subject: [PATCH] Add/edit events (#67) * define basic timetable UI and event model * handle rrules * skip holidays * add custom event provider * remove add event button * prevent UniEventProvider disposal for now, we prevent the provider from being disposed _at all_, because we don't yet have a better solution that prevents Timetable from disposing its EventProvider * fetch events from firestore * give event instances different IDs * make event json numeric values numbers instead of strings * set default event fields and ignore invalid events * add classHeader field to event * rename AuthProvider to AuthenticationProvider the previous name clashed with the Firebase API * filter events by user classes * limit maximum selected nodes in filter firestore 'in' queries have a limit of 10 elements: https://firebase.google.com/docs/firestore/query-data/queries#in_and_array-contains-any * filter events based on selected filter nodes * wait for filter to be fetched in the event provider * different colours for event types * generate event instances lazily * change test holidays * define academic calendar with DateIntervals * store rrules as RFC-5545-compliant strings * handle week parity and odd-week holidays * fix last day of holiday not showing on calendar * rename AuthProvider again calling it AuthenticationProvider is way too long and causes unnecessary conflicts, we can just solve the name collision with the Firebase API by importing selectively if the need arises * make calendar a property of event * read calendar from database * show event details on tap * add exam sessions to academic calendar * make calendar intervals events * improve date format on event view * show class on event page * show relevance information on event page * show recurrence on event view in English * fix tests * Show month name as timetable scaffold title * Handle null-relevance events * Fix tests * Reformat code * Fix fetchHeader to use the new class ID * Fix some linter issues * Don't show month name when user is not authenticated * Filter events by degree field * Specify button to press in classes startup page * Show dialog if there are no events to display * Show different dialog when filter is not selected * Fix analysis issues * Reorganise event widgets * Fix tests * Filter calendar events by relevance * Show localized degree name on event page * Use `then` calls in event provider instead of async methods * Minor formatting/dartdoc improvements * Make filter page named * Localize week names in date header * Fix tests * Cache events read from firebase * Show progress indicator while events are loading * Remove 'All' from relevance query * Move progress indicator in stack I will forever find it confusing that the Stack order is top-to-bottom * Bump major version * Fix tests and formatting * Fix error when nothing is selected in filter * More dialogs when there are no events * Fix formatting * Fix reversed parity * Fix all recurrences being set on Monday * Create add event page * Add class selector * Add calendar/semester picker * Check if user has permission to add event * Add (WIP) relevance picker on add event page * Add time picker * Add week parity picker * Add weekday picker * Show details when event type is selected * Open edit event page This also fixes #65 * Implement event deletion * Implement add event functionality * Add "until" field to rrule * Implement event editing * Write calendar field when adding/updating event * Fix bad rebase * Fix all events being set on Monday * Select default day of week based on what the user taps * Don't show 'Anyone' relevance option on add event page * Force users to pick at least one relevance node * Validate class and event type * Move helper methods after build * Validate relevance * Validate week type * Validate week days * Get error color from theme * Bump version * Fix bad merge in test --- lib/authentication/model/user.dart | 4 +- lib/generated/intl/messages_en.dart | 11 + lib/generated/intl/messages_ro.dart | 11 + lib/generated/l10n.dart | 110 +++ lib/l10n/intl_en.arb | 11 + lib/l10n/intl_ro.arb | 11 + lib/pages/classes/model/class.dart | 11 + lib/pages/filter/service/filter_provider.dart | 7 +- lib/pages/filter/view/filter_page.dart | 22 +- lib/pages/filter/view/relevance_picker.dart | 235 +++--- .../portal/service/website_provider.dart | 2 +- lib/pages/portal/view/portal_page.dart | 2 +- lib/pages/portal/view/website_view.dart | 1 - .../timetable/model/academic_calendar.dart | 5 +- .../timetable/model/events/all_day_event.dart | 4 +- .../model/events/recurring_event.dart | 4 +- .../timetable/model/events/uni_event.dart | 51 ++ .../timetable/service/uni_event_provider.dart | 158 ++-- .../timetable/view/events/add_event_view.dart | 682 ++++++++++++++++++ .../timetable/view/events/event_view.dart | 27 + lib/pages/timetable/view/timetable_page.dart | 22 + lib/resources/locale_provider.dart | 4 + lib/widgets/selectable.dart | 2 + pubspec.lock | 14 + pubspec.yaml | 10 +- 25 files changed, 1249 insertions(+), 172 deletions(-) create mode 100644 lib/pages/timetable/view/events/add_event_view.dart 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