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.

Zu lang - nicht gelesen

Bewegte Präsentation der App

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
Init-Screen Main-Screen 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

CO2-Monitor-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. 🥳