Roomy is a Flutter application for simplifying shared living through cleaning schedules, shopping lists, payments, and roommate communication.
| Layer | Technology | |-------|------------| | Framework | Flutter 3.8+ / Dart | | Auth | Firebase Authentication (kept — backend verifies Firebase JWTs) | | Backend (new) | Spring Boot 3 REST API — replaces Cloud Functions and Firestore | | Database | PostgreSQL (via Spring Data JPA + Flyway) | | File Storage | MinIO (S3-compatible, self-hosted) | | Legacy backend | Firebase (Firestore, Storage, Functions) — being phased out | | State | Informers (reactive primitives), Veto (MVVM) | | DI | GetIt (service locator) | | Routing | go_router (declarative navigation) | | UI | shadcn_ui, custom T-prefixed widgets | | Analytics | PostHog, Loglytics | | Persistence | Hive (local), PostgreSQL (remote via REST) |
The Spring Boot backend lives in backend/ and is being built progressively (issue #379).
backend/
├── src/main/java/app/roomy/backend/
│ ├── config/ # Security, JPA, MinIO, OpenAPI config
│ ├── controller/ # REST controllers (one per domain)
│ ├── dto/ # Request/response records
│ ├── entity/ # JPA entities (Lombok builder pattern)
│ ├── mapper/ # Entity ↔ DTO mappers
│ ├── repository/ # Spring Data JPA repositories
│ ├── security/ # Firebase JWT filter + auth provider
│ └── service/ # Business logic services
├── src/main/resources/
│ └── db/migration/ # Flyway migrations (V1–V9)
├── helm/roomy-backend/ # Kubernetes Helm chart
└── docker-compose.yml # Local dev (PostgreSQL + MinIO)
/api)| Domain | Endpoints |
|--------|-----------|
| Users | GET/POST /users, GET/PUT/DELETE /users/{id} |
| User Settings | GET/PUT /users/{id}/settings |
| Households | GET/POST /households, GET/PUT/DELETE /households/{id} |
| Household Members | POST /households/{id}/members/remove |
| Household Invites | CRUD at /households/invites, accept/decline/cancel actions |
| Cleaning Tasks | CRUD at /cleaning/tasks |
| Cleaning Time Slots | CRUD at /cleaning/timeslots |
| Shopping Lists | CRUD at /shopping/lists |
| Shopping List Items | CRUD at /shopping/items |
| Payments | CRUD at /payments |
| File Upload | POST /upload (MinIO) |
The Flutter app calls the backend via BackendApiService (lib/firebase/backend/services/).
Firebase Cloud Functions are still called for endpoints not yet migrated.
CloudFunctionId enum maps each operation to either the CF URL or the Spring Boot URL.
lib/
├── analytics/ # Analytics abstractions (TAnalytics base class)
├── auth/ # Authentication, user profiles, usernames
├── cleaning/ # Cleaning tasks, schedules, time slots
├── core/ # App initialization, LocatorService
├── data/ # Constants (k_*), extensions, globals (g_*)
├── environment/ # Environment configuration, Firebase options
├── feedback/ # In-app feedback system
├── firebase/ # RmyApi base, FirestoreCollection enum
├── forms/ # TFormConfig base classes, validators
├── generated/ # Code generation output (intl, build_runner)
├── households/ # Household management, invites, members
├── http/ # Connection service, URL launcher
├── inbox/ # Notifications, messages
├── l10n/ # Localization (.arb files)
├── notifications/ # Push notifications, FCM
├── payments/ # Payment tracking, splits
├── routing/ # go_router setup, navigation tabs
├── settings/ # App settings, preferences
├── shopping/ # Shopping lists and items
├── state/ # State utilities, BoxService, callbacks
├── storage/ # Firebase Storage, local storage
├── ui/ # Theme system, base widgets
└── main.dart # App entry point
Each feature follows a consistent internal structure:
lib/<feature>/
├── analytics/ # Feature-specific analytics (extends TAnalytics)
├── apis/ # Firestore APIs (extends RmyApi<T>)
├── data/ # Feature constants
├── dtos/ # Data Transfer Objects (json_serializable)
├── enums/ # Feature enums
├── extensions/ # Feature-specific extensions
├── forms/ # Form configs (extends TFormConfig)
├── models/ # UI models wrapping DTOs
├── routing/ # Feature router
├── services/ # Business logic services
├── views/ # Views and ViewModels (Veto pattern)
└── widgets/ # Feature-specific widgets
All classes use standardized section markers in this order:
// 📍 LOCATOR
// 🧩 DEPENDENCIES
// 🎬 INIT & DISPOSE
// 👂 LISTENERS
// ⚡️ OVERRIDES
// 🎩 STATE
// 🛠 UTIL
// 🧲 FETCHERS
// 🏗️ HELPERS
// 🪄 MUTATORS
Every injectable class follows the locator pattern:
class CleaningTasksService {
// 📍 LOCATOR
static CleaningTasksService get locate => GetIt.I.get();
static void registerLazySingleton() =>
GetIt.I.registerLazySingleton(CleaningTasksService.new);
// 🧩 DEPENDENCIES
final _api = CleaningTasksApi.locate;
final _householdService = HouseholdService.locate;
}
Registration types:
registerLazySingleton - Services (initialized on first use)registerSingleton - Services (initialized at app start)registerFactory - ViewModels, Forms, APIs (new instance per request)All registrations happen in lib/core/services/locator_service.dart.
Uses Informer<T> from the informers package:
class MyViewModel extends BaseViewModel {
// 🎩 STATE
final _items = ListInformer<ItemDto>([]);
final _isLoading = Informer<bool>(false);
// 🧲 FETCHERS (expose as immutable)
ValueListenable<List<ItemDto>> get items => _items;
ValueListenable<bool> get isLoading => _isLoading;
// 🪄 MUTATORS
void addItem(ItemDto item) {
_items.updateCurrent((current) {
current.add(item);
return current;
}, forceUpdate: true);
}
}
Informer<T> - Single valueListInformer<T> - List of valuesMapInformer<K, V> - Map of valuesforceUpdate: true for collections to notify listenersViews are stateless and connect to ViewModels via ViewModelBuilder:
// View (StatelessWidget)
class CleaningTasksView extends StatelessWidget {
static const path = 'cleaning';
@override
Widget build(BuildContext context) {
return ViewModelBuilder<CleaningTasksViewModel>.reactive(
viewModelBuilder: CleaningTasksViewModel.locate,
onViewModelReady: (vm) => vm.initialise(),
onDispose: (vm) => vm.dispose(),
builder: (context, vm, child) => TScaffold(
body: ValueListenableBuilder(
valueListenable: vm.tasks,
builder: (context, tasks, _) => TasksList(tasks: tasks),
),
),
);
}
}
// ViewModel (extends BaseViewModel)
class CleaningTasksViewModel extends BaseViewModel {
static CleaningTasksViewModel get locate => GetIt.I.get();
static void registerFactory() =>
GetIt.I.registerFactory(CleaningTasksViewModel.new);
@override
Future<void> initialise() async {
await _loadTasks();
super.initialise(); // Always last
}
@override
void dispose() {
_subscription?.cancel();
super.dispose(); // Always last
}
}
Three-layer architecture for data:
┌─────────────────────────────────────────────────────────┐
│ ViewModel │
│ - Consumes services │
│ - Manages UI state │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Service (TurboCollectionService / TurboDocumentService)│
│ - Business logic │
│ - Caches data (ID maps, sorted lists) │
│ - Optimistic updates │
│ - Streams from APIs │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ API (extends RmyApi<T>) │
│ - Low-level Firestore operations │
│ - CRUD, queries, streaming │
│ - Stateless │
└─────────────────────────────────────────────────────────┘
API Pattern:
class CleaningTasksApi extends RmyApi<CleaningTaskDto> {
CleaningTasksApi() : super(firestoreCollection: FirestoreCollection.cleaningTasks);
// 📍 LOCATOR
static CleaningTasksApi get locate => GetIt.I.get();
static void registerFactory() => GetIt.I.registerFactory(CleaningTasksApi.new);
}
FirestoreCollection Enum:
Central configuration for all collections in lib/firebase/firestore/enums/firestore_collection.dart:
enum FirestoreCollection {
users,
households,
cleaningTasks,
// ...
String path({String? householdId}) => /* collection path */;
FromJson<T> fromJson<T>() => /* DTO.fromJson */;
ToJson<T> toJson<T>() => /* DTO.toJsonFactory */;
}
Sync Service Pattern:
Services extend base classes for automatic syncing:
TurboCollectionService<T, API> - Collection syncTurboDocumentService<T, API> - Single document syncHouseholdCollectionSyncService<T, API> - Collection that re-syncs on household changeHouseholdDocumentSyncService<T, API> - Document that re-syncs on household changeForms extend TFormConfig with enum-based field definitions:
enum _ManageCleaningTaskFormField { name, description, taskSize }
class ManageCleaningTaskForm extends TFormConfig {
// 📍 LOCATOR
static ManageCleaningTaskForm get locate => GetIt.I.get();
static void registerFactory() => GetIt.I.registerFactory(ManageCleaningTaskForm.new);
// 🎩 STATE
@override
late final Map<Enum, TFormFieldConfig> formFieldConfigs = {
_ManageCleaningTaskFormField.name: TFormFieldConfig<String>(
id: _ManageCleaningTaskFormField.name,
fieldType: TFieldType.textInput,
valueValidator: kValueValidatorsRequired(errorText: () => gStrings.thisFieldIsRequired),
),
};
// 🧲 FETCHERS
TFormFieldConfig<String> get name => formFieldConfig(_ManageCleaningTaskFormField.name);
// 🎬 INIT & DISPOSE
void initialise({CleaningTaskDto? task}) {
if (task != null) {
name.silentUpdateValue(task.name);
}
}
}
Feature analytics extend TAnalytics:
class CleaningAnalytics extends TAnalytics {
static CleaningAnalytics locate({required String location}) =>
Loglytics.getAnalytics(location: location);
void cleaningTaskCreated({required CleaningTaskSize? taskSize}) => service.created(
subject: subjects.cleaningTask,
parameters: parameters(taskSize: taskSize?.name),
);
Map<String, dynamic> parameters({String? taskSize}) => {
if (taskSize != null) 'task_size': taskSize,
};
}
// Usage
final _analytics = CleaningAnalytics.locate(location: 'CleaningTasksService');
_analytics.cleaningTaskCreated(taskSize: task.taskSize);
Use TurboResponse<T> for operation outcomes:
Future<TurboResponse<HouseholdDto>> createHousehold() async {
try {
final result = await _api.create(dto);
return TurboResponse.success(result);
} catch (e, s) {
log.error('Failed to create household', error: e, stackTrace: s);
return TurboResponse.fail(
title: gStrings.error,
message: gStrings.failedToCreateHousehold,
);
}
}
User feedback:
gShowNotification() - Success confirmations, non-critical feedbackgShowOkDialog() - Critical errors requiring acknowledgmentUses go_router with StatefulShellRoute for tab navigation:
// Main navigation tabs
StatefulShellRoute.indexedStack(
branches: [
StatefulShellBranch(routes: [homeRouter]),
StatefulShellBranch(routes: [shoppingListRouter]),
StatefulShellBranch(routes: [cleaningRouter]),
StatefulShellBranch(routes: [paymentsRouter]),
],
);
// View path definition
class CleaningTasksView extends StatelessWidget {
static const path = 'cleaning';
// ...
}
// Navigation
context.go(CleaningTasksView.path.asRootPath);
context.push(ManageCleaningTaskView.path);
Use json_serializable with specific patterns:
@JsonSerializable()
class CleaningTaskDto implements TurboWriteableId<String> {
CleaningTaskDto({
required this.id,
required this.name,
this.description,
});
@override
final String id;
final String name;
final String? description;
factory CleaningTaskDto.fromJson(Map<String, dynamic> json) =>
_$CleaningTaskDtoFromJson(json);
Map<String, dynamic> toJson() => _$CleaningTaskDtoToJson(this);
static Map<String, dynamic> toJsonFactory(CleaningTaskDto dto) => dto.toJson();
}
Use switch expressions for exhaustive handling:
enum CleaningTaskSize { small, medium, large }
extension CleaningTaskSizeX on CleaningTaskSize {
String get label => switch (this) {
CleaningTaskSize.small => gStrings.small,
CleaningTaskSize.medium => gStrings.medium,
CleaningTaskSize.large => gStrings.large,
};
int get points => switch (this) {
CleaningTaskSize.small => 1,
CleaningTaskSize.medium => 2,
CleaningTaskSize.large => 3,
};
}
| Type | Convention | Example |
|------|------------|---------|
| Views | *View | CleaningTasksView |
| ViewModels | *ViewModel | CleaningTasksViewModel |
| Services | *Service | CleaningTasksService |
| APIs | *Api | CleaningTasksApi |
| DTOs | *Dto | CleaningTaskDto |
| Forms | *Form | ManageCleaningTaskForm |
| Analytics | *Analytics | CleaningAnalytics |
| Constants | k_* prefix | k_keys.dart, k_sizes.dart |
| Globals | g_* prefix | gStrings, gShowNotification |
lib/generated/ and *.g.dartlib/l10n/gStrings global or context.stringsflutter pub run intl_utils:generate after ARB changesAfter modifying DTOs or adding localization:
# DTOs and other generated code
flutter pub run build_runner build --delete-conflicting-outputs
# Localization
flutter pub run intl_utils:generate
┌────────────────────────────────────────────────────────────────┐
│ USER INTERFACE │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ View │ │ View │ │ View │ │ View │ │
│ │ (Home) │ │(Shopping)│ │(Cleaning)│ │(Payments)│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐ │
│ │ViewModel │ │ViewModel │ │ViewModel │ │ViewModel │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼───────────────┼───────────────┼───────────────┼────────┘
│ │ │ │
└───────────────┴───────┬───────┴───────────────┘
│
┌───────────────────────────────▼────────────────────────────────┐
│ SERVICES LAYER │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ HouseholdService│ │ CleaningService │ │ ShoppingService │ │
│ │(TurboDocument) │ │(TurboCollection)│ │(TurboCollection)│ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
└───────────┼────────────────────┼────────────────────┼──────────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌────────────────────────────────▼───────────────────────────────┐
│ API LAYER │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ HouseholdsApi │ │CleaningTasksApi │ │ShoppingListsApi │ │
│ │ (RmyApi<T>) │ │ (RmyApi<T>) │ │ (RmyApi<T>) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
└───────────┼────────────────────┼────────────────────┼──────────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌────────────▼────────────┐
│ FIREBASE FIRESTORE │
│ (FirestoreCollection) │
└─────────────────────────┘
test/ directory mirrors lib/ structure
Reacties