13 — State Management (Organizations Module)
- 1. Cubit map
- 2. State machine (generic per 00-shared/06 §3.1)
- 3. OrgDetailCubit
- 4. OrgEditCubit
- 5. OrgSettingsCubit
- 6. FeatureFlagsCubit
- 7. TenantsCubit (
PaginatedListMixin<Organization>) - 8. Caching & staleness (module TTLs)
- 9. Realtime & cross-cubit
- 10. Testing hooks
Cubit architecture per 00-shared/06. One cubit per screen; base
LoadState(Initial/Loading/Success/Error(ApiException)),PaginatedListMixin, cache + SWR, optimistic updates, connectivity. All(proposed)client design.
1. Cubit map
| Cubit | Screen(s) | Data sources | Notes |
|---|---|---|---|
OrgDetailCubit | S1 Overview, S8 detail | E3 GET /organizations/:id | shared by self + platform (id from route/context); TTL 5 min SWR |
OrgEditCubit | S2 Edit, S3 Branding, S8 create/edit | E1/E4 + storage upload (planned) | form state not cached; submit → full DTO |
OrgSettingsCubit | S4 Settings tabs | E6 GET :id/settings + E7 PATCH; reference tabs E10 | full-object save; per-tab dirty flags |
FeatureFlagsCubit | S5 | E8 map + E11 catalog | merged view; optimistic toggle; rollback |
MembersCubit | S6 | E13 rbac endpoints + users module | invite/role/remove |
TenantsCubit | S7 | E2 list | PaginatedListMixin<Organization>; q/sort/filter state |
OnboardingCubit | global | org status + register | drives banner + registration flow (forward-looking) |
2. State machine (generic per 00-shared/06 §3.1)
stateDiagram-v2
[*] --> Initial
Initial --> Loading: Load
Loading --> Success: load(org) ok
Loading --> Error: ApiException
Error --> Loading: Retry
Success --> Success: Refresh / mutate (reconcile)
Success --> Error: mutation fails → rollback (toggle) or snackbar (form)
Success --> Loading: pull-to-refresh (bypass cache)
3. OrgDetailCubit
sealed class OrgDetailState {}
class OrgDetailInitial extends OrgDetailState {}
class OrgDetailLoading extends OrgDetailState {}
class OrgDetailSuccess extends OrgDetailState {
final Organization org; // domain model
final bool stale; // served from cache while revalidating
}
class OrgDetailError extends OrgDetailState { final ApiException e; }
Events: LoadOrg(id), RefreshOrg(), OrgUpdated(org) (post-save reconcile from Edit/Branding/Settings cubits via shared bus or direct repository cache invalidation — prefer cache-bust key + refetch).
Load flow: repo.getOrg(id) → cache hit (sl:{tenant}:org:{id}, TTL 5 min) → emit Success(stale:true) + background refetch → Success(stale:false). Miss → Loading → Success/Error.
Self-view (OQ-1): loadSelf() resolves id via (planned) /organizations/me; fallback (documented stopgap) list-lookup by slug — flagged in code comment.
4. OrgEditCubit
State: form (OrgFormModel), submitting, fieldErrors, conflicts {slug?, domain?}, saveResult.
Events: InitForm(org), FieldChanged(field, value), SlugPreview(name), Submit(), Discard().
Reducers: submit → submitting → on 200 Success (emit OrgUpdated for dependents) → snackbar; on 409 → conflicts[field] inline, no form reset; on 400 → fieldErrors mapped from envelope details[] (http-exception.filter.ts:103-107).
sequenceDiagram
participant W as OrgEditCubit
participant R as OrganizationsRepository
participant API as PATCH /organizations/:id
W->>W: Submit()
W->>R: update(id, dto)
alt 200
API-->>R: updated doc
R-->>W: Success(org)
W-->>UI: snackbar + OrgUpdated
else 409
R-->>W: ApiException(code=DUPLICATE_RESOURCE)
W-->>UI: conflicts[field] + scroll-to-field
else 400
W-->>UI: fieldErrors from details[]
end
5. OrgSettingsCubit
State: initial (per tab), dirty {attendance, academic, theme} (bitmap), saving, error.
Events: LoadSettings(id), TabChanged(tab), FieldChanged(tab, field, value), SaveAll(), RevertTab(tab).
Key reducer: SaveAll() always serializes the merged full object from lastServerSettings + dirty fields (full-replace safety — organizations.service.ts:134). Success → clear dirty + snackbar. Failure → keep dirty + error snackbar (retry safe).
stateDiagram-v2
[*] --> Idle
Idle --> Loading: LoadSettings
Loading --> Ready: settings fetched
Ready --> Ready: FieldChanged (dirty[tab]=true)
Ready --> Saving: SaveAll
Saving --> Ready: 200 → dirty cleared, snackbar
Saving --> Ready: 400 → field errors on tab (dirty kept)
Saving --> Ready: network → error snackbar + Retry
6. FeatureFlagsCubit
State: flags: Map<String, FlagView> where FlagView {key, label?, description?, module?, enabled, pending}; loading, error.
Events: Load(), Toggle(key), Retry(key), DeleteFlag(key).
Optimistic flow: Toggle → set enabled=!enabled, pending=true → repo setFlagMap(fullNewMap) (E9) → on 200 reconcile + pending=false + lightImpact; on error → rollback + pending=false + error snackbar with Retry (retry re-submits last-intended map). Map = merge(catalog keys, server map keys).
stateDiagram-v2
[*] --> Idle
Idle --> Loading: Load
Loading --> Ready: map + catalog merged
Ready --> PendingToggle: Toggle(key)
PendingToggle --> Ready: 200 reconcile
PendingToggle --> Ready: error → rollback + snackbar(Retry)
7. TenantsCubit (PaginatedListMixin<Organization>)
State: page (1), limit (20), sort (-createdAt), q, statusFilter (proposed), items, hasNext, isLoadingMore, loadState.
Events: LoadFirst(), LoadMore(), Search(q), ChangeSort(sort), ChangeFilter(status), Refresh(), TenantDeleted(id) (remove row + refetch meta).
Contract mirrors API exactly (pagination-query.dto.ts:5-54): on Search/ChangeSort/Filter → reset page=1, clear items, fetch; hasNext drives infinite scroll; Refresh bypasses cache.
sequenceDiagram
participant C as TenantsCubit
participant R as OrganizationsRepository
participant API as GET /organizations?page&limit&sort&q
C->>C: Search("spring")
C->>R: list(page:1, q:"spring", sort:"-createdAt")
API-->>R: data[] + meta {totalItems,totalPages,hasNext,...}
R-->>C: Success(items, meta)
C->>C: hasNext=true → LoadMore on scroll bottom
C->>R: list(page:2, ...)
8. Caching & staleness (module TTLs)
| Data | Cache key | TTL | Notes |
|---|---|---|---|
| Org detail | sl:{tenant}:org:{id} | 5 min SWR | RefreshIndicator bypasses |
| Tenants page | sl:{tenant}:orgs:{page}:{limit}:{sort}:{q} | 5 min | full query key (includes filter (proposed)) |
| Settings (embedded) | no client cache | — | form data; refetch on entry |
| Flag map | sl:{tenant}:flags | 5 min | invalidated on toggle success |
| Flag catalog | sl:{tenant}:flag-catalog | 24 h (reference) | labels/descriptions |
| Members | sl:{tenant}:members | 5 min | invalidated on invite/role change |
9. Realtime & cross-cubit
- No org-config WS topics today;
(forward-looking): subscribeorg.branding.updated,org.feature-flags.updated→ invalidate caches + refetch (B2: WS protocol unverified). - Cross-cubit invalidation: after E4/E7/E9 success → bump
org:{id}cache version; Overview re-fetches on focus (AppLifecycle/route pop). FeatureFlagsCubitis the module-level toggle source for other modules' gating (00-shared/06 §4).
10. Testing hooks
- Pure-Dart cubits, mocked repositories; widget tests per state machine (Loading/Success/Error/Empty) + optimistic rollback (
00-shared/06 §6). - Golden: full-object-save form with 3 tabs dirty states.