← Flutter / Dart

Flutter / Dart · 16 / 18

Eine kleine Fahrzeugakte von der UI bis zur Datenbank

Du verbindest Formular, Fachservice und echte lokale Speicherung zu einem geprüften Ablauf.

  • Groß
  • Anspruchsvoll
  • 120 min

Voraussetzungen: Unit- und Widget-Tests mit echten Erwartungen

Dieses größere Tutorial baut eine bewusst kleine Fahrzeugliste. Der Nutzer gibt einen Namen ein, der Service normalisiert ihn und ein Repository schreibt ihn in SQLite. Danach liest die Oberfläche den bestätigten Datenstand. Ein Test schließt die Datenbank und öffnet sie erneut. So ist die Persistenz tatsächlich nachgewiesen. Das Beispiel verwendet native SQLite und läuft auf Linux beziehungsweise in einer passenden nativen Flutter-App, nicht als Browser-App. Die vollständige Fahrzeugakte ergänzt unter anderem Einträge, Anhänge, Erinnerungen und Backups.

01Ein unabhängiges Lernprojekt aufsetzen

Erstelle flutter create --platforms=linux,android vehicle_learning. Ersetze pubspec.yaml und die angegebenen Dateien. Entferne nur den generierten test/widget_test.dart dieses neuen Lernprojekts, da er noch die Standard-App erwartet. Führe flutter pub get aus.

pubspec.yaml · yamlDatei öffnen
name: vehicle_learning
publish_to: none
environment:
  sdk: '>=3.11.0 <4.0.0'
dependencies:
  flutter:
    sdk: flutter
  drift: 2.33.0
  path_provider: 2.1.5
dev_dependencies:
  flutter_test:
    sdk: flutter
flutter:
  uses-material-design: true

Starten / prüfen

flutter pub get

02Vertrag und Speicherung aufbauen

VehicleRepository beschreibt save und all. VehicleDatabase implementiert ihn mit Drift. Um den Datenfluss ohne Codegenerator sichtbar zu halten, verwendet dieses Lernbeispiel ausdrücklich SQL statt generierter Tabellen. Die echte App nutzt Drift-Tabellen und umfangreichere Migrationen.

lib/store.dart · dartDatei öffnen
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';

class Vehicle {
  const Vehicle(this.id, this.name);
  final int id;
  final String name;
}

abstract interface class VehicleRepository {
  Future<void> save(String name);
  Future<List<Vehicle>> all();
}

class VehicleDatabase extends GeneratedDatabase implements VehicleRepository {
  VehicleDatabase(File file) : super(NativeDatabase(file));
  @override
  int get schemaVersion => 1;
  @override
  Iterable<TableInfo> get allTables => const [];
  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (_) async {
      await customStatement(
        'CREATE TABLE vehicles(id INTEGER PRIMARY KEY, name TEXT NOT NULL)',
      );
    },
  );
  @override
  Future<void> save(String name) =>
      customStatement('INSERT INTO vehicles(name) VALUES (?)', [name]);
  @override
  Future<List<Vehicle>> all() async =>
      (await customSelect('SELECT id,name FROM vehicles ORDER BY id').get())
          .map((row) => Vehicle(row.read<int>('id'), row.read<String>('name')))
          .toList();
}

class VehicleService {
  VehicleService(this.repository);
  final VehicleRepository repository;
  Future<void> create(String input) async {
    final name = input.trim();
    if (name.isEmpty) throw ArgumentError('Name required');
    await repository.save(name);
  }
}

03Die Fachregel im Service halten

Der Service akzeptiert keine leeren Namen. Das Repository kennt dagegen nur den Schreibvertrag. Die SQL-Abfrage bindet Werte getrennt. Die Oberfläche erhält nach erfolgreichem Schreiben die tatsächlichen Zeilen und erfindet keinen bestätigten Datensatz vor dem Commit.

04Entwurf und bestätigten Zustand verbinden

save sperrt den Button während der Aktion. Erst nach dem erfolgreichen Lesen wird der Entwurf geleert. mounted schützt Zustandsupdates nach dem Entfernen des Bildschirms. load behandelt auch den initialen Lesefehler. In einer größeren App gehört dieser Zustand in ein ViewModel oder einen Feature-Notifier.

lib/main.dart · dartDatei öffnen
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'store.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final directory = await getApplicationDocumentsDirectory();
  final db = VehicleDatabase(File('${directory.path}/vehicle_learning.sqlite'));
  runApp(MaterialApp(home: VehicleScreen(db: db)));
}

