My third complete rewrite of the Quickshop app. Using the following stack:
- Global state: Riverpod
- Navigation: Go Router
- Authentication: Firebase Authentication
- Data sync: Firestore
- Local data persistance: Drift
- Local preferences data: Shared preferences
- HTTP: Plain old http
- UI Theme: Material 3
services: Services are wrappers around accessing external systems like databases, HTTP calls, and authentication.data: Contains all data layer behaviour, including data models, business logic, caching, retrieval and local persistance. Thedatafolder contains subfolders for different data categories/features. Within each feature there may be the following folders:models: Data models used by the feature. All models use freezed for immutability and equality generation.repositories: Repositories use services to fetch, query, and update data models. There is generally one repository per data model type. They do not typically maintain any public state, and do not communicate with each other, as orchestration and caching should be performed in view models or application-level notifiers. Where applicable, repositories may return a stream of data for read operations that emits new values as the data is updated. The purpose of repositories is to be lightweight wrappers around backing data services, handling data transformations and simplifying unit testing by providing a clean interface to mock compared to the typically more complex interfaces of the data services themselves.application: The application folder is the data coordiantion and caching layer. It contains classes that coordinate between repositories, and that maintain application-wide (global) state for data types where the dataset is used across multiple pages and/or is small enough to be cached in memory.- Notifiers: For each such data type there is a single Riverpod
notifierwhich maintains the in-memory cache source of truth for that data type in the app. All write operations should go through the notifier rather than the repository, as this allows the notifier to make optimistic, synchronous updates to the in-memory state before asynchronously persisting the update via the repository. - Providers: For use-cases including: read-only data from repositories, a filtered subset of notifier data, or transformation of notifier data; then the application folder may contain a Riverpod
provideras a live-updating read-only view of that data. However, unless the read-only view is required by multiple screens, such behaviour is typically better suited to view models. - Side Effects: Write operations spanning multiple data types are handled by one notifier acting as the initiator and orchestrator, calling methods on other notifiers to inform them of the update. If a transaction is required, the initiating notifier is reponsible for creating the transaction and passing it to the others for them to pass down to their repositories.
- Use Cases: Use cases are used to define coordination logic between repositories without in-memory caching of the results. E.g. aggregation of queries for datasets too large to fully cache, or observing one dataset to trigger pre-emptive loads of another dataset.
- Notifiers: For each such data type there is a single Riverpod
database: Stores local SQLite / Drift Database table schemas, and Data Access Objects (DAOs) encapsulating DB query/update operations.
pages: Pages define top-level widgets within the app; a page is either a fullscreen widget, or a widget which fills the contents of a tabbed view.- View models: View models are co-located with pages, and are responsible for agregating and transforming data from application state and repositories. A view model should only be used by its page. Depending on the requirements of the page, the view model may be a read-only provider, or a mutable riverpod notifier.
widgets: Shared widgets re-used across multiple pages in the application
This project uses Flutter Version Manager (FVM) to allow using different flutter versions in different projects on a single machine.
- Install Flutter
- Install FVM
- Install the project specific flutter version by running
fvm installon the command line
FVM will download and install the Flutter SDK version specified in the .fvmrc file, caching that Flutter version globally on your machine. It then creates a folder .fvm with symbolic links to the global install location. The settings in .vscode/settings.json specify that the Flutter extension for VSCode should use the Flutter SDK installed by FVM.
Android Studio 2024.2.1 started using Open JDK version 21 as its bundled java development kit. With Flutter 3.24.3 this produced compile errors similar to this issue upon adding the google sign in plugin.
Changes suggested in this comment resovled the issue by bumping gradle and java versions, so upgrading to at least Android Studio 2024.2.1 is advisable to prevent compilation issues.
Sensitive values are defined in a JSON file and passed to flutter build/run using a Dart define argument with --dart-define-from-file. These files, along with google-services.json files used to connect to Firebase projects, and keystores used to sign the application, are stored separately in the Quickshop 3 Secrets private repo
To run the app locally, the following files should be copied from the secrets repo:
- App secrets files of name pattern
app_secrets_<ENV>.jsonshould be copied into the/settingsfolder should - Google services json files should be copied into
/android/app/src/<ENV>folders for their respective environment - Debug and upload keystores and properties files should be copied into
/android/keystorefolder
The JSON secret files are stored as a single line string in Github Secrets due to issues with Github Actions unnecessarily censoring special characters if a secret contains formatted JSON: See here for details. This issue does not impact running locally, so it is safe to format the JSON file locally with newlines and indentation.
Google documentation states that Firebase API keys are safe to be included in code or checked in to source control: https://firebase.google.com/docs/projects/api-keys#general-info. Furthermore, in general a public application cannot be considered capable of keeping any values secret. A determined attacker will always be able to find a way to extract them, e.g. by decompiling the application, or by inspecting outbound network packets.
As such, it might seem pointless to designate any values as secret/sensitive in a public application. However, because this repo itself is public, these API keys and Firebase configuration values have been hidden to encourage anyone cloning the repo to run it against their own Firebase project.
The Quickshop backend comprises the a Firestore database and a collection of Firebase Functions. These can be emulated locally for debugging purposes - see the Quickshop Firebase project for details regarding the Firebase emulator.
To connect the Quickshop app to a local Firebase emulator:
-
Copy the
settings/app_settings_local_example.jsonfile tosettings/app_settings_local.json -
Setup local connection configuration:
- Android Physical Devices:
- Setup a reverse proxy using the android debug command
adb reverse tcp:8080 tcp:8080. This proxies any requests tolocalhost:8080on the Anroid device being debugged to port 8080 of the host computer. Port 8080 is the default port for the Firebase Firestore emulator. - Set the entry in the local settings JSON file:
"FIRESTORE_EMULATOR_HOST": "localhost:8080"
- Setup a reverse proxy using the android debug command
- Android Emulator:
- Set the entry in the local settings JSON file:
"FIRESTORE_EMULATOR_HOST": "10.0.2.2:8080"toapp_secrets_dev.json - This IP address automatically redirects requests on the Android emulator to the corresponding port on the host computer.
- Set the entry in the local settings JSON file:
- Screen Mirroring Physical Devices:
- Android emulators can be quite slow, but it is convenient to have the running app accessible on desktop to quickly type and control with a mouse.
- scrcpy is a tool which makes it easy to mirror the screen of a physical Android phone to any computer.
- Due to a bug in Flutter (fixed in master but not yet in stable as of 3.32), use
scrcpy --no-mouse-hoverto avoid exceptions. See: flutter/flutter#160144
- Android Physical Devices:
-
Run the "Local (debug)" launch configuration, which
To run Quickshop against another Firebase project, copy and rename the settings/app_secrets_example.json file to app_secrets_<ENV>.json file
Appropriate values can be generated by using the flutterfire CLI tool, and then copying the values from the generated <ENV>.dart file into the corresponding secrets file. This command will also produce the google-services.json file.
flutterfire configure --project=quickshop-ENV --out=lib/firebase/<ENV>.dart --android-package-name=com.buntagon.quickshop.ENV --android-out=android/app/src/<ENV>/google-services.json --platforms=android,web,windows
The firebaseGoogleAuthWebClientId can be found in the Firebase console as the "Web client ID" described here: https://github.com/firebase/FirebaseUI-Flutter/blob/main/docs/firebase-ui-auth/providers/oauth.md
Debug and upload keystores and corresponding properties files should be placed into the android/keystore folder. See here for a general description of the android app signing approach.
Drift is used for local storage of data on the device. This package is a wrapper around the native sqlite database capability provided in Android and iOS operating systems, with support for object-relational mapping (ORM) and query watching. Drift includes a built-in database inspector that can be used to inspect the contents of the app database. To use it:
- Start debugging the app in Visual Studio Code
- Press
CTRL + SHIFT + Pto bring up the command palete, and enterDart: Open DevTools in Browser - Select the
Drifttab within the devtools browser window
https://drift.simonbinder.eu/testing/
Install SQLite version as close as possible to that used by Drift. See https://pub.dev/packages/sqlite3_flutter_libs/changelog
SQLite download links are available here: https://www.sqlite.org/download.html. Note that the links to older versions of SQLite are not listed on this site, to encourage always using the latest version, but download links all have a consistent format. E.g. if the current link to download Windows binaries for version 3.50.4 is https://www.sqlite.org/2025/sqlite-dll-win-x64-3500400.zip, then to download the older version 3.50.3 simply change the 4 to a 3 https://www.sqlite.org/2025/sqlite-dll-win-x64-3500300.zip
The assets directory houses images, fonts, and any other files you want to
include with your application.
The assets/images directory contains resolution-aware
images.
The Image Asset Studio tool in Android Studio was used to import the SVG file of the app icon. The imported icon file did not have any padding around the icon. The import process in Android Studio generates WEBP files at each display density, although it can be configured to create PNG files instead. It also converts the SVG into an Android Vector Drawable format, adding sufficient padding so it can be used as an adaptive icon. Because the Quickshop icon silhouete is sufficiently distinct when converted to a single colour, android\app\src\main\res\mipmap-anydpi-v26\ic_launcher.xml was modified to set the monochrome icon to be the same as the adaptive icon foreground. To take advantage of the round versions of the legacy WEBP icons generated by the image asset tool, the line android:roundIcon="@mipmap/ic_launcher_round" was added to AndroidManifest.xml to specify the icon file name for Android operating systems that use round icons.
To add the app icons for the DEV flavor
- Define the flavor dimension and flavor in
android\app\build.gradle - Sync the project with gradle in Android Studio
- Create the
android\app\src\dev\resfolder - Import the dev icon by right clicking on the new
resfolder, selectingNew > Image Asset, then on the second screen use the drop down to selectdevas the source set for importing the icon
Immediately after launching a debug session the app icon displayed in the recent apps view may show a generic android icon instead of the proper app icon. This should resolve itself either after a device reboot or within 5-10 minutes once the OS refreshes its cached icons.
In order for the pipelines to be able to automatically tag commits when running, the workflow runner needs to be granted Read and write permissions to the repository in Github:
- Settings > Actions > General > Workflow permissions
- Select Read and write permissions and save
The following environment variables are set in each environment in Github Repo Settings > Environments:
- APP_SECRETS: The contents of the
app_secrets_<ENV>.jsonfile described above, with all newline characters removed - GOOGLE_SERVICES_JSON: The contents of the appropriate google services json file, located at
android/app/src/<ENV>/google-services.json, with all newline characters removed - FB_APP_DIST_CREDENTIALS: A google cloud service account JSON key granting permission to push app builds to firebase app distrubtion. See steps detailed here for creating a service account with
Firebase App Distribution Adminrole. Again, all newlines should be removed from the json key file before adding it as a secret in github actions - UPLOAD_KEYSTORE_B64: Base64 encoding of a
.jksjava keystore file used to sign the app. Stored in a separate private repository. - UPLOAD_KEYSTORE_PROPS_B64: Base64 encoding of a
.propertiesfile used to access the keystore. Git bash includes thebase64command which can be used for encoding files. - ANDROID_FIREBASE_APP_ID: Same as the
firebaseAndroidAppIdinapp_secrets_<ENV>.dart
Each commit on main branch runs flutter tests using the pipeline .github/workflows/test.yaml
To release to development:
- Manually run the
Dev - Build and Deploypipeline in github actions, passing in a semantic version number in format<MAJOR>.<MINOR>.<PATCH>- The source file for this pipeline
.github/workflows/build_and_deploy_dev.yamlpipeline
- The source file for this pipeline
Dev pipeline behaviour:
- Tag the current commit with a tag in format
dev/<MAJOR>.<MINOR>.<PATCH>-<BUILD_NUMBER> - Build the application with the semantic version and build number
There can be multiple dev releases with the same semantic version number which can be differentiated by the build number.
To release to production:
- On master, add an entry describing the changes in this release in
CHANGELOG.mdwith a semantic version number in format<MAJOR>.<MINOR>.<PATCH> - Manually run the
Prod - Build and Deploypipeline in github actions, supplying the semantic version number as an input property.- The source file for this pipeline is
github/workflows/build_and_deploy_prod.yaml
- The source file for this pipeline is
Release pipeline behaviour:
- Check for an existing git tag with that semantic version; fail if it already exists
- Otherwise tag the current commit with that version
- Build the application with the semantic version
This approach ensures there will be only a single production release build with a given semantic version number.
This project generates localized messages based on arb files found in
the lib/localization directory.
To support additional languages, please visit the tutorial on Internationalizing Flutter apps.