Heute möchte ich meine co2monitor.app vorstellen, die ich passend zu meinem co2monitor.api-Projekt geschrieben habe, um die aufgezeichneten und gespeicherten Messwerte zu visualisieren. Ich habe mich für Flutter entschieden, da ich es mit Dart für ein tolles Gesamtpaket halte, mit dem man plattformübergreifende Apps schreiben kann. Die App ruft die REST-Schnittstelle der co2monitor.api ab und stellt die Messwerte in zwei Diagrammen dar. Ausserdem bietet sie eine Konfigurationsseite, um die Verbindung zur API einzustellen.
Das Grundgerüst
Wie schon gesagt, habe ich mich für Flutter und Dart als Grundlage entschieden. Die wichtigsten Bibliotheken sind wahrscheinlich Riverpod für die Zustandsverwaltung, http für die Abfrage der API, fl_chart für das Erstellen der Diagramme und shared_preferences für die Speicherung der Nutzereinstellungen. Um die API-Antworten von JSON in Dart-Objekte zu konvertieren, habe ich die quicktype verwendet.
Die Provider
Riverpod bietet die Möglichkeit, Provider mit und ohne Codegenerierung zu erstellen. Ich habe beide Varianten ausprobiert, was im Code auch zu sehen ist. Im Endeffekt finde ich die Codegenerierung etwas unübersichtlicher und werde sie in Zukunft wahrscheinlich erst mal nicht mehr anwenden. Die Autoren von Riverpod schreiben selber, dass das Dart Team an der Implementierung der Codegenerierung noch arbeitet, also mal abwarten, was da noch kommt.
Aus diesem Grund möchte ich hier nur auf den InitStateProvider
eingehen. Dieser Provider nutzt den AsyncNotifierProvider
und wird in der main-Methode aufgerufen. Er ist dafür zuständig, die Nutzereinstellungen zu laden und die Verbindungsdaten zu API abzurufen bzw. abzuspeichern. Hier einmal die setInitState
-Funktion, die die Nutzereinstellungen mittels shared_preferences abspeichert.
Future<void> setInitState(bool wasInit, String apiUrl, String apiKey) async {
var value = InitState(
wasInit: wasInit,
apiUrl: apiUrl,
apiKey: apiKey,
);
final prefs = await SharedPreferences.getInstance();
prefs.setBool(prefKeyWasInit, wasInit);
prefs.setString(prefKeyApiUrl, apiUrl);
prefs.setString(prefKeyApiKey, apiKey);
state = AsyncData(value);
}
Die Widgets
Konfigurationsseite | Hauptseite | Drawer |
---|---|---|
Es gibt die folgenden Widgets:
- Die Konfigurationsseite, auf der die Verbindungsdaten zur API eingestellt werden können
- Die Hauptseite, auf der die Diagramme angezeigt werden
- Der Drawer, der die Navigation zwischen den Seiten ermöglicht und die Einstellungen bzg. des abzufragenden Zeitintervalls
Die Konfigurationsseite
Die Konfigurationsseite ist ein ConsumerStatefulWidget
von Riverpod, da die Nutzereingaben gespeichert und an den InitStateProvider
übergeben werden müssen. Die Eingaben werden in einem Form
-Widget gesammelt und mit einem TextFormField
eingelesen. Die Eingaben werden mit dem onSaved
-Callback gespeichert. Die _saveForm
-Funktion wird aufgerufen, wenn der Save
-Button gedrückt wird. Sie ruft die setInitState
-Funktion des InitStateProvider
auf, um die Nutzereinstellungen zu speichern. Damit der API-Key nicht im Klartext angezeigt wird, wird der obscureText
-Parameter des TextFormField
auf true
gesetzt. Der Toggle
-Button ruft die _toggleObscureText
-Funktion auf, die den obscureText
-Parameter umkehrt und so die Überprüfung des API-Keys ermöglicht.
class _InitScreenState extends ConsumerState<InitScreen> {
final _formKey = GlobalKey<FormState>();
String _apiKey = "";
String _apiUrl = "";
bool _obscureText = true;
void _saveForm() {
final isValid = _formKey.currentState!.validate();
if (!isValid) {
return;
}
_formKey.currentState!.save();
ref.read(initStateProvider.notifier).setInitState(true, _apiUrl, _apiKey);
}
void _toggleObscureText() {
setState(() {
_obscureText = !_obscureText;
});
}
@override
Widget build(BuildContext context) {
\\ ... Widget tree
}
Die Hauptseite und die Diagramme
Die Hauptseite wie auch die Diagramme sind jeweils ein ConsumerStatefulWidget
von Riverpod, da die Diagramme die Daten der API visualisieren müssen und die Daten stetig aktualisiert werden. Beim Initialisieren der Hauptseite wird zuerst das Theme abgefragt und gesetzt. Das Theme lässt sich mittels Button und onTabTheme
-Funktion von dunkel zu hell und umgekehrt schalten. Die Daten können mittels _onTabRefresh
erneut abgerufen werden. Der Status der Abfrage wird über eine Snackbar angezeigt.
```dart
class _MainScreenState extends ConsumerState<MainScreen> {
@override
void initState() {
super.initState();
ref.read(themeNotifierProvider);
}
void onTabTheme(MyTheme theme) {
ref.read(themeNotifierProvider.notifier).setTheme(!theme.isDark);
}
Future<void> _onTabRefresh(BuildContext context) async {
final messageProv = ref.read(messageStateNotifierProvider.notifier);
messageProv.showLoadingSnackBar(context);
try {
await ref.read(dataProvider.notifier).getData();
if (context.mounted) {
messageProv.showSuccessSnackBar(context, "Data successfully refreshed");
}
} catch (err) {
if (context.mounted) {
messageProv.showErrorSnackBar(
context, "Data could not be loaded.\n$err");
}
} finally {
if (context.mounted) {
messageProv.hideSnackBar(context);
}
}
}
@override
Widget build(BuildContext context) {
final themeProv = ref.watch(themeNotifierProvider);
// ... Widget tree
}
Die Diagramme sind in einem ListView
angeordnet, damit sie auch auf kleineren Bildschirmen angezeigt werden können. Die Daten werden über die dataProvider
von Riverpod abgefragt. Die dataProvider
ist ein FutureProvider
, der die Daten von der API abfragt und in ein AsyncValue
-Objekt speichert. Das AsyncValue
-Objekt kann drei Zustände annehmen: AsyncLoading
, AsyncError
und AsyncData
. Je nach Zustand wird ein CircularProgressIndicator
, eine Fehlermeldung oder die Diagramme angezeigt. Die Daten werden minütlich aktualisiert, indem ein Timer
gestartet wird, der die Daten erneut abfragt. Wichtig ist hier die dispose
-Funktion, die den Timer stoppt, wenn die Seite nicht mehr angezeigt wird.
class LineChartScreenState extends ConsumerState<LineChartScreen> {
Timer? _timer;
@override
void initState() {
super.initState();
ref.read(periodNotifierProvider);
}
Future<void> fetchLatestDataEveryMinute(BuildContext context) async {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 60), (_) async {
final latestData =
await ref.watch(latestDataProvider.notifier).updateLatestData();
await ref.watch(dataProvider.notifier).updateData(latestData);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final dataProv = ref.watch(dataProvider);
final latestDataProv = ref.watch(latestDataProvider);
final dateFormatter = ref.watch(dateFormatterProvider);
final periodProv = ref.watch(periodNotifierProvider);
fetchLatestDataEveryMinute(context);
return dataProv.when(
data: (data) => ListView(
children: <Widget>[
latestDataProv.when(
data: (latestData) => Center(
// ... some Text
),
),
Co2LineChartWidget(data),
Divider(
color: Theme.of(context).colorScheme.outline,
),
TempLineChartWidget(data),
],
),
error: (err, _) => Center(
child: Text("Data could not be loaded: \n$err"),
),
loading: () => Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}
Der Drawer
Der Drawer bietet die Möglichkeit, den Zeitraum für die Abfrage der Daten einzustellen. Hier stehen hartkodiert eine, drei, sechs, zwölf, 24 und 48 Stunden zur Auswahl. Auch kann hier eine Neuinitialisierung der App durchgeführt werden, indem auf den “Reset Init State”-Button gedrückt wird. Die resetInitState
-Funktion des InitStateProvider
wird aufgerufen, um die Nutzereinstellungen zurückzusetzen.
Die CI/CD Pipeline
Ich lasse die App mittels GitHub-Actions automatisch bauen. Dafür habe ich eine Workflow-Datei erstellt, die Ubuntu als Grundlage nutzt, dann Flutter und Dart installiert und die App baut. Die App wird dann als Release auf GitHub veröffentlicht. Dafür war ein GitHub Repository Secret notwendig, das über das Repository -> Settings -> Secrets and variables -> Actions erstellt werden kann. Die App wird nur gebaut, wenn ein neuer Tag erstellt wird.
Die Workflow-Datei sieht wie folgt aus:
name: Build and Release Flutter App
on:
push:
tags:
- "v*.*.*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.SUPERSECRET }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v2
with:
distribution: "zulu"
java-version: "11"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: "3.13.6"
- run: flutter pub get
- run: flutter build apk --release
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
build/app/outputs/flutter-apk/app-release.apk
tag_name: ${{ github.ref }}
token: ${{ secrets.SUPERSECRET }}
Präsentation der App
Fazit
Ich habe mit Flutter eine App geschrieben, die die Daten meines CO2-Monitors visualisiert. Das Abrufen und minütliche Aktualisieren klappt einwandfrei, auch wird die App automatisch beim erstellen eines neuen Tags gebaut. Es gibt noch ein paar Anpassungen wie die Auswahl des Standorts oder das Anpassen aller Provider an das gleiche Schema. Dennoch bin ich mit dem Ergebnis sehr zufrieden. 🥳