critical priority medium complexity frontend pending frontend specialist Tier 2

Acceptance Criteria

commitChanges() emits ChapterMembershipSaving, then on service success emits ChapterMembershipSuccess with the updated affiliations list
commitChanges() emits ChapterMembershipError with the appropriate failure type on any service exception; the state after error retains the pending lists so the user can retry
commitChanges() is a no-op (does not emit any state) if both pendingAddChapterIds and pendingRemoveAffiliationIds are empty
commitChanges() throws a StateError if called when the current state is not ChapterMembershipLoaded
rollbackChanges() clears both pending lists and emits ChapterMembershipLoaded with the original affiliations and empty pending lists
rollbackChanges() throws a StateError if called when the current state is not ChapterMembershipLoaded
5-chapter maximum enforced in trackPendingAdd (from task-002) AND as a pre-flight check in commitChanges(): if (currentAffiliations.length - pendingRemoves.length + pendingAdds.length) > 5, emit ChapterMembershipError(MaxChaptersExceeded) without calling the service
The MaxChaptersExceeded check uses net chapter count (additions minus removals), not gross additions, to allow a user to remove one and add two without being blocked at 4
After a successful commit, the Cubit state is ChapterMembershipSuccess — the consumer must call loadAffiliations() to return to a loaded state (explicit re-fetch, not automatic transition)
After a failed commit, pending lists are preserved so the user can correct and retry without re-entering their changes

Technical Requirements

frameworks
Flutter
BLoC (flutter_bloc)
Dart async/await
apis
MultiChapterMembershipService.applyAffiliationChanges() (or equivalent batch method)
data models
ChapterAffiliation
ChapterMembershipState
ChapterMembershipFailure (MaxChaptersExceeded variant)
performance requirements
commitChanges() must not block the UI thread — async execution with emit in try/catch
The 5-chapter net count calculation is O(1) arithmetic on list lengths — no database call required for the guard
security requirements
commitChanges() must validate the 5-chapter constraint client-side before any service/network call to prevent unnecessary API round-trips and give immediate user feedback
The service layer must also enforce the constraint server-side (database constraint) — the Cubit guard is UX optimisation, not the sole enforcement
ui components
ChapterMembershipSaving state drives a loading overlay or disabled commit button in the UI
ChapterMembershipSuccess state triggers a success toast/snackbar
ChapterMembershipError(MaxChaptersExceeded) drives a specific inline error message: 'NHF members may belong to up to 5 local chapters'

Execution Context

Execution Tier
Tier 2

Tier 2 - 518 tasks

Can start after Tier 1 completes

Implementation Notes

In commitChanges, capture the current loaded state before emitting Saving so you can restore pending lists in the error branch: `final loadedState = state as ChapterMembershipLoaded;`. On error, emit `ChapterMembershipError(failure, preservedState: loadedState)` — or alternatively emit `loadedState` first so UI stays responsive, then emit the error; choose the pattern that matches project convention. The 5-chapter net count formula: `netCount = (loadedState.currentAffiliations.length - loadedState.pendingRemoveAffiliationIds.length) + loadedState.pendingAddChapterIds.length`. For rollbackChanges, the 'original affiliations' are the affiliations in `currentAffiliations` — this list should not include the pending changes (which have not been committed), so the rollback simply produces `state.copyWith(pendingAddChapterIds: [], pendingRemoveAffiliationIds: [])`.

Do not store a separate 'snapshot' of pre-edit state; the current affiliations in ChapterMembershipLoaded are always the last committed state. After ChapterMembershipSuccess, do NOT auto-transition back to loaded — force the consumer to call loadAffiliations() explicitly so the UI confirms the server state before showing it.

Testing Requirements

Unit tests (flutter_test with bloc_test): (1) commitChanges happy path — assert [Saving, Success] emission; verify service called with correct pending add/remove lists. (2) commitChanges with empty pending lists — assert no state emitted. (3) commitChanges service throws NetworkFailure — assert [Saving, Error(NetworkFailure)]; verify pending lists preserved in a subsequent state check. (4) commitChanges with net chapter count > 5 — assert Error(MaxChaptersExceeded) emitted without service call.

(5) commitChanges with net count exactly 5 — assert proceeds to Saving. (6) rollbackChanges — assert ChapterMembershipLoaded emitted with original affiliations and empty pending lists. (7) commitChanges in non-loaded state — assert StateError thrown. (8) rollbackChanges in non-loaded state — assert StateError thrown.

Mock MultiChapterMembershipService. Verify mock service is never called for the MaxChaptersExceeded case using `verifyNever`.

Component
Chapter Membership Cubit
service medium
Epic Risks (3)
high impact medium prob technical

The Duplicate Activity Warning Dialog must announce itself to VoiceOver and TalkBack immediately on appearance. Flutter's default modal semantics do not guarantee focus shift to the dialog on all platform versions, risking silent appearance for screen reader users — a WCAG 2.2 failure.

Mitigation & Contingency

Mitigation: Wrap the dialog content in a Semantics node with liveRegion: true and explicitly request focus via FocusScope.of(context).requestFocus() on the dialog's primary action button in the post-frame callback. Test on physical iOS (VoiceOver) and Android (TalkBack) devices, not only simulators.

Contingency: If automatic focus fails on a specific platform version, add a platform-specific fallback using SemanticsService.announce() to force a live region announcement of the dialog's headline text.

medium impact medium prob technical

The Chapter Membership Cubit tracks pending changes before commit to support the two-step add/confirm flow. If the user navigates away mid-edit or the app is backgrounded, uncommitted pending state could be replayed incorrectly on return, causing phantom affiliation additions or removals.

Mitigation & Contingency

Mitigation: Design the Cubit to hold pending changes only in transient in-memory state with no persistence. On any navigation-away event, emit a reset state that discards pending changes. Prevent accidental navigation during an active edit by showing a discard-changes confirmation dialog.

Contingency: If state desync is reported in production, add an explicit state reconciliation step in the Cubit's onResume handler that re-fetches the authoritative affiliation list from the repository and resets all pending state before re-rendering.

medium impact high prob technical

The Chapter Assignment Editor's searchable chapter list must load quickly. If the organisation has hundreds of chapters (NHF has 1,400 local chapters) and the full list is fetched on dialog open, the editor will be slow to display and the search will be sluggish.

Mitigation & Contingency

Mitigation: Scope the chapter list to only chapters within the coordinator's administrative scope (not all 1,400), leveraging the existing hierarchy access scope service. Implement server-side search with a minimum 2-character threshold and debounce to avoid excessive Supabase queries.

Contingency: If the scoped list is still too large, add local caching of the chapter list with a 15-minute TTL and an explicit refresh button, ensuring the editor is always responsive even on poor network conditions.