high priority low complexity infrastructure pending backend specialist Tier 2

Acceptance Criteria

ExpenseTypeAnalyticsTracker is accessible as a singleton (via Riverpod Provider or get_it — match existing DI pattern)
trackEvent(AnonymisedExpenseEvent event) is a synchronous void method that appends to the in-memory buffer and returns immediately (< 1 ms)
Buffer capacity is exactly 50 events; adding the 51st event triggers an immediate flush before enqueueing the new event
A periodic timer fires every 30 seconds and flushes the buffer if non-empty
AppLifecycleListener (or WidgetsBindingObserver) triggers a flush when the app transitions to AppLifecycleState.paused or AppLifecycleState.detached
Flush dispatches the buffered batch to the AnalyticsSink interface; the buffer is cleared immediately after dispatch is initiated (not after completion)
In non-production environments (kDebugMode or a build flavour flag), a StubAnalyticsSink logs events to debugPrint and discards them
In production, the real sink dispatches to the existing push-notification infrastructure endpoint
Dispatch is unawaited (fire-and-forget); any sink errors are caught internally and do not propagate to the caller or cause UI jank
The tracker exposes a testable AnalyticsSink interface so tests can inject a mock sink

Technical Requirements

frameworks
Flutter
Riverpod
flutter_test
apis
Existing push-notification analytics sink endpoint (internal)
data models
AnonymisedExpenseEvent
AnalyticsSink
performance requirements
trackEvent() returns in < 1 ms — no async gap on the call thread
Flush must not block the UI isolate — use unawaited() or fire-and-forget Future
Timer uses Timer.periodic from dart:async — no additional isolates
security requirements
Sink must transmit only AnonymisedExpenseEvent payloads — never attach auth tokens or user identifiers to the batch payload
Production sink URL/config stored in environment variables, not hardcoded

Execution Context

Execution Tier
Tier 2

Tier 2 - 518 tasks

Can start after Tier 1 completes

Implementation Notes

Define an abstract AnalyticsSink interface with a single method: Future dispatch(List events). The real sink wraps the existing push-notification HTTP client; the stub just prints. Use Riverpod's Provider.family or a simple Provider to inject the sink — this allows tests to override it trivially. For the lifecycle listener, use WidgetsBindingObserver.didChangeAppLifecycleState in a mixin on the tracker class, registered in the tracker's constructor.

The 30-second timer should be created lazily on first trackEvent() call and cancelled in a dispose() method. Use unawaited(sink.dispatch(batch)) wrapped in try/catch to ensure fire-and-forget. Be careful not to clear the buffer before creating the dispatch batch — copy the list, clear the buffer, then dispatch the copy to avoid race conditions if trackEvent is called during flush.

Testing Requirements

Unit tests (task-006 covers this fully). For this task, provide a working StubAnalyticsSink and expose the AnalyticsSink interface. Manually verify in debug mode that debugPrint output appears for each tracked event. Integration smoke test: run the app in debug mode, trigger 5 expense type selections, and confirm stub sink logs appear in the console.

No automated integration tests required here — covered by task-006.

Component
Expense Type Analytics Tracker
infrastructure low
Epic Risks (2)
high impact medium prob scope

The compatibility matrix might be under-specified in source documentation. If a new organisation adds expense types or redefines rules, hardcoded pairwise logic becomes a maintenance liability and can silently allow previously excluded combinations.

Mitigation & Contingency

Mitigation: Model the matrix as a const Map<ExpenseType, Set<ExpenseType>> rather than if-else chains; add a unit test that exhaustively asserts every pair combination so any future matrix change forces explicit test updates.

Contingency: If per-organisation matrix variants are requested before the epic closes, extract matrix loading into expense-type-config with an org-override slot and defer per-org configuration to the repository epic.

medium impact medium prob technical

VoiceOver (iOS) and TalkBack (Android) handle Semantics widget announcements differently in Flutter. Live-region behaviour for disabled state changes is inconsistent across Flutter versions and may require platform-specific workarounds that are not yet documented.

Mitigation & Contingency

Mitigation: Write accessibility integration tests using Flutter's SemanticsController targeting both iOS and Android simulators from the outset; pin to a Flutter version known to handle Semantics.liveRegion correctly.

Contingency: If platform parity is unachievable before release, ship with a known gap documented in the WCAG audit log and schedule a dedicated accessibility sprint; do not block other epics.