Athena — roomy-mobile/architecture.md

Architecture

Roomy is a Flutter application for simplifying shared living through cleaning schedules, shopping lists, payments, and roommate communication.

Tech Stack

| 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) |

Backend Architecture

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)

REST API surface (all under /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) |

Flutter ↔ Backend

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.

Migration status (as of 2026-04-14)

  • Milestone 1 Foundation — ✅ done
  • Milestone 2 Replace Cloud Functions — ✅ done (invites, member remove)
  • Milestone 3 PostgreSQL data layer — ✅ done (all 10 endpoint groups)
  • Milestone 4 WebSockets (real-time) — pending
  • Milestone 5 Storage (MinIO Flutter migration) — partially done (upload endpoint ready)
  • Milestone 6 Flutter full migration — in progress

Directory Structure

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

Feature Structure

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

Core Patterns

Class Section Markers

All classes use standardized section markers in this order:

// 📍 LOCATOR
// 🧩 DEPENDENCIES
// 🎬 INIT & DISPOSE
// 👂 LISTENERS
// ⚡️ OVERRIDES
// 🎩 STATE
// 🛠 UTIL
// 🧲 FETCHERS
// 🏗️ HELPERS
// 🪄 MUTATORS

Dependency Injection

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.

State Management

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 value
  • ListInformer<T> - List of values
  • MapInformer<K, V> - Map of values
  • Use forceUpdate: true for collections to notify listeners

MVVM Pattern (Veto)

Views 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
  }
}

Firestore Integration

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 sync
  • TurboDocumentService<T, API> - Single document sync
  • HouseholdCollectionSyncService<T, API> - Collection that re-syncs on household change
  • HouseholdDocumentSyncService<T, API> - Document that re-syncs on household change

Form Management

Forms 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);
    }
  }
}

Analytics Pattern

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);

Error Handling

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 feedback
  • gShowOkDialog() - Critical errors requiring acknowledgment

Routing

Uses 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);

DTOs

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();
}

Enums

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,
  };
}

Key Conventions

Naming

| 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 |

File Organization

  • One public class per file
  • Feature-first organization
  • Concept subfolders (apis/, services/, views/, etc.)
  • Generated files in lib/generated/ and *.g.dart

Localization

  • ARB files in lib/l10n/
  • Access via gStrings global or context.strings
  • Run flutter pub run intl_utils:generate after ARB changes

Code Generation

After 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

Data Flow

┌────────────────────────────────────────────────────────────────┐
│                         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)   │
                    └─────────────────────────┘

Testing Approach

  • No mocks: Test real implementations
  • Pure functions: Extract complex logic into testable pure functions
  • Firebase emulators: For integration tests
  • Focus: Business logic only, no UI tests
  • Location: test/ directory mirrors lib/ structure

Reacties

Nog geen reacties