A production-grade Employee CRUD app built with Flutter, SQLite and Bloc, developed with strict test-driven development.
Features · Screenshots · Quick start · Architecture · Testing · Implementation details
Staffly is a Flutter app for managing employee records. It stores each employee's full name, job title, country and salary in a local SQLite database, and supports create, read, update and delete.
It is built to production standards rather than as a demo:
- Strict TDD. Every behaviour started as a failing test. The commit history shows the red → green → refactor loop as separate commits.
- Layered architecture. Data → Repository → State management → UI, built in that order, with the boundaries enforced by package dependencies.
- Money handled correctly. Salaries are stored as integer minor units (paisa), never as
double. - 100% test coverage, enforced in CI by Very Good Workflows.
- 📋 Employee list: cards showing each employee's name, job title, country and formatted salary
- 🔍 Instant search: filter by name, job title or country as you type
- ➕ Add employee: a modal form with field-level validation
- ✏️ Edit employee: the form is pre-filled, and the route (
/upsert/:employeeId) works as a deep link - 🗑️ Delete employee: asks for confirmation before deleting
- 💾 Offline persistence: all records are saved to SQLite on the device
- 🌐 Localized strings: all UI text comes from ARB files via
flutter_localizations - 🧪 Three flavors: development, staging and production
![]() Employee list |
![]() Add employee |
![]() Edit employee |
![]() Delete confirmation |
![]() Empty state |
![]() No search results |
| Concern | Choice |
|---|---|
| Framework | Flutter 3.44 · Dart 3.12 |
| State management | bloc / flutter_bloc |
| Local database | sqflite (SQLite) |
| Routing | go_router |
| Form validation | formz |
| Value equality | equatable |
| Testing | flutter_test, bloc_test, mocktail, sqflite_common_ffi |
| Linting | very_good_analysis, bloc_lint |
| Scaffolding & CI | Very Good CLI, Very Good Workflows |
- Flutter SDK
^3.44.0(Dart^3.12.0) - An Android emulator, iOS simulator or physical device
- Optional: Very Good CLI (
dart pub global activate very_good_cli)
git clone https://github.com/ArunChapagain/flutter-kata.git
cd flutter-kata/staffly
flutter pub get
flutter run --flavor development --target lib/main_development.dartOther flavors:
flutter run --flavor staging --target lib/main_staging.dart
flutter run --flavor production --target lib/main_production.dartFor git hooks, translations and other developer setup, see staffly/README.md.
Four layers, grouped into the classic data and presentation halves:
staffly/
├── packages/
│ ├── employee_api/ [Dart-only] Data layer
│ │ └── EmployeeApi (abstract) · SqliteEmployeeApi · EmployeeRecord
│ ├── employee_repository/ [Dart-only] Repository layer
│ │ └── EmployeeRepository · Employee · Salary
│ └── app_ui/ [Flutter] Design system
│ └── AppTheme · AppSpacing · AppColors · shared primitives
└── lib/
├── employees/
│ ├── bloc/ List state: EmployeesBloc
│ ├── cubit/ Form state: EmployeeUpsertCubit
│ ├── models/ formz inputs (FullName, JobTitle, …)
│ ├── view/ Presentation layer
│ └── widgets/ EmployeeCard, upsert form
└── app/ · bootstrap.dart · main_*.dart Composition root
Dependency direction is enforced by pubspec.yaml, not by convention:
lib/employees/view → lib/employees/bloc → employee_repository → employee_api
↘ app_ui
flowchart LR
UI["UI<br/>(pages & widgets)"] --> SM["State management<br/>(EmployeesBloc · EmployeeUpsertCubit)"]
SM --> REPO["employee_repository<br/>(Employee · Salary)"]
REPO --> API["employee_api<br/>(SqliteEmployeeApi)"]
API --> DB[("SQLite")]
UI --> UIKIT["app_ui<br/>(theme & primitives)"]
Each package is tested on its own. The data layer runs against a real SQLite database through sqflite_common_ffi, so the database tests run under plain dart test without a device.
# Everything, recursively, with coverage
cd staffly
very_good test --recursive --coverage --test-randomize-ordering-seed random
# A single package
cd staffly/packages/employee_api && dart test
# Static analysis and formatting
cd staffly
dart analyze --fatal-infos
dart format --set-exit-if-changed .CI (.github/workflows/main.yaml) runs analysis, bloc_lint, the tests with a 100% coverage threshold for the app and every package, a spell check on the Markdown files, and a license check on dependencies.
The git log records each cycle as separate commits:
git log --oneline --reversetest(<scope>): …is a red commit that adds failing testsfeat(<scope>): …is the green commit that makes them passrefactor(<scope>): …is a cleanup commit, made only while the tests are green
Due to time constraints, database search and pagination have not been implemented. Implementing them at the database and API level would be best for scaling and performance.
Constructor injection everywhere; MultiRepositoryProvider to expose repositories to the widget tree. This allows testing with real SQLite via sqflite_common_ffi simply by injecting a different DatabaseFactory.
Hand-written value objects extending Equatable. Keeps code size small and the red/green TDD loop clean without generated code overhead.
Persisted as INTEGER paisa; modelled as a Salary value object; parsed and formatted at the domain boundary. Salary owns both directions (rupees string in, grouped string out, always-2dp string for editing out, integer for storage out). No floating point arithmetic is used to avoid precision defects.
EmployeeRecord (data layer, storage shape) and Employee (domain shape), with a tested mapper between them.
employee_api exports both the EmployeeApi interface and SqliteEmployeeApi.
app_ui contains only generic primitives. Domain-specific widgets like EmployeeCard live in lib/employees/widgets/.
String id, generated in EmployeeRepository via an injected generator defaulting to const Uuid().v4.
Each form field is a FormzInput subclass; EmployeeUpsertState mixes in FormzMixin. This allows for pure unit tests for validation logic.
EmployeeUpsertCubit performs create and update against the repository. On success the view pops the modal and the list dispatches EmployeesFetchRequested. Delete stays on EmployeesBloc.
To ensure the app is deeplinkable, routes do not require passing full objects via the router's extra state. The edit screen (/upsert/:employeeId) accepts an ID and relies on FetchEmployeeBloc to load the Employee model from storage before seeding the form cubit.
Expected failures return (Salary?, formz validation errors); exceptional failures throw typed exceptions from a sealed base.
Where and how AI was used on this project — tools, prompts, and rationale — is logged incrementally in docs/ai-usage.md.
Released under the MIT License.