class VehicleScreen extends StatefulWidget {
  const VehicleScreen({required this.db, super.key});
  final VehicleDatabase db;
  @override
  State<VehicleScreen> createState() => _VehicleScreenState();
}

class _VehicleScreenState extends State<VehicleScreen> {
  final name = TextEditingController();
  List<Vehicle> vehicles = [];
  String error = '';
  bool busy = false;
  @override
  void initState() {
    super.initState();
    load();
  }

  Future<void> load() async {
    try {
      final rows = await widget.db.all();
      if (mounted) setState(() => vehicles = rows);
    } catch (_) {
      if (mounted) setState(() => error = 'Cannot load vehicles');
    }
  }

  Future<void> save() async {
    if (busy) return;
    setState(() {
      busy = true;
      error = '';
    });
    try {
      await VehicleService(widget.db).create(name.text);
      final rows = await widget.db.all();
      if (!mounted) return;
      name.clear();
      setState(() => vehicles = rows);
    } catch (_) {
      if (mounted) setState(() => error = 'Check the name or storage');
    } finally {
      if (mounted) setState(() => busy = false);
    }
  }

  @override
  void dispose() {
    name.dispose();
    widget.db.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('Vehicle learning')),
    body: Padding(
      padding: const EdgeInsets.all(24),
      child: Column(
        children: [
          TextField(
            controller: name,
            decoration: const InputDecoration(labelText: 'Name'),
          ),
          FilledButton(
            onPressed: busy ? null : save,
            child: const Text('Save'),
          ),
          Text(error),
          Expanded(
            child: ListView(
              children: [
                for (final car in vehicles) ListTile(title: Text(car.name)),
              ],
            ),
          ),
        ],
      ),
    ),
  );
}

Starten / prüfen

flutter run -d linux

05Persistenz über einen Neustart prüfen

Lege Family car an, beende die Lernapp und starte sie erneut. Der Eintrag muss bleiben. Der automatisierte Test erreicht dieselbe Grenze durch Schließen und erneutes Öffnen einer temporären Datenbank. Ein reiner MemoryRepository-Test könnte diese Garantie nicht beweisen.

test/store_test.dart · dartDatei öffnen
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../lib/store.dart';

void main() {
  test(
    'normalised vehicle survives reopening and blank input is rejected',
    () async {
      final directory = await Directory.systemTemp.createTemp(
        'vehicle_learning_',
      );
      final file = File('${directory.path}/data.sqlite');
      final first = VehicleDatabase(file);
      try {
        await VehicleService(first).create('  Family car  ');
        await expectLater(
          VehicleService(first).create('  '),
          throwsArgumentError,
        );
      } finally {
        await first.close();
      }
      final second = VehicleDatabase(file);
      try {
        final rows = await second.all();
        expect(rows, hasLength(1));
        expect(rows.single.name, 'Family car');
      } finally {
        await second.close();
        await directory.delete(recursive: true);
      }
    },
  );
}

Starten / prüfen

flutter test test/store_test.dart
Erwartetes Ergebnis

Ein normalisierter Name bleibt nach erneutem Öffnen erhalten. Leere Namen erzeugen keinen zweiten Datensatz.

06Mit Fahrzeugakte vergleichen

Öffne app_providers.dart, meter_services.dart und app_database.dart in der verlinkten Version. Dort verdrahtet Riverpod Dienste und Repositories. Datenbank und Dateien besitzen getrennte Grenzen. Ein weiterer Lernschritt wäre, den hier lokalen Bildschirmzustand in einen Riverpod-Notifier zu verschieben, ohne die Fachregel zu ändern.

Datenfluss
  1. Formularentwurf
  2. VehicleService
  3. VehicleRepository
  4. Drift / SQLite
  5. bestätigte Liste

Deine Übung

Ergänze eine Löschaktion anhand der ID. Prüfe, dass nach dem Löschen und erneutem Öffnen nur der ausgewählte Datensatz fehlt.

Lösung mit Erklärung anzeigen

Erweitere den Repository-Vertrag um delete(int id). Verwende DELETE FROM vehicles WHERE id = ? mit gebundenem Parameter. Der Service koordiniert die Aktion. Die Oberfläche lädt erst nach Erfolg neu. Ein Test mit zwei Fahrzeugen muss zeigen, dass nur die passende ID gelöscht wird. Bei späteren Anhängen wäre zusätzlich eine Referenz- und Dateibereinigungsregel nötig.

Projektbezug und Quellen

Das Beispiel ist zum Lernen vereinfacht. Die verlinkte Codeversion zeigt die Anwendung im Projekt und die dortigen Prüfungen.