This larger tutorial builds a deliberately small vehicle list. The user enters a name, the service normalises it and a repository writes it into SQLite. The interface then reads confirmed data. A test closes and reopens the database, providing actual persistence evidence. The example uses native SQLite and runs on Linux or in a compatible native Flutter app, not as a browser app. The complete Vehicle File additionally includes records, attachments, reminders and backups.
01Set up an independent learning project
Run flutter create --platforms=linux,android vehicle_learning. Replace pubspec.yaml and the specified files. Remove only the generated test/widget_test.dart in this new learning project because it still expects the default app. Run flutter pub get.
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
Run / check
flutter pub get02Build the contract and persistence
VehicleRepository describes save and all. VehicleDatabase implements it with Drift. To expose the data flow without a generator, this example deliberately uses SQL rather than generated tables. The real app uses Drift tables and more extensive migrations.
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);
}
}
03Keep the domain rule in the service
The service rejects blank names. The repository only knows the write contract. SQL binds values separately. After successful writing, the interface receives actual rows rather than inventing confirmed records before commit.
04Connect draft and confirmed state
save disables the button during the action. The draft is cleared only after successful reading. mounted prevents state updates after the screen is removed. load also handles initial read failures. A larger app puts this state in a view model or 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)),
],
),
),
],
),
),
);
}
Run / check
flutter run -d linux05Verify persistence across restart
Create Family car, stop the learning app and start it again. The record must remain. The automated test reaches the same boundary by closing and reopening a temporary database. A MemoryRepository-only test could not prove that guarantee.
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);
}
},
);
}
Run / check
flutter test test/store_test.dartA normalised name survives reopening. Blank names create no second record.
06Compare with Vehicle File
Open app_providers.dart, meter_services.dart and app_database.dart at the linked version. Riverpod wires services and repositories there. Database and files have separate boundaries. A further learning step is moving this screen-local state into a Riverpod notifier without changing the domain rule.
- Form draft
- VehicleService
- VehicleRepository
- Drift / SQLite
- confirmed list
Your exercise
Add a delete action using the ID. Verify that after deletion and reopening only the selected record is missing.
Show the solution and explanation
Extend the repository contract with delete(int id). Use DELETE FROM vehicles WHERE id = ? with a bound parameter. The service coordinates the action. The interface reloads only after success. A two-vehicle test must show that only the matching ID is deleted. Later attachments would additionally require reference and file-cleanup rules.
Project context and sources
The example is simplified for learning. The linked source version shows its use in the project and the checks applied there.