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.
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 get02Vertrag 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.
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.
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 linux05Persistenz ü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.
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.dartEin 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.
- Formularentwurf
- VehicleService
- VehicleRepository
- Drift / SQLite
- 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.