It is recommended that you go through our workshop first, to familiarize yourself with the technologies and the contribution process.
- Pull Request (contribution) process
- Development tips
- Style guide
- GitHub Actions
- Working with Firebase
- Internationalization
- Custom icons
- Check out this tutorial if you don't know how to make a PR.
- Increase the version number in the
pubspec.yaml
file with the following guidelines in mind:- Build number (0.2.1+4) is for very small changes and bug fixes (usually not visible to the end user). It should always be incremented, never reset, as it is what Google Play uses to tell updates apart.
- Patch version (0.2.1+4) is for minor improvements that may be visible to an attentive end user. It should reset to 0 when the minor/major version increments.
- Minor version (0.2.1+4) is for added functionality (i.e. merging a branch that introduces a new feature). It should reset to 0 when the major version increments.
- Major version (0.2.1+4) marks important project milestones.
- Document any non-obvious parts of the code and make sure the commit description is clear on why the change is necessary.
- If it's a new feature, write at least one test for it.
Please note that in order for a PR to be merged (accepted), all of the tests need to pass, including the linter (which checks for coding style and warnings, see Style guide). These checks are ran automatically using GitHub Actions. You also need at least one approval from a maintainer - after submitting a PR, you can request a review from the top right Reviewers menu on the Pull Request page.
Please use the following structure for creating new development branches:
[username]/[snake_case_branch_name]
This will make branches easier to find and search for.
Examples:
razvanra2/add_authoring_policy
luigi/dev_eng_improv
auditore/fix_md_typo
torvalds/android_speedups
When developing a new feature or working on a bug, your pull request will end up containing fixup commits (commits that change the same line of code repeatedly) or too fine-grained commits. An issue that can arise from this is that the main branch history will become poluted with unnecessary commits. To avoid it, we implement and enforce a squash policy. All commits that are merged into the main development branch have to be squashed ahead of the merge. You can do so by pressing "squash and merge" in GitHub (recommended), or, alternetively, following the generic local squash routine outlined bellow:
git checkout your_branch_name
git rebase -i HEAD~n
# n is normally the number of commits in the pull request.
# Set commits (except the one in the first line) from 'pick' to 'squash', save and quit.
# On the next screen, edit/refine commit messages.
# Save and quit.
git push -f # (force push to GitHub)
Please update the resulting commit message, if needed. It should read as a coherent message. In most cases, this means not just listing the interim commits.
Please refrain from creating several pull requests for the same change. Use the pull request that is already open (or was created earlier) to amend changes. This preserves the discussion and review that happened earlier for the respective change set. Similarly, please create one PR per development item, instead of bundling multiple fixes and improvements in a single PR.
- Make sure you have the Project view open in the Project tab on the left in Android Studio (not Android).
- Flutter comes with Hot Reload (the lightning icon, or Ctrl+\ or ⌘\), which allows you to load changes in the code quickly into an already running app, without you needing to reinstall it. It's a very handy feature, but it doesn't work all the time - if you change the code, use Hot Reload but don't see the expected changes, or see some weird behaviour, you may need to close and restart the app (or even reinstall).
- If running on web doesn't give the expected results after changing some code, you may need to clear the cache (in Chrome: Ctrl+Shift+C or ⌘+Shift+C to open the Inspect menu, then right-click the Refresh button, and select Empty cache and Hard reload.)
- Flutter Inspector is a powerful tool which allows you to visualize and explore Flutter widget trees. You can use it to find out where a specific part of the UI is defined in the code (by turning on Select widget mode and selecting the widget you'd like to find), it can help you debug layouts (by enabling Debug Paint, you can visualize padding, alignments and widget borders) and much more.
- Get used to reading code and searching through the codebase. The project is fairly large and for most things, you should be able to find a usage/implementation example within our codebase. Do try to reuse code as much as possible - prefer creating a customizable widget with the parameters you need for different use cases, over copy-pasting widget code. Some tips for exploring the codebase:
- Ctrl+Shift+F or ⌘+Shift+F upon clicking a directory in the Project view lets you search a keyword through the entire directory. This is particularly useful for searching for something in the entire codebase.
- Ctrl+click or ⌘+click through a class/method name takes you to its definition. You can also right-click on it and use "Find usages" or the various "Go To" options to explore how it is used/defined.
- Oftentimes, a class definition will be in a file that is outside our project - either from a package or the Flutter framework itself. These files are marked with a yellow background in the Android Studio tab bar. You cannot edit them, but it's often useful to read through them to understand how they work.
- By default, Flutter apps run in debug mode. That means a DEBUG banner is shown on the upper right corner of the app, and errors and overflows are marked quite visibly in the UI.
- If you'd like to temporarily hide the debug mode banner (to take a screenshot, for instance), open the Flutter Inspector tab from the right hand edge of Android Studio, click More actions and select Hide debug mode banner.
- Note that Flutter's debug mode is different from the Android Studio debugging (bug button), which is meant to allow you to use breakpoints and other debugging tools.
- If you really need to test the release version (usually not necessary), run
flutter run --release
from the Terminal.- The release version cannot be ran in an emulator.
- You may also need to temporarily change the release signing config. In the android/app/build.gradle file, replace
signingConfig signingConfigs.release
withsigningConfig signingConfigs.debug
. - For simplicity, you could call the default "main.dart" configuration in Android Studio "Debug", duplicate it and call the second one "Release", with
--release
as an argument. For example:
❗ | On Android, ACS UPB Mobile uses a separate (development) environment in debug mode. That means a completely different Firebase project - separate data, including user info. |
---|
This project uses the official Dart style guide with the following mentions:
- Android Studio (IntelliJ) with the
dartfmt
tool is used to automatically format the code (Ctrl+Alt+L or ⌥+⌘+L), including the order of imports (Ctrl+Alt+O or ⌥+⌘+O). - The extra_pedantic package is used for static analysis: it automatically highlights warnings related to the recommended dart style. Most of them can be fixed automatically by invoking Context Actions (place the cursor on the warning and press Alt+Enter) and selecting the correct action. Do not suppress a warning unless you know what you're doing - if you don't know how to fix it and Android Studio doesn't help, hover over the warning and you'll see a link to the documentation that can help you understand.
- Where necessary, comments should use Markdown formatting (e.g.
`backticks`
for code snippets and[brackets]
for code references).
This project uses GitHub Actions for CI/CD. That means that testing and deployment are automated.
The following actions are currently set up:
- Linter: Checks for warnings and coding style issues. Runs on every push and pull request.
- If your PR is made from a branch inside the repository (rather than a fork), acs-upb-mobile-bot should automatically add code review comments pointing out any warnings.
- Sometimes, the automatic check for dead links in documentation fails with "429 too many requests" (see this issue). You can ignore those if you know the links in question are good.
- If you have formatting issues, the "Check formatting" step will point out the files that need to be formatted and the workflow will fail.
- If your PR is made from a branch inside the repository (rather than a fork), acs-upb-mobile-bot should automatically add code review comments pointing out any warnings.
- Tests: Runs all tests in the test/ directory and submits a coverage report to codecov. This action is triggered on every push and pull request.
- If at least one test fails, this workflow will fail.
- The coverage is the percentage of lines of code that are executed at least once in tests. This project aims to keep coverage above 70% at all times.
- Deployment: Deploys the web version of the app to the website (acs-upb-mobile.web.app) and creates a corresponding GitHub Release including the APK. This action is triggered when a new version tag is pushed. Do not push version tags unless you know what you are doing.
ACS UPB Mobile uses Firebase - an app development platform built on Google infrastructure and using Google Cloud Platform - to manage remote storage and authentication, as well as other cloud resources.
This application uses flutterfire plugins in order to access Firebase services. They are already enabled in the pubspec file and ready to import and use in the code.
❗ FlutterFire only has Cloud Storage support for Android and iOS. The web version needs a special implementation. See resources/storage for an example.
Firebase provides an entire suite of back-end services and SDKs for authenticating users within an application, through FirebaseAuth.
For our application, we use the following features:
- account creation
- login
- password reset via e-mail
- account verification via e-mail
- account deletion
Firebase Authentication stores user information such as UID, email, name and password hash. In order to store additional information such as user preferences and group, when a new user signs up, a corresponding document is created in the users collection.
This service automatically handles the authentication tokens and enforces security rules, which is particularly useful for an open-source application which users can fiddle with, such as ours. For example, multiple failed authentication attempts lead to a temporary timeout, and a user cannot delete their account unless they have logged in very recently (or refreshed their authentication token).
Firestore security rules can be enforced based on the user’s UID. This method means that, even though users can access the database connection string through the public repository, they can only do a limited set of actions on the database, depending on whether they are authenticated and their permissions.
Cloud Firestore is a noSQL database that organises its data in collections and documents.
Collections are simply a list of documents, where each document has an ID within the collection.
Documents are similar to a JSON file (or a C struct
, if you prefer), in that they contain different fields which have three important components: a name - what we use to refer to the field, similar to a dictionary key -, a type (which can be one of string
, number
, boolean
, map
, array
, null
- yeah null is its own type -, timestamp
, geopoint
, reference
- sort of like a pointer to another document), and the actual value, the data contained in the field.
In addition to fields, documents can contain collections… which contain other documents… which can contain collections, and so on and so forth, allowing us to create a hierarchical structure within the database.
More information about the Firestore data model can be found here.
Firestore allows for defining specific security rules for each collection. Rules can be applied for each different type of transaction - reads
(where single-document reads - get
- and queries - list
- can have different rules) and writes
(where create
, delete
and update
can be treated separately).
More information on Firestore security rules can be found here.
The project database contains the following collections:
users
This collection stores per-user data. The document key is the user's `uid` (from FirebaseAuth).All the documents in the collection share the same structure:
Field | Type | Required? | Additional info |
---|---|---|---|
group | string |
🗹 | e.g. “314CB” |
name | map<string, string> |
🗹 | keys are “first” and “last” |
permissionLevel | number |
☐ | a numeric value that defines what the user is allowed to do; if missing, it is treated as being equal to zero |
- websites
A user can define their own websites, that only they have access to. These will reside in the websites sub-collection, and have the following field structure, similar to the one in the root-level websites collection:
Field | Type | Required? | Additional info |
---|---|---|---|
category | string |
🗹 | one of: “learning”, “association”, “administrative”, “resource”, “other” |
icon | string |
☐ | path in Firebase Storage; if missing, it defaults to "icons/websites/globe.png" |
label | string |
🗹 | unless specified, the app sets this to be the link without the protocol |
link | string |
🗹 | it needs to include the protocol |
Anyone can create a new user (a new document in this collection) if the permissionLevel
of the created user is 0, null or not set at all.
Authenticated users can only read, delete and update their own document (including its subcollections) and no one else's. However, they cannot modify the permissionLevel
field.
websites
This collection stores useful websites, shown in the app under the *Portal* page. Who they are relevant for depends on the `degree` and `relevance` fields (for more information, see the filters collection).All the documents in the collection share the same structure:
Field | Type | Required? | Additional info |
---|---|---|---|
addedBy | string |
🗹 | ID of user who created this website |
category | string |
🗹 | one of: “learning”, “association”, “administrative”, “resource”, “other” |
degree | string |
⍰ | “BSc” or “MSc”, must be specified if relevance is not *null* |
editedBy | array<string> |
☐ | list of user IDs |
icon | string |
☐ | path in Firebase Storage; if missing, it defaults to "icons/websites/globe.png" |
label | string |
🗹 | unless specified, the app sets this to be the link without the protocol |
link | string |
🗹 | it needs to include the protocol |
relevance | null / list<string> |
🗹 | *null* if relevant for everyone, otherwise a string of filter node names |
Since websites in this collection are public information (anyone can read), altering and adding data here is a privilege and needs to be monitored, therefore anyone who wants to modify this data needs to be authenticated in the first place.
Users can create a new public website only if their permissionLevel
is equal to or greater than three and they sign the data by putting their uid
in the addedBy
field.
Users can update a website if they do not modify the addedBy
field and they sign the modification by adding their uid
at the end of the editedBy
list.
Users can only delete a website if they are the ones who created it (their uid
is equal to the addedBy
field) or if their permissionLevel
is equal to or greater than four.
filters
This collection storesFilter
objects.
These are basically trees with named nodes and levels. In the case of the relevance filter, they are meant to represent the way the University organises students:
All
_______________|_______________
/ \
BSc MSc // Degree
________|________ ________|__ …
/ \ / |
IS CTI IA SPRC … // Specialization
…|… ______|______ ⋮ ⋮
/ | | \
CTI-1 CTI-2 CTI-3 CTI-4 // Year
⋮ ⋮ __|… ⋮
/ |
3-CA 3-CB … // Series
__|…
/ |
331CA 332CA … // Group
All the documents in the collection share the same structure:
Field | Type | Required? | Additional info |
---|---|---|---|
levelNames | array<map<string, string>> |
🗹 | localized names for each tree level (e.g. "Year"); the map keys are the locale strings ("en", "ro") |
root | map<string, map<string, map<…>>> |
🗹 | nested map representing the tree structure, where the key is the name of the node and the value is a map of its children; the leaf nodes have an empty map as a value, **not** *null* or something else |
Filter structure is public information and should never (or very rarely) need to be modified, therefore for this collection, anyone can read but no one can write.
import_moodle
This collection contains class data imported directly from the University's Moodle instance. The data is exported as a spreadsheet from Moodle, and imported to our app's Firestore using a Node.js script. Additional information about classes is stored in the classes collection.The structure of the documents in the collection is the same as the columns in the export file:
All of the fields are strings
. In the app, shortname
is used to extract the class' acronym, fullname
is the class' name, and category_path
defines the category under which the class is listed on the ClassesPage.
This is public information already available on Moodle, and will never be editable directly through the app. Therefore for this collection, anyone can read but no one can write.
classes
This collection stores information about classes defined in the import_moodle collection. The ID of a document in this collection corresponds to the shortname of a document in import_moodle.All the documents in the collection share the same structure:
Field | Type | Required? | Additional info |
---|---|---|---|
grading | map<string, number> |
☐ | map where the key is the name of the evaluation (e.g. “Exam”) and the value is the number of points that specific evaluation weighs (generally out of 10 total) |
lecturer | string |
☐ | the ID of a person in the people collection |
shortcuts | array<map<string, string>> |
☐ | array of maps representing relevant links for a class, similar to websites; map keys are "addedBy", "link", "name", and "type", with "type" being one of "main", "classbook", "resource" and "other" |
Since classes in this collection are public information (anyone can read), altering and adding data here is a privilege and needs to be monitored, therefore anyone who wants to modify this data needs to be authenticated in the first place.
Users can update an existing class document if their permissionLevel
is equal to or greater than three. Additionally, they can only create a new class document if a document with that specific ID exists in the import_moodle collection.
Documents in this collection cannot be deleted.
people
This collection currently contains information about faculty staff, extracted from the official website using a Python scraper.All the documents in the collection share the same structure:
Field | Type | Required? | Additional info |
---|---|---|---|
string |
🗹 | ||
name | string |
🗹 | |
office | string |
🗹 | |
phone | string |
🗹 | |
photo | string |
🗹 | a link to the person's photo |
position | string |
🗹 | the person's position within the faculty, e.g. "Professor, Dr." |
This is public information already available on the official website, and currently cannot be edited through the app due to privacy concerns. Therefore for this collection, anyone can read but no one can write.
calendars
This collection stores academic calendar information, used to calculate recurrences of events in the events collection. This information is generally posted yearly on the university website (for example here for the year 2020).The ID of a document in this collection is the academic year it corresponds to (for academic year 2020-2021, the ID is simply "2020").
All the documents in the collection share the same structure:
Field | Type | Required? | Additional info |
---|---|---|---|
exams | array< event*> |
🗹 | exam sessions defined in the academic year |
holidays | array< event*> |
🗹 | holiday intervals defined in the academic year |
semester | array< event*> |
🗹 | semesters defined in the academic year, in chronological order |
*An event is a map<string, dynamic>
with the following fields:
Field | Type | Required? | Additional info |
---|---|---|---|
name | string |
⍰ | The name of the time interval defined in the calendar. This needs to be specified for everything but semesters, where the name is automatically set as the index of the event in the list (starting from 1). |
start | timestamp |
🗹 | The first day of the interval. The hour is irrelevant, it should be set to 00:00. |
end | timestamp |
🗹 | The last day of the interval. The hour is irrelevant, it should be set to 00:00. |
degree | string |
⍰ | “BSc” or “MSc”, must be specified if relevance is specified and not null |
relevance | null / list<string> |
☐ | null if relevant for everyone, otherwise a string of filter node names |
Academic calendars are public information and should never (or very rarely) need to be modified, therefore for this collection, anyone can read but no one can write.
events
This collection stores timetable events, shown in the app under the *Timetable* page.All the documents in the collection share the same structure:
Field | Type | Required? | Additional info |
---|---|---|---|
addedBy | string |
🗹 | ID of user who created this website |
editedBy | array<string> |
🗹 | list of user IDs of users who edited this event |
calendar | string |
🗹 | ID of a calendar in the calendars collection; it is used to calculate recurrences and skip holidays correctly |
class | string |
🗹 | ID of a class in the classes collection |
name | string |
☐ | Optional event name. If this is not specified, the class acronym will be used instead. |
start | timestamp |
🗹 | The first instance of the event. |
duration | map |
🗹 | A map containing duration keys like "hours", "minutes" etc. |
location | string |
☐ | Optional event location, if applicable. |
type | string |
🗹 | One of "lecture", "lab", "seminar", "sports", "other" |
rrule | string |
☐ | If the event repeats, a recurrence rule in the format defined in RFC-5543, for example "RRULE:FREQ=WEEKLY;UNTIL=20210131T000000;INTERVAL=2;BYDAY=TH" for an event that repeats every second Thursday until Jan 31st 2021 |
degree | string |
⍰ | “BSc” or “MSc”, must be specified if relevance is specified and not null |
relevance | null / list<string> |
☐ | null if relevant for everyone, otherwise a string of filter node names |
Since events in this collection are public information (anyone can read), altering and adding data here is a privilege and needs to be monitored, therefore anyone who wants to modify this data needs to be authenticated in the first place.
Users can create a new event only if their permissionLevel
is equal to or greater than three and they sign the data by putting their uid
in the addedBy
field.
Users can update an event if they do not modify the addedBy
field and they sign the modification by adding their uid
at the end of the editedBy
list.
Users can only delete an event if they are the ones who created it (their uid
is equal to the addedBy
field) or if their permissionLevel
is equal to or greater than four.
Cloud Storage complements Firestore by allowing storage of binary files, such as photos and videos.
The Cloud Storage is structured in directories and files (also referred to as objects), much like any other type of storage. These are placed inside a bucket - the basic container that holds data. You can think of buckets like a physical storage device - they have a location (and their own security rules and permissions), and unlike directories, cannot be nested.
The main bucket of the app (acs-upb-mobile.appspot.com
) can be accessed via the Firebase console.
It contains app resources such as icons and profile pictures, organised similarly to the data in Firestore:
- Website icons are stored in the
websites/
directory. The icon of a website in Firestore that has the ID "abcd" will be in storage underwebsites/abcd/icon.png
. - Profile pictures are stored in the
users/
directory. The picture of a user with the UID "abcd" would be in storage underusers/abcd/picture.png
.
Storage security rules are similar to Firestore security rules. One of the reasons for keeping the storage structure as close to possible to the Firestore structure is the ability to have similar security rules (for example, if, in Firestore, a user can only access their own document, the same rule can be applied for a user's folder inside Storage).
More information on Storage security rules can be found here.
Cloud Functions for Firebase is a serverless solution for running bits of code in response to events or at scheduled time intervals. They are, for all intents and purposes, JavaScript/TypeScript functions that run directly "in the cloud", without needing to be tied to an app or device.
The project currently has two functions set up to perform daily backups of the data in Firestore (backupFirestore) and Storage (backupStorage). They are scheduled to run automatically, every day at 00:00 EEST.
All strings that are visible to the user should be internationalised and set in the corresponding .arb
files within the l10n
folder. The Flutter Intl Android Studio plugin does all the hard work for you by generating the code when you save an .arb
file. Strings can then be accessed using S.current.stringID
.
In the database, internationalized strings are saved as a dictionary where the locale is the key:
{
'ro': 'Îmi place Flutter!',
'en': 'I like Flutter!'
}
These will have a corresponding Map
variable in the Dart code (e.g. Map<String, String> infoByLocale
). See WebsiteProvider
for a serialization/deserialization example.
Changing the app's language is done via the settings page.
The LocaleProvider
class offers utility methods for fetching the current locale string. See PortalPage
for a usage example.
If you need to use icons other than the ones provided by the
Material library or Feather Icons (accessible directly in the code through the Icons
and FeatherIcons
classes respectively), the process is as follows:
- Convert the
.ttf
custom font in the project to an.svg
font (using a tool such as this one). - Go to FlutterIcon and upload (drag & drop) the file you obtained earlier in order to import the icons.
- Check that the imported icons are the ones defined in the
CustomIcons
class to make sure nothing went wrong with the conversion, and select all of them. - (Upload and) select any additional icons that you want to use in the project, then click Download.
- Rename the font file in the archive downloaded earlier to
CustomIcons.ttf
and replace the custom font in the project. - Copy the IconData definitions from the
.dart
file in the archive and replace the corresponding definitions in theCustomIcons
class; - Check that everything still works correctly :)
Note: FontAwesome outline icons are recommended, where possible, because they are consistent with the overall style. For additional action icons check out FontAwesomeActions - the repo provides an .svg
font you can upload directly into FlutterIcon.