refactor(tracking): tighten sf_tracking package

- Lazy-init sfTracking to avoid touching Firebase at import time
- DRY SfTrackingRepository with a single _broadcast helper
- Drop empty DashboardTracking, fix double step_completed in device_setup
- Move yearsBetween to packages/utils
- Add 5 unit tests for SfTrackingRepository
- Strip noisy comments from mixins and view models
This commit is contained in:
2026-04-07 16:59:38 +02:00
parent 4728e25803
commit 42ec003b05
27 changed files with 295 additions and 517 deletions

View File

@@ -14,6 +14,7 @@ import 'package:sf_app_platform/navigation/app_router.dart';
import 'package:sf_app_platform/save_family_app.dart';
import 'package:navigation/navigation.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_tracking/sf_tracking.dart';
Future<void> initApp(EnvironmentEnum env) async {
WidgetsFlutterBinding.ensureInitialized();
@@ -23,12 +24,12 @@ Future<void> initApp(EnvironmentEnum env) async {
scaTreezorModule();
themePackages();
// Firebase MUST be initialized before configureAppRouter, because
// SaveFamilyApp builds an SfRouterListener bound to sfTracking, and the
// FirebaseTrackingClient inside sfTracking accesses FirebaseAnalytics.instance
// at construction time. Accessing it before Firebase.initializeApp crashes.
// Order matters: Firebase → sfTracking (FirebaseTrackingClient touches
// FirebaseAnalytics.instance) → router (SaveFamilyApp wires sfTracking
// into SfRouterListener at construction time).
await setupFirebase(env);
await setupNotifications();
initSfTracking();
configureAppRouter();

View File

@@ -126,7 +126,7 @@ class ContactViewModel extends Notifier<ContactViewState> {
unawaited(
_tracking.legacySupportContactInitiated(
channel: 'email',
country: country,
country: country.isEmpty ? 'unknown' : country,
),
);

View File

@@ -53,9 +53,6 @@ class VolumeControlViewModel extends Notifier<VolumeControlViewState> {
final device = state.device;
if (device == null) return;
// Capture which volume types actually changed compared to the device's
// previous settings, so we only emit analytics for the ones the user
// actually moved.
final previous = device.settings.volume;
final mediaChanged = previous.media != state.media;
final ringtoneChanged = previous.ringtone != state.ringtone;

View File

@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'package:utils/utils.dart';
final legacyDeviceSetupViewModelProvider =
NotifierProvider<LegacyDeviceSetupViewModel, LegacyDeviceSetupViewState>(
@@ -28,10 +29,7 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
late final TextEditingController watchCodeController;
late final TextEditingController activationKeyController;
/// When the user entered the current step. Used to compute how long they
/// spent on each step and fire `step_completed` with a `duration_seconds`
/// param — super useful for marketing/UX to see where users get stuck.
DateTime _currentStepEnteredAt = DateTime.now();
DateTime? _currentStepEnteredAt;
@override
LegacyDeviceSetupViewState build() {
@@ -48,10 +46,10 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
return initial;
}
/// Emits a `step_completed` event for the step the user is LEAVING and
/// resets the step timer so the next step starts fresh.
void _completeCurrentStep(String stepName) {
final duration = DateTime.now().difference(_currentStepEnteredAt).inSeconds;
final enteredAt = _currentStepEnteredAt;
if (enteredAt == null) return;
final duration = DateTime.now().difference(enteredAt).inSeconds;
unawaited(
_tracking.legacyDeviceSetupStepCompleted(
step: stepName,
@@ -104,8 +102,6 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
state = state.copyWith(errorMessage: I18n.errorScanWatchRequired);
return;
}
// Distinguish QR vs manual code entry: if watchCode is populated and
// watchQr is empty, the user typed it manually.
if (state.watchCode.isNotEmpty && state.watchQr.isEmpty) {
unawaited(_tracking.legacyDeviceSetupManualCodeEntered());
}
@@ -158,8 +154,6 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
void onWatchQrScanned(String qr) {
unawaited(_tracking.legacyDeviceSetupQrScanned());
// The QR scan jumps directly from scan_watch to profile, so complete
// the scan_watch step with its timer too.
_completeCurrentStep('scan_watch');
state = state.copyWith(watchQr: qr, step: LegacyAddKidStep.profile);
}
@@ -201,20 +195,11 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
if (!ref.mounted) return false;
final now = DateTime.now();
final ageYears =
now.year -
birth.year -
((now.month < birth.month ||
(now.month == birth.month && now.day < birth.day))
? 1
: 0);
unawaited(
_tracking.legacyDeviceSetupCompleted(
childGender: genrer,
relationType: relationType,
childAgeYears: ageYears,
childAgeYears: yearsBetween(birth, DateTime.now()),
),
);

View File

@@ -16,9 +16,6 @@ final locationMapViewModelProvider =
class LocationMapViewModel extends Notifier<LocationMapViewState> {
late final SfTrackingRepository _tracking;
/// Debounce timer for map zoom tracking. We do NOT want to fire an event
/// for every zoom delta during a pinch gesture; we wait until the user
/// stops zooming for a brief moment and then log the final zoom level.
Timer? _zoomDebounce;
static const Duration _zoomDebounceDelay = Duration(seconds: 1);
@@ -29,8 +26,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
return const LocationMapViewState();
}
// ─── Layer toggles ─────────────────────────────────────────────────────
void toggleGeofences() {
final newVisible = !state.showGeofences;
unawaited(_tracking.legacyLocationMapGeofencesToggled(newVisible));
@@ -43,29 +38,18 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(showFrequentPlaces: newVisible);
}
// ─── Place creation funnel ────────────────────────────────────────────
void startPlacing(PlacingMode mode) {
unawaited(_tracking.legacyLocationPlaceCreationStarted(_modeName(mode)));
state = state.copyWith(placingMode: mode);
}
void cancelPlacing() {
// Infer mode + step from current state BEFORE we reset it so we can
// send meaningful funnel drop-off data to analytics.
final wasPicking = state.placingMode != PlacingMode.none;
final wasAdjustingRadius = state.adjustingRadius;
if (wasPicking || wasAdjustingRadius) {
final mode = wasPicking
? _modeName(state.placingMode)
// Only geofences reach the radius-adjust step.
: 'geofence';
final atStep = wasPicking ? 'picking_point' : 'adjusting_radius';
final cancellation = _inferCancellationContext();
if (cancellation != null) {
unawaited(
_tracking.legacyLocationPlaceCreationCancelled(
mode: mode,
atStep: atStep,
mode: cancellation.mode,
atStep: cancellation.atStep,
),
);
}
@@ -77,6 +61,16 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
);
}
_CancellationContext? _inferCancellationContext() {
final isPicking = state.placingMode != PlacingMode.none;
final isAdjustingRadius = state.adjustingRadius;
if (!isPicking && !isAdjustingRadius) return null;
final mode = isPicking ? _modeName(state.placingMode) : 'geofence';
final atStep = isPicking ? 'picking_point' : 'adjusting_radius';
return _CancellationContext(mode: mode, atStep: atStep);
}
void confirmGeofencePlacement(LatLng center) {
unawaited(_tracking.legacyLocationPointConfirmed('geofence'));
state = state.copyWith(
@@ -93,8 +87,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void updatePreviewRadius(double radius) {
// Intentionally NOT tracked: this fires on every slider tick. The final
// radius is captured in [confirmRadius] once the user settles.
state = state.copyWith(previewRadius: radius);
}
@@ -113,8 +105,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(previewPoint: null);
}
// ─── Exploration / selection ──────────────────────────────────────────
void selectGeofence(GeofenceEntity geofence) {
unawaited(_tracking.legacyLocationGeofenceSelected());
state = state.copyWith(
@@ -168,8 +158,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(selectedHistoryPosition: null);
}
// ─── Live tracking ────────────────────────────────────────────────────
void toggleFollowing() {
final newFollowing = !state.isFollowing;
unawaited(_tracking.legacyLocationFollowingToggled(newFollowing));
@@ -177,16 +165,12 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void stopFollowing() {
// Called automatically when the user pans the map manually. We track
// this as a "following disabled" event for consistency with toggle.
if (state.isFollowing) {
unawaited(_tracking.legacyLocationFollowingToggled(false));
}
state = state.copyWith(isFollowing: false);
}
// ─── UI chrome ────────────────────────────────────────────────────────
void toggleActionsExpanded() {
final newExpanded = !state.actionsExpanded;
unawaited(_tracking.legacyLocationMapActionsExpanded(newExpanded));
@@ -201,22 +185,14 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(frequencyExpanded: false);
}
// ─── Map zoom (debounced) ─────────────────────────────────────────────
void updateMapZoom(double zoom) {
state = state.copyWith(mapZoom: zoom);
// Debounce: cancel any pending fire and schedule a new one. If the user
// keeps zooming, the timer keeps resetting and we only fire once the
// zoom settles for [_zoomDebounceDelay].
_zoomDebounce?.cancel();
_zoomDebounce = Timer(_zoomDebounceDelay, () {
unawaited(_tracking.legacyLocationMapZoomed(zoom));
});
}
// ─── Helpers ──────────────────────────────────────────────────────────
String _modeName(PlacingMode mode) {
switch (mode) {
case PlacingMode.geofence:
@@ -228,3 +204,9 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
}
}
class _CancellationContext {
const _CancellationContext({required this.mode, required this.atStep});
final String mode;
final String atStep;
}

View File

@@ -1,12 +1,4 @@
/// SaveFamily tracking & analytics package.
///
/// Public API exposed via this barrel:
/// - [SfTrackingRepository] — main entry point with all feature mixins
/// - [sfTracking] — top-level singleton (use from non-Riverpod contexts like router setup)
/// - [sfTrackingProvider] — Riverpod provider exposing the same singleton
/// - [SfRouterListener] — listener bound to a router (typically GoRouter's routerDelegate) that emits screen views
/// - [UserInfoTrackingListener] — helper to set user id + properties on login
/// - [TrackingClient] — interface to add new vendors (AppsFlyer, Mixpanel, etc)
library;
export 'src/clients/debug_tracking_client.dart';
@@ -15,7 +7,6 @@ export 'src/mixins/account_tracking.dart';
export 'src/mixins/auth_tracking.dart';
export 'src/mixins/contacts_tracking.dart';
export 'src/mixins/control_panel_tracking.dart';
export 'src/mixins/dashboard_tracking.dart';
export 'src/mixins/device_setup_tracking.dart';
export 'src/mixins/device_tracking.dart';
export 'src/mixins/location_tracking.dart';

View File

@@ -1,12 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:sf_tracking/src/tracking_client.dart';
/// Logs all tracking calls to the console via `debugPrint`. Useful as a
/// secondary client during local development to see events without opening
/// Firebase DebugView.
///
/// Add it to the clients list in `sf_tracking_provider.dart` only when
/// `kDebugMode` is true.
/// Logs every tracking call to the console. Wired only in `kDebugMode` so
/// developers see events without opening Firebase DebugView.
class DebugTrackingClient implements TrackingClient {
const DebugTrackingClient();

View File

@@ -1,11 +1,8 @@
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:sf_tracking/src/tracking_client.dart';
/// Firebase Analytics implementation of [TrackingClient].
///
/// Propagates a `consent_status` parameter on every event so that GDPR-driven
/// filtering can happen later in the data warehouse without losing the data
/// itself. Pattern borrowed from `effi-app-flutter/lib/core/tracking_clients/firebase_tracking_client.dart`.
/// Firebase Analytics implementation. Propagates `consent_status` on every
/// event so the data warehouse can filter non-consented events later.
class FirebaseTrackingClient implements TrackingClient {
FirebaseTrackingClient({required bool consentAnalytics})
: _hasConsent = consentAnalytics;
@@ -14,9 +11,8 @@ class FirebaseTrackingClient implements TrackingClient {
bool _hasConsent;
@override
Future<void> setAnalyticsStatus({bool enabled = true}) {
return _analytics.setAnalyticsCollectionEnabled(enabled);
}
Future<void> setAnalyticsStatus({bool enabled = true}) =>
_analytics.setAnalyticsCollectionEnabled(enabled);
@override
Future<void> setConsentStatus(bool hasConsent) async {
@@ -30,35 +26,28 @@ class FirebaseTrackingClient implements TrackingClient {
}
@override
Future<void> setUserId(String? userId) {
return _analytics.setUserId(id: userId);
}
Future<void> setUserId(String? userId) => _analytics.setUserId(id: userId);
@override
Future<void> setUserProperty(String name, String value) {
return _analytics.setUserProperty(name: name, value: value);
}
Future<void> setUserProperty(String name, String value) =>
_analytics.setUserProperty(name: name, value: value);
@override
Future<void> track(String name, [Map<String, Object>? parameters]) {
return _analytics.logEvent(
name: name,
parameters: _withConsent(parameters),
);
}
Future<void> track(String name, [Map<String, Object>? parameters]) =>
_analytics.logEvent(name: name, parameters: _withConsent(parameters));
@override
Future<void> trackScreenView(String screenName, [Map<String, Object>? parameters]) {
return _analytics.logScreenView(
screenName: screenName,
parameters: _withConsent(parameters),
);
}
Future<void> trackScreenView(
String screenName, [
Map<String, Object>? parameters,
]) =>
_analytics.logScreenView(
screenName: screenName,
parameters: _withConsent(parameters),
);
Map<String, Object> _withConsent(Map<String, Object>? parameters) {
return {
'consent_status': _hasConsent.toString(),
...?parameters,
};
}
Map<String, Object> _withConsent(Map<String, Object>? parameters) => {
'consent_status': _hasConsent.toString(),
...?parameters,
};
}

View File

@@ -2,49 +2,34 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_account';
/// Tracking events for the legacy account module (profile, password,
/// linked devices, app users, account deletion). Mixed into `SfTrackingRepository`.
mixin AccountTracking on Tracking {
/// User saved their personal data successfully.
Future<void> legacyAccountPersonalDataEdited() =>
trackEvent('${_prefix}_personal_data_edited');
/// User changed their password successfully.
Future<void> legacyAccountPasswordChanged() =>
trackEvent('${_prefix}_password_changed');
/// Password change failed (wrong current password, validation, server error, etc).
Future<void> legacyAccountPasswordChangeFailed(String reason) =>
trackEvent('${_prefix}_password_change_failed', {'reason': reason});
/// User unlinked a device from their account.
Future<void> legacyAccountLinkedDeviceUnlinked() =>
trackEvent('${_prefix}_linked_device_unlinked');
/// User renamed a linked device (edited the carrier name on the linked
/// devices screen).
Future<void> legacyAccountLinkedDeviceRenamed() =>
trackEvent('${_prefix}_linked_device_renamed');
/// User tapped "delete" on the app users screen. Note: the current
/// implementation is known to delete the logged user (placeholder logic);
/// the event is still useful to measure demand for this feature.
Future<void> legacyAccountAppUserDeleteTriggered() =>
trackEvent('${_prefix}_app_user_delete_triggered');
/// ⚠️ CHURN SIGNAL — User opened the account deletion flow.
Future<void> legacyAccountDeletionInitiated() =>
trackEvent('${_prefix}_deletion_initiated');
/// ⚠️ CHURN SIGNAL — User confirmed and the deletion API call is in flight.
Future<void> legacyAccountDeletionConfirmed() =>
trackEvent('${_prefix}_deletion_confirmed');
/// ⚠️ CHURN SIGNAL — Account deletion completed on the backend.
Future<void> legacyAccountDeletionCompleted() =>
trackEvent('${_prefix}_deletion_completed');
/// User backed out of the deletion flow before confirming.
Future<void> legacyAccountDeletionCancelled() =>
trackEvent('${_prefix}_deletion_cancelled');
}

View File

@@ -2,113 +2,74 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_auth';
/// Tracking events for the legacy authentication flow (login + 2FA + signup +
/// password recovery + logout). Mixed into `SfTrackingRepository`.
mixin AuthTracking on Tracking {
// ─── Login ───────────────────────────────────────────────────────────────
Future<void> legacyAuthLoginAttempt() =>
trackEvent('${_prefix}_login_attempt');
/// User submitted the login form (after client-side validation passed).
Future<void> legacyAuthLoginAttempt() => trackEvent('${_prefix}_login_attempt');
Future<void> legacyAuthLoginSuccess() =>
trackEvent('${_prefix}_login_success');
/// Backend accepted the credentials. 2FA may still be required afterwards.
Future<void> legacyAuthLoginSuccess() => trackEvent('${_prefix}_login_success');
/// Backend rejected the credentials or threw an error.
Future<void> legacyAuthLoginFailure(String reason) =>
trackEvent('${_prefix}_login_failure', {'reason': reason});
// ─── 2FA ─────────────────────────────────────────────────────────────────
Future<void> legacyAuth2faRequested() =>
trackEvent('${_prefix}_2fa_requested');
/// 2FA code was successfully requested from the backend.
Future<void> legacyAuth2faRequested() => trackEvent('${_prefix}_2fa_requested');
Future<void> legacyAuth2faVerified() =>
trackEvent('${_prefix}_2fa_verified');
/// 2FA code was verified and the session is now authenticated.
Future<void> legacyAuth2faVerified() => trackEvent('${_prefix}_2fa_verified');
/// 2FA verification failed (wrong code, expired, etc).
Future<void> legacyAuth2faFailure(String reason) =>
trackEvent('${_prefix}_2fa_failure', {'reason': reason});
/// User asked to resend the 2FA code.
Future<void> legacyAuth2faResend() => trackEvent('${_prefix}_2fa_resend');
// ─── Signup ──────────────────────────────────────────────────────────────
/// User started the signup flow (form submitted, request in flight).
Future<void> legacyAuthSignupStarted() =>
trackEvent('${_prefix}_signup_started');
/// Backend accepted the signup. Account is created.
Future<void> legacyAuthSignupCompleted() =>
trackEvent('${_prefix}_signup_completed');
/// Backend rejected the signup.
Future<void> legacyAuthSignupFailed(String reason) =>
trackEvent('${_prefix}_signup_failed', {'reason': reason});
/// User successfully advanced past a signup form step (validation passed
/// and they moved to the next page). [stepIndex] is 0-based.
Future<void> legacyAuthSignupStepCompleted(int stepIndex) =>
trackEvent('${_prefix}_signup_step_completed', {
'step_index': stepIndex,
});
trackEvent('${_prefix}_signup_step_completed', {'step_index': stepIndex});
/// User went back to a previous signup step. [stepIndex] is the step they
/// JUST LEFT (i.e. the one they came from).
Future<void> legacyAuthSignupStepBack(int stepIndex) =>
trackEvent('${_prefix}_signup_step_back', {'step_index': stepIndex});
/// User tried to advance past a step but client-side validation failed.
/// [stepIndex] is 0-based.
Future<void> legacyAuthSignupStepValidationFailed(int stepIndex) =>
trackEvent('${_prefix}_signup_step_validation_failed', {
'step_index': stepIndex,
});
// ─── Password recovery ───────────────────────────────────────────────────
/// User requested a password reset (form submitted, request in flight).
Future<void> legacyAuthPasswordResetRequested() =>
trackEvent('${_prefix}_password_reset_requested');
/// Backend confirmed it sent the recovery email.
Future<void> legacyAuthPasswordResetEmailSent() =>
trackEvent('${_prefix}_password_reset_email_sent');
/// ⭐ User finished the password recovery flow (new password saved on
/// backend). This is the final conversion of the recovery funnel.
Future<void> legacyAuthPasswordResetCompleted() =>
trackEvent('${_prefix}_password_reset_completed');
/// Password recovery failed — either client validation (weak password) or
/// backend error.
Future<void> legacyAuthPasswordResetFailed(String reason) =>
trackEvent('${_prefix}_password_reset_failed', {'reason': reason});
// ─── Phone linking ───────────────────────────────────────────────────────
/// User submitted a phone number to receive an OTP code for phone linking.
Future<void> legacyAuthLinkPhoneCodeRequested() =>
trackEvent('${_prefix}_link_phone_code_requested');
/// Requesting the OTP code from the backend failed.
Future<void> legacyAuthLinkPhoneCodeRequestFailed(String reason) =>
trackEvent('${_prefix}_link_phone_code_request_failed', {
'reason': reason,
});
/// User successfully verified the OTP code and the phone is linked.
Future<void> legacyAuthLinkPhoneCodeVerified() =>
trackEvent('${_prefix}_link_phone_code_verified');
/// OTP verification for phone linking failed (wrong code, expired, etc).
Future<void> legacyAuthLinkPhoneCodeVerificationFailed(String reason) =>
trackEvent('${_prefix}_link_phone_code_verification_failed', {
'reason': reason,
});
// ─── Logout ──────────────────────────────────────────────────────────────
/// User logged out and the session has been cleared.
Future<void> legacyAuthLogout() => trackEvent('${_prefix}_logout');
}

View File

@@ -2,20 +2,12 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_contacts';
/// Tracking events for the legacy device-contacts feature (contacts allowed
/// to call/be called by the kid's device). Mixed into `SfTrackingRepository`.
mixin ContactsTracking on Tracking {
/// User created a new contact entry on the device. [totalCount] is the
/// total number of contacts the user has AFTER the add (useful to segment
/// by "small agenda vs. big agenda").
Future<void> legacyContactsAdded({required int totalCount}) =>
trackEvent('${_prefix}_added', {'total_count': totalCount});
/// User edited an existing contact entry.
Future<void> legacyContactsEdited() => trackEvent('${_prefix}_edited');
/// User deleted a contact entry from the device. [totalCount] is the total
/// number of contacts remaining after the delete.
Future<void> legacyContactsDeleted({required int totalCount}) =>
trackEvent('${_prefix}_deleted', {'total_count': totalCount});
}

View File

@@ -2,12 +2,7 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_control_panel';
/// Tracking events for the legacy control panel (the home dashboard with
/// device cards and a position map). Mixed into `SfTrackingRepository`.
mixin ControlPanelTracking on Tracking {
/// User switched the active device (which kid they're monitoring).
/// [totalDevices] is how many devices the user has linked — a proxy for
/// family size (more kids = stickier segment).
Future<void> legacyControlPanelDeviceSelected({
required int totalDevices,
}) =>
@@ -15,8 +10,6 @@ mixin ControlPanelTracking on Tracking {
'total_devices': totalDevices,
});
/// User triggered a manual refresh of device positions (pull-to-refresh
/// or refresh button).
Future<void> legacyControlPanelPositionsRefreshed() =>
trackEvent('${_prefix}_positions_refreshed');
}

View File

@@ -1,15 +0,0 @@
import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_dashboard';
/// Tracking events for the legacy dashboard shell (bottom navigation, tab
/// changes, top-level dashboard interactions). Mixed into `SfTrackingRepository`.
///
/// Empty for now — events will be added as we instrument the legacy dashboard
/// in subsequent iterations. Method skeletons live here so the mixin is ready
/// to extend without touching the repository.
mixin DashboardTracking on Tracking {
/// User selected a tab in the legacy bottom navigation bar.
Future<void> legacyDashboardTabChanged(String tabName) =>
trackEvent('${_prefix}_tab_changed', {'tab_name': tabName});
}

View File

@@ -2,16 +2,9 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_device_setup';
/// Tracking events for the legacy device-setup wizard (the "aha moment"
/// flow where a parent links a kid's wearable to their account). Mixed into
/// `SfTrackingRepository`.
mixin DeviceSetupTracking on Tracking {
/// User entered the device setup wizard.
Future<void> legacyDeviceSetupStarted() => trackEvent('${_prefix}_started');
/// User completed an individual step of the wizard. [step] is the name of
/// the step they just left (intro, link_info, scan_watch, profile, etc).
/// [durationSeconds] is how long they spent on that step.
Future<void> legacyDeviceSetupStepCompleted({
required String step,
required int durationSeconds,
@@ -21,10 +14,6 @@ mixin DeviceSetupTracking on Tracking {
'duration_seconds': durationSeconds,
});
/// ⭐ User finished the wizard and the device was successfully created.
/// Enriched with demographics of the kid: gender, relation type
/// (mother/father/etc) and age in years — all super valuable for marketing
/// personas.
Future<void> legacyDeviceSetupCompleted({
required String childGender,
required String relationType,
@@ -36,8 +25,6 @@ mixin DeviceSetupTracking on Tracking {
'child_age_years': childAgeYears,
});
/// Wizard failed to complete due to an error. [atStep] is the step where it
/// failed and [reason] is the error message.
Future<void> legacyDeviceSetupFailed({
required String atStep,
required String reason,
@@ -47,24 +34,15 @@ mixin DeviceSetupTracking on Tracking {
'reason': reason,
});
/// User backed out of the wizard before completing it. [atStep] is the
/// step they were on when they cancelled.
Future<void> legacyDeviceSetupCancelled(String atStep) =>
trackEvent('${_prefix}_cancelled', {'at_step': atStep});
/// User successfully scanned the watch QR code (instead of typing the code
/// manually).
Future<void> legacyDeviceSetupQrScanned() =>
trackEvent('${_prefix}_qr_scanned');
/// User advanced past the scan-watch step using a manually typed code
/// instead of scanning the QR.
Future<void> legacyDeviceSetupManualCodeEntered() =>
trackEvent('${_prefix}_manual_code_entered');
/// ⭐ After successfully creating a device, user tapped "add another kid"
/// which resets the wizard. This is the strongest signal of families with
/// multiple kids — a retention goldmine.
Future<void> legacyDeviceSetupResetForNewKid() =>
trackEvent('${_prefix}_reset_for_new_kid');
}

View File

@@ -2,53 +2,30 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_device';
/// Tracking events for the legacy device management module (locate, remote
/// connection, volume, scheduled activities, rewards, background image,
/// health / steps / apps usage / call history). Mixed into
/// `SfTrackingRepository`.
///
/// Pure screen views are tracked automatically by `SfRouterListener` — only
/// explicit user actions are listed here.
mixin DeviceTracking on Tracking {
// ─── Locate ──────────────────────────────────────────────────────────────
/// User triggered a "locate device" command.
Future<void> legacyDeviceLocateRequested() =>
trackEvent('${_prefix}_locate_requested');
/// The locate command completed successfully on the backend.
Future<void> legacyDeviceLocateSuccess() =>
trackEvent('${_prefix}_locate_success');
/// The locate command failed.
Future<void> legacyDeviceLocateFailure(String reason) =>
trackEvent('${_prefix}_locate_failure', {'reason': reason});
// ─── Remote connection (camera + call) ───────────────────────────────────
/// User entered the remote connection screen (active session start).
Future<void> legacyDeviceRemoteConnectionStarted() =>
trackEvent('${_prefix}_remote_connection_started');
/// User triggered the remote camera to take a photo via remote connection.
Future<void> legacyDeviceRemoteConnectionPhotoTaken() =>
trackEvent('${_prefix}_remote_connection_photo_taken');
/// User initiated a two-way call from the remote connection screen.
Future<void> legacyDeviceRemoteConnectionCallInitiated() =>
trackEvent('${_prefix}_remote_connection_call_initiated');
/// User navigated through the pictures from the remote camera.
/// [direction] is `next`, `prev` or `direct` (tapped a specific index).
Future<void> legacyDeviceRemoteConnectionPictureViewed(String direction) =>
trackEvent('${_prefix}_remote_connection_picture_viewed', {
'direction': direction,
});
// ─── Volume control ──────────────────────────────────────────────────────
/// User changed a volume control level. [type] is `media`, `ringtone` or
/// `alarm`. [level] is the new value (0-100 typically).
Future<void> legacyDeviceVolumeControlChanged({
required String type,
required int level,
@@ -58,20 +35,12 @@ mixin DeviceTracking on Tracking {
'level': level,
});
// ─── Background image ────────────────────────────────────────────────────
/// User changed the device background image (chose from library).
Future<void> legacyDeviceBackgroundImageChanged() =>
trackEvent('${_prefix}_background_image_changed');
/// User uploaded a custom photo to use as device background.
Future<void> legacyDeviceBackgroundImageUploaded() =>
trackEvent('${_prefix}_background_image_uploaded');
// ─── Scheduled activities ────────────────────────────────────────────────
/// User added a scheduled activity (alarm, reminder, etc on the device).
/// [weekDay] is 0-6 (0 = Sunday). [period] is a `HH:mm-HH:mm` string.
Future<void> legacyDeviceScheduledActivityAdded({
required int weekDay,
required String period,
@@ -81,8 +50,6 @@ mixin DeviceTracking on Tracking {
'period': period,
});
/// User edited a previously created scheduled activity.
/// [weekDay] 0-6, [period] `HH:mm-HH:mm` string.
Future<void> legacyDeviceScheduledActivityUpdated({
required int weekDay,
required String period,
@@ -92,54 +59,33 @@ mixin DeviceTracking on Tracking {
'period': period,
});
/// User removed a scheduled activity.
Future<void> legacyDeviceScheduledActivityRemoved() =>
trackEvent('${_prefix}_scheduled_activity_removed');
// ─── Rewards ─────────────────────────────────────────────────────────────
/// User assigned reward minutes to the device.
Future<void> legacyDeviceRewardsGranted({required int amount}) =>
trackEvent('${_prefix}_rewards_granted', {'amount': amount});
// ─── Activity meter (steps) ──────────────────────────────────────────────
/// User toggled the pedometer (step counter) on the device.
Future<void> legacyDeviceActivityPedometerToggled(bool enabled) =>
trackEvent('${_prefix}_activity_pedometer_toggled', {
'enabled': enabled.toString(),
});
/// User changed the time range filter on the activity meter screen.
/// [range] is `today`, `seven_days`, `thirty_days` or `custom`.
Future<void> legacyDeviceActivityMeterTimeRangeChanged(String range) =>
trackEvent('${_prefix}_activity_meter_time_range_changed', {
'range': range,
});
// ─── Health ──────────────────────────────────────────────────────────────
/// User changed the heart-rate sampling frequency.
Future<void> legacyDeviceHealthHeartRateFrequencyChanged(int seconds) =>
trackEvent('${_prefix}_health_heart_rate_frequency_changed', {
'frequency_seconds': seconds,
});
/// User triggered an on-demand health measurement (heart rate / SpO2).
Future<void> legacyDeviceHealthMeasurementStarted() =>
trackEvent('${_prefix}_health_measurement_started');
/// User changed the time range filter on the health screen.
Future<void> legacyDeviceHealthTimeRangeChanged(String range) =>
trackEvent('${_prefix}_health_time_range_changed', {'range': range});
// ─── Apps usage ──────────────────────────────────────────────────────────
/// User changed the time range filter on the apps usage screen.
/// [range] is `today`, `seven_days`, `thirty_days` or `custom`.
/// [totalDurationSeconds] is the total time across all apps for that range.
/// [topAppName] is the app with the highest duration in the result set
/// (empty string if no data).
Future<void> legacyDeviceAppsUseTimeRangeChanged({
required String range,
required int totalDurationSeconds,
@@ -151,10 +97,6 @@ mixin DeviceTracking on Tracking {
'top_app_name': topAppName,
});
// ─── Call history ────────────────────────────────────────────────────────
/// User changed the filter on the call history screen.
/// [filter] is `all`, `incoming`, `outgoing` or `missed`.
Future<void> legacyDeviceCallHistoryFilterChanged(String filter) =>
trackEvent('${_prefix}_call_history_filter_changed', {'filter': filter});
}

View File

@@ -2,12 +2,7 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_location';
/// Tracking events for the legacy location module (geofences, frequent
/// places, position history, location frequency, map layers). Mixed into
/// `SfTrackingRepository`.
mixin LocationTracking on Tracking {
// ─── Geofences (safe zones) ──────────────────────────────────────────────
Future<void> legacyLocationGeofenceCreated() =>
trackEvent('${_prefix}_geofence_created');
@@ -17,8 +12,6 @@ mixin LocationTracking on Tracking {
Future<void> legacyLocationGeofenceDeleted() =>
trackEvent('${_prefix}_geofence_deleted');
// ─── Frequent places ─────────────────────────────────────────────────────
Future<void> legacyLocationFrequentPlaceCreated() =>
trackEvent('${_prefix}_frequent_place_created');
@@ -28,22 +21,14 @@ mixin LocationTracking on Tracking {
Future<void> legacyLocationFrequentPlaceDeleted() =>
trackEvent('${_prefix}_frequent_place_deleted');
// ─── Position history ────────────────────────────────────────────────────
/// User loaded position history for a date range.
Future<void> legacyLocationHistoryLoaded() =>
trackEvent('${_prefix}_history_loaded');
// ─── Location frequency / privacy controls ───────────────────────────────
/// User changed how often the device sends location updates.
Future<void> legacyLocationFrequencyUpdated(int seconds) =>
trackEvent('${_prefix}_frequency_updated', {
'frequency_seconds': seconds,
});
// ─── Map layer toggles ───────────────────────────────────────────────────
Future<void> legacyLocationMapGeofencesToggled(bool visible) =>
trackEvent('${_prefix}_map_geofences_toggled', {
'visible': visible.toString(),
@@ -59,22 +44,12 @@ mixin LocationTracking on Tracking {
'visible': visible.toString(),
});
// ─── Place creation funnel ───────────────────────────────────────────────
/// User tapped the "add geofence" or "add frequent place" button and
/// entered placing mode. [mode] is `geofence` or `frequent_place`.
Future<void> legacyLocationPlaceCreationStarted(String mode) =>
trackEvent('${_prefix}_place_creation_started', {'mode': mode});
/// User confirmed the point on the map (tapped to drop the pin). For
/// geofences, this leads to the radius-adjusting step. For frequent places,
/// this triggers the actual creation flow.
Future<void> legacyLocationPointConfirmed(String mode) =>
trackEvent('${_prefix}_point_confirmed', {'mode': mode});
/// User confirmed the geofence radius (last step before API call).
/// [isEditing] distinguishes editing an existing geofence from creating a
/// new one.
Future<void> legacyLocationRadiusConfirmed({
required double radius,
required bool isEditing,
@@ -84,9 +59,6 @@ mixin LocationTracking on Tracking {
'is_editing': isEditing.toString(),
});
/// User abandoned the creation or edit flow before completing it. [atStep]
/// is `picking_point` or `adjusting_radius`. [mode] is the mode that was
/// active when cancelled.
Future<void> legacyLocationPlaceCreationCancelled({
required String mode,
required String atStep,
@@ -96,30 +68,21 @@ mixin LocationTracking on Tracking {
'at_step': atStep,
});
// ─── Exploration / edit entry points ─────────────────────────────────────
/// User tapped a geofence on the map to inspect it.
Future<void> legacyLocationGeofenceSelected() =>
trackEvent('${_prefix}_geofence_selected');
/// User tapped "edit" on a selected geofence.
Future<void> legacyLocationGeofenceEditStarted() =>
trackEvent('${_prefix}_geofence_edit_started');
/// User tapped a frequent place on the map to inspect it.
Future<void> legacyLocationFrequentPlaceSelected() =>
trackEvent('${_prefix}_frequent_place_selected');
/// User tapped a position pin from the route history to see its details.
Future<void> legacyLocationHistoryPositionSelected() =>
trackEvent('${_prefix}_history_position_selected');
/// User cleared the route history trail from the map.
Future<void> legacyLocationHistoryCleared() =>
trackEvent('${_prefix}_history_cleared');
// ─── Dismissals (complete the exploration funnels) ───────────────────────
Future<void> legacyLocationGeofenceDismissed() =>
trackEvent('${_prefix}_geofence_dismissed');
@@ -129,56 +92,33 @@ mixin LocationTracking on Tracking {
Future<void> legacyLocationHistoryPositionDismissed() =>
trackEvent('${_prefix}_history_position_dismissed');
// ─── Live tracking + UI engagement ───────────────────────────────────────
/// User toggled "follow device" mode on the map.
Future<void> legacyLocationFollowingToggled(bool enabled) =>
trackEvent('${_prefix}_following_toggled', {
'enabled': enabled.toString(),
});
/// User expanded or collapsed the actions drawer on the map.
Future<void> legacyLocationMapActionsExpanded(bool expanded) =>
trackEvent('${_prefix}_map_actions_expanded', {
'expanded': expanded.toString(),
});
// ─── Map zoom (debounced at the view model to avoid flooding) ────────────
/// User finished a zoom interaction. The view model debounces rapid zoom
/// changes (e.g. continuous pinch gestures) so this fires once the zoom has
/// settled, not on every delta.
Future<void> legacyLocationMapZoomed(double zoom) =>
trackEvent('${_prefix}_map_zoomed', {'zoom': zoom});
// ─── Map style + top-level map actions ──────────────────────────────────
/// User changed the visual style of the map. [style] is one of
/// `standard`, `voyager`, `light`, `dark`, `satellite`.
Future<void> legacyLocationMapStyleChanged(String style) =>
trackEvent('${_prefix}_map_style_changed', {'style': style});
/// ⭐ User tapped "share location" — typically sends the kid's position
/// via the native share sheet to a messaging app. This is one of the most
/// viral actions of the product.
Future<void> legacyLocationShared() => trackEvent('${_prefix}_shared');
/// User tapped the "center on device" / my-location button on the map.
Future<void> legacyLocationMapCenterTapped() =>
trackEvent('${_prefix}_map_center_tapped');
/// User tapped the refresh button on the map (different from the pull-to-
/// refresh in the control panel — this is the in-map refresh action).
Future<void> legacyLocationMapRefreshTapped() =>
trackEvent('${_prefix}_map_refresh_tapped');
/// User opened the bottom list sheet containing geofences, frequent
/// places and position history.
Future<void> legacyLocationListSheetOpened() =>
trackEvent('${_prefix}_list_sheet_opened');
/// User filtered the position-history list by type. [type] is the raw
/// position type string (e.g. `gps`, `wifi`, `sos`) or `all` when cleared.
Future<void> legacyLocationHistoryTypeFilterChanged(String type) =>
trackEvent('${_prefix}_history_type_filter_changed', {'type': type});
}

View File

@@ -2,11 +2,7 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_onboarding';
/// Tracking events for the legacy onboarding intro flow. Mixed into
/// `SfTrackingRepository`.
mixin OnboardingTracking on Tracking {
/// User advanced to a different intro card. [stepIndex] is the index of the
/// new card.
Future<void> legacyOnboardingStepChanged(int stepIndex) =>
trackEvent('${_prefix}_step_changed', {'step_index': stepIndex});
}

View File

@@ -2,34 +2,25 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_settings';
/// Tracking events for the legacy settings module (alarms, SOS, language,
/// alerts, timezone, wifi, remote management, etc). Mixed into
/// `SfTrackingRepository`.
mixin SettingsTracking on Tracking {
// ─── Alarms ──────────────────────────────────────────────────────────────
/// User created a new alarm on the device. [time] is `HH:mm`.
Future<void> legacySettingsAlarmAdded({required String time}) =>
trackEvent('${_prefix}_alarm_added', {'time': time});
/// User edited an existing alarm. [time] is the new `HH:mm`.
Future<void> legacySettingsAlarmUpdated({required String time}) =>
trackEvent('${_prefix}_alarm_updated', {'time': time});
Future<void> legacySettingsAlarmRemoved() =>
trackEvent('${_prefix}_alarm_removed');
// ─── SOS contacts ────────────────────────────────────────────────────────
Future<void> legacySettingsSosContactAdded({required int totalCount}) =>
trackEvent('${_prefix}_sos_contact_added', {'total_count': totalCount});
Future<void> legacySettingsSosContactRemoved({required int totalCount}) =>
trackEvent('${_prefix}_sos_contact_removed', {'total_count': totalCount});
// ─── Block phone whitelist ───────────────────────────────────────────────
Future<void> legacySettingsBlockPhoneContactAdded({required int totalCount}) =>
Future<void> legacySettingsBlockPhoneContactAdded({
required int totalCount,
}) =>
trackEvent('${_prefix}_block_phone_contact_added', {
'total_count': totalCount,
});
@@ -41,34 +32,22 @@ mixin SettingsTracking on Tracking {
'total_count': totalCount,
});
// ─── Disable functions (parental controls) ───────────────────────────────
/// Legacy aggregate event: user saved the disable functions screen.
Future<void> legacySettingsDisableFunctionsChanged() =>
trackEvent('${_prefix}_disable_functions_changed');
/// Granular: user toggled the device keyboard off/on.
Future<void> legacySettingsDisableFunctionsKeyboardToggled(bool enabled) =>
trackEvent('${_prefix}_disable_functions_keyboard_toggled', {
'enabled': enabled.toString(),
});
/// Granular: user toggled GPS off/on.
Future<void> legacySettingsDisableFunctionsGpsToggled(bool enabled) =>
trackEvent('${_prefix}_disable_functions_gps_toggled', {
'enabled': enabled.toString(),
});
// ─── Language ────────────────────────────────────────────────────────────
Future<void> legacySettingsLanguageChanged(String language) =>
trackEvent('${_prefix}_language_changed', {'language': language});
// ─── Alerts ──────────────────────────────────────────────────────────────
/// User saved the alerts configuration. [alertCount] is how many alerts
/// are currently enabled. [alertsEnabled] is the comma-joined list of the
/// active alert type names, truncated to a safe length.
Future<void> legacySettingsAlertsConfigured({
required int alertCount,
required String alertsEnabled,
@@ -78,51 +57,32 @@ mixin SettingsTracking on Tracking {
'alerts_enabled': alertsEnabled,
});
// ─── Timezone ────────────────────────────────────────────────────────────
Future<void> legacySettingsTimezoneChanged(String timezone) =>
trackEvent('${_prefix}_timezone_changed', {'timezone': timezone});
// ─── WiFi ────────────────────────────────────────────────────────────────
Future<void> legacySettingsWifiAdded({required int totalCount}) =>
trackEvent('${_prefix}_wifi_added', {'total_count': totalCount});
Future<void> legacySettingsWifiRemoved({required int totalCount}) =>
trackEvent('${_prefix}_wifi_removed', {'total_count': totalCount});
// ─── Sound ───────────────────────────────────────────────────────────────
/// User changed the sound mode of the device. [mode] is typically
/// `normal`, `silent` or `vibrate`.
Future<void> legacySettingsSoundChanged({required String mode}) =>
trackEvent('${_prefix}_sound_changed', {'mode': mode});
// ─── Sync clock ──────────────────────────────────────────────────────────
Future<void> legacySettingsSyncClockTriggered() =>
trackEvent('${_prefix}_sync_clock_triggered');
// ─── Battery ─────────────────────────────────────────────────────────────
/// User toggled night mode (battery saving) on the device.
Future<void> legacySettingsBatteryNightModeToggled(bool enabled) =>
trackEvent('${_prefix}_battery_night_mode_toggled', {
'enabled': enabled.toString(),
});
// ─── Remote management (destructive commands) ───────────────────────────
/// User triggered a remote shutdown of the device.
Future<void> legacySettingsRemoteManagementShutdown() =>
trackEvent('${_prefix}_remote_management_shutdown');
/// User triggered a remote restart of the device.
Future<void> legacySettingsRemoteManagementRestart() =>
trackEvent('${_prefix}_remote_management_restart');
/// ⚠️ CHURN SIGNAL — user triggered a factory reset of the device.
/// This effectively wipes the device and often precedes unlinking.
Future<void> legacySettingsRemoteManagementFactoryReset() =>
trackEvent('${_prefix}_remote_management_factory_reset');
}

View File

@@ -2,15 +2,9 @@ import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_support';
/// Tracking events for the legacy customer service module. Mixed into
/// `SfTrackingRepository`.
mixin SupportTracking on Tracking {
/// User tapped a button to contact support. [channel] is `email`, `phone`,
/// `whatsapp`, etc — depends on the support screen UX. [country] is the
/// country the user picked in the support form (may be empty if not
/// selected). Note that this only tracks the *intent* (the button tap
/// that opens the external client). We can't confirm the message was
/// actually sent.
/// Pass `'unknown'` (not an empty string) for [country] when the user
/// didn't select one, so the dashboard doesn't fill up with empty buckets.
Future<void> legacySupportContactInitiated({
required String channel,
required String country,

View File

@@ -5,28 +5,23 @@ import 'package:sf_tracking/src/clients/firebase_tracking_client.dart';
import 'package:sf_tracking/src/sf_tracking_repository.dart';
import 'package:sf_tracking/src/tracking_client.dart';
/// Singleton instance of [SfTrackingRepository] used by both the Riverpod
/// provider AND the router listener wired in `SaveFamilyApp`.
///
/// We need this as a top-level singleton (not just a Riverpod provider)
/// because tracking has to be available outside Riverpod contexts (e.g. in
/// `init_app.dart` orchestration code, or in plain Dart classes that don't
/// have access to a `WidgetRef`).
///
/// Both the singleton and the Riverpod provider return the same instance, so
/// every consumer agrees on which clients are registered and what state they
/// hold (consent, user id, etc).
final SfTrackingRepository sfTracking = SfTrackingRepository(
clients: <TrackingClient>[
// Default consent is `true` for the first iteration. When we add a
// GDPR consent screen, this should read from a persisted preference and
// call `sfTracking.setConsentStatus(...)` whenever the user toggles it.
FirebaseTrackingClient(consentAnalytics: true),
if (kDebugMode) const DebugTrackingClient(),
],
);
/// Top-level singleton consumed both by [sfTrackingProvider] and by
/// non-Riverpod call sites (e.g. the router setup in `init_app.dart`).
/// Must be built via [initSfTracking] AFTER `Firebase.initializeApp` —
/// [FirebaseTrackingClient] touches `FirebaseAnalytics.instance` in its
/// constructor.
late final SfTrackingRepository sfTracking;
void initSfTracking() {
sfTracking = SfTrackingRepository(
clients: <TrackingClient>[
// TODO: read consent from a persisted preference once the GDPR
// consent screen exists, and call `sfTracking.setConsentStatus(...)`
// when the user toggles it.
FirebaseTrackingClient(consentAnalytics: true),
if (kDebugMode) const DebugTrackingClient(),
],
);
}
/// Riverpod provider that exposes the same [sfTracking] singleton. Use this
/// from view models, screens, and any consumer that already has access to
/// `ref`.
final sfTrackingProvider = Provider<SfTrackingRepository>((ref) => sfTracking);

View File

@@ -5,7 +5,6 @@ import 'package:sf_tracking/src/mixins/account_tracking.dart';
import 'package:sf_tracking/src/mixins/auth_tracking.dart';
import 'package:sf_tracking/src/mixins/contacts_tracking.dart';
import 'package:sf_tracking/src/mixins/control_panel_tracking.dart';
import 'package:sf_tracking/src/mixins/dashboard_tracking.dart';
import 'package:sf_tracking/src/mixins/device_setup_tracking.dart';
import 'package:sf_tracking/src/mixins/device_tracking.dart';
import 'package:sf_tracking/src/mixins/location_tracking.dart';
@@ -16,16 +15,12 @@ import 'package:sf_tracking/src/navigation/navigation_tracking.dart';
import 'package:sf_tracking/src/tracking.dart';
import 'package:sf_tracking/src/tracking_client.dart';
/// Concrete tracking repository for SaveFamily.
///
/// Extends [Tracking] (the abstract base every mixin uses via `on Tracking`)
/// and mixes in feature-specific tracking mixins. Each call is broadcast to
/// every registered [TrackingClient]. Failures in any client are caught and
/// logged so analytics never breaks the app.
/// Mixes in every feature tracking mixin and broadcasts each call to all
/// registered [TrackingClient]s. Per-client failures are swallowed so a
/// buggy vendor never breaks the app.
class SfTrackingRepository extends Tracking
with
AuthTracking,
DashboardTracking,
DeviceTracking,
DeviceSetupTracking,
ContactsTracking,
@@ -36,74 +31,54 @@ class SfTrackingRepository extends Tracking
LocationTracking,
ControlPanelTracking
implements NavigationTracking {
SfTrackingRepository({required List<TrackingClient> clients}) : _clients = clients;
SfTrackingRepository({required List<TrackingClient> clients})
: _clients = clients;
final List<TrackingClient> _clients;
@override
Future<void> setAnalyticsStatus({bool enabled = true}) async {
/// Broadcasts an operation to every registered client, swallowing
/// per-client exceptions so a buggy vendor never breaks the app. [op] is
/// the human-readable operation name used in error logs.
Future<void> _broadcast(
String op,
Future<void> Function(TrackingClient client) action,
) async {
for (final client in _clients) {
try {
await client.setAnalyticsStatus(enabled: enabled);
await action(client);
} catch (e) {
debugPrint('[SfTracking] setAnalyticsStatus failed on ${client.runtimeType}: $e');
debugPrint('[SfTracking] $op failed on ${client.runtimeType}: $e');
}
}
}
@override
Future<void> setConsentStatus(bool hasConsent) async {
for (final client in _clients) {
try {
await client.setConsentStatus(hasConsent);
} catch (e) {
debugPrint('[SfTracking] setConsentStatus failed on ${client.runtimeType}: $e');
}
}
}
Future<void> setAnalyticsStatus({bool enabled = true}) =>
_broadcast('setAnalyticsStatus', (c) => c.setAnalyticsStatus(enabled: enabled));
@override
Future<void> setUserId(String? userId) async {
for (final client in _clients) {
try {
await client.setUserId(userId);
} catch (e) {
debugPrint('[SfTracking] setUserId failed on ${client.runtimeType}: $e');
}
}
}
Future<void> setConsentStatus(bool hasConsent) =>
_broadcast('setConsentStatus', (c) => c.setConsentStatus(hasConsent));
@override
Future<void> setUserProperty(String name, String value) async {
for (final client in _clients) {
try {
await client.setUserProperty(name, value);
} catch (e) {
debugPrint('[SfTracking] setUserProperty($name) failed on ${client.runtimeType}: $e');
}
}
}
Future<void> setUserId(String? userId) =>
_broadcast('setUserId', (c) => c.setUserId(userId));
@override
Future<void> setUserProperty(String name, String value) =>
_broadcast('setUserProperty($name)', (c) => c.setUserProperty(name, value));
@protected
@override
Future<void> trackEvent(String name, [Map<String, Object>? parameters]) async {
for (final client in _clients) {
try {
await client.track(name, parameters);
} catch (e) {
debugPrint('[SfTracking] trackEvent($name) failed on ${client.runtimeType}: $e');
}
}
}
Future<void> trackEvent(String name, [Map<String, Object>? parameters]) =>
_broadcast('trackEvent($name)', (c) => c.track(name, parameters));
@override
Future<void> trackScreenView({required String screenName, Map<String, Object>? parameters}) async {
for (final client in _clients) {
try {
await client.trackScreenView(screenName, parameters);
} catch (e) {
debugPrint('[SfTracking] trackScreenView($screenName) failed on ${client.runtimeType}: $e');
}
}
}
Future<void> trackScreenView({
required String screenName,
Map<String, Object>? parameters,
}) => _broadcast(
'trackScreenView($screenName)',
(c) => c.trackScreenView(screenName, parameters),
);
}

View File

@@ -2,12 +2,8 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
/// Base class for the tracking repository. Mixins use `on Tracking` to access
/// [trackEvent] and [setUserProperty] without depending on a concrete
/// implementation.
///
/// `SfTrackingRepository` extends this and mixes in feature-specific tracking
/// mixins (`AuthTracking`, `DeviceTracking`, etc).
/// Base class every feature mixin uses via `on Tracking`. Concrete
/// implementations live in `SfTrackingRepository`.
abstract class Tracking {
@protected
Future<void> trackEvent(String name, [Map<String, Object>? parameters]);

View File

@@ -1,29 +1,25 @@
import 'dart:async';
/// Contract for any tracking backend (Firebase Analytics, AppsFlyer, Mixpanel, etc).
///
/// New vendors implement this interface and are added to the list passed to
/// [SfTrackingRepository]. Each event is broadcast to every registered client.
/// Contract for any tracking backend (Firebase Analytics, AppsFlyer,
/// Mixpanel, etc). Implementations are registered with [SfTrackingRepository]
/// and receive every broadcast call.
abstract class TrackingClient {
/// Enables or disables analytics collection at the SDK level.
Future<void> setAnalyticsStatus({bool enabled = true});
/// Updates GDPR consent flags. Implementations should also propagate this
/// state to subsequent events (e.g. via a `consent_status` parameter) so
/// that data warehouses can filter out non-consented events.
/// Implementations should also propagate consent state to subsequent
/// events (e.g. via a `consent_status` parameter) so the data warehouse
/// can filter non-consented events without losing them.
Future<void> setConsentStatus(bool hasConsent);
/// Sets a user-level property (e.g. `user_role`, `user_language`).
Future<void> setUserProperty(String name, String value);
/// Identifies the current user. Pass `null` on logout to clear it.
/// Pass `null` on logout to clear it.
Future<void> setUserId(String? userId);
/// Logs a custom event.
Future<void> track(String name, [Map<String, Object>? parameters]);
/// Logs a screen view. Vendors that have a dedicated screen-view API
/// (like Firebase Analytics) should use it; others can fall back to a
/// regular event.
Future<void> trackScreenView(String screenName, [Map<String, Object>? parameters]);
Future<void> trackScreenView(
String screenName, [
Map<String, Object>? parameters,
]);
}

View File

@@ -1,22 +1,15 @@
import 'package:sf_tracking/src/sf_tracking_repository.dart';
/// Helper that translates a domain user object into Firebase Analytics user
/// properties + user id.
/// Translates a domain user object into Firebase user properties + user id.
///
/// Decoupled from `UserEntity` (which lives in `sf_shared`) to avoid a
/// circular dependency: `sf_tracking` should not depend on `sf_shared`. The
/// caller (e.g. `save_family_app.dart`) extracts the fields it needs from
/// the entity and passes them as primitives.
/// Receives primitives (not `UserEntity`) on purpose: `sf_tracking` must not
/// depend on `sf_shared`, so the caller extracts the fields and passes them
/// as primitives to keep the package a leaf in the dep graph.
class UserInfoTrackingListener {
const UserInfoTrackingListener(this._tracking);
final SfTrackingRepository _tracking;
/// Call this whenever the logged-in user changes (login, profile reload).
///
/// [createdAtMillis] is the user's signup timestamp as milliseconds since
/// the Unix epoch (matches what `UserEntity.createdAt` returns from the
/// SaveFamily backend).
Future<void> onUserChanged({
required String userId,
required String role,
@@ -38,8 +31,6 @@ class UserInfoTrackingListener {
await _tracking.setUserProperty('user_has_api_key', hasApiKey.toString());
}
/// Call this on logout. Clears the user id so subsequent events are
/// attributed to an anonymous session.
Future<void> onUserCleared() async {
await _tracking.setUserId(null);
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sf_tracking/sf_tracking.dart';
/// Records every call made to it. Lets us assert that the repository
/// correctly fans out events to all registered clients.
class _RecordingClient implements TrackingClient {
final List<String> events = [];
final List<String> screenViews = [];
String? lastUserId;
final Map<String, String> userProperties = {};
bool? lastConsent;
bool? lastEnabled;
@override
Future<void> setAnalyticsStatus({bool enabled = true}) async {
lastEnabled = enabled;
}
@override
Future<void> setConsentStatus(bool hasConsent) async {
lastConsent = hasConsent;
}
@override
Future<void> setUserId(String? userId) async {
lastUserId = userId;
}
@override
Future<void> setUserProperty(String name, String value) async {
userProperties[name] = value;
}
@override
Future<void> track(String name, [Map<String, Object>? parameters]) async {
events.add(name);
}
@override
Future<void> trackScreenView(String screenName,
[Map<String, Object>? parameters]) async {
screenViews.add(screenName);
}
}
/// Throws on every call. Used to verify the repository's error isolation.
class _BrokenClient implements TrackingClient {
@override
Future<void> setAnalyticsStatus({bool enabled = true}) async {
throw StateError('boom');
}
@override
Future<void> setConsentStatus(bool hasConsent) async {
throw StateError('boom');
}
@override
Future<void> setUserId(String? userId) async {
throw StateError('boom');
}
@override
Future<void> setUserProperty(String name, String value) async {
throw StateError('boom');
}
@override
Future<void> track(String name, [Map<String, Object>? parameters]) async {
throw StateError('boom');
}
@override
Future<void> trackScreenView(String screenName,
[Map<String, Object>? parameters]) async {
throw StateError('boom');
}
}
void main() {
group('SfTrackingRepository', () {
test('broadcasts events to every registered client', () async {
final a = _RecordingClient();
final b = _RecordingClient();
final repo = SfTrackingRepository(clients: [a, b]);
await repo.legacyAuthLoginAttempt();
expect(a.events, ['legacy_auth_login_attempt']);
expect(b.events, ['legacy_auth_login_attempt']);
});
test('a broken client never aborts the chain', () async {
final good = _RecordingClient();
final repo = SfTrackingRepository(clients: [_BrokenClient(), good]);
// None of these should throw.
await repo.legacyAuthLoginAttempt();
await repo.trackScreenView(screenName: 'login');
await repo.setUserId('user-1');
await repo.setUserProperty('user_role', 'parent');
await repo.setConsentStatus(true);
await repo.setAnalyticsStatus(enabled: false);
expect(good.events, ['legacy_auth_login_attempt']);
expect(good.screenViews, ['login']);
expect(good.lastUserId, 'user-1');
expect(good.userProperties['user_role'], 'parent');
expect(good.lastConsent, true);
expect(good.lastEnabled, false);
});
test('user properties broadcast to every client', () async {
final a = _RecordingClient();
final b = _RecordingClient();
final repo = SfTrackingRepository(clients: [a, b]);
await repo.setUserProperty('user_role', 'admin');
await repo.setUserId('abc');
expect(a.userProperties['user_role'], 'admin');
expect(b.userProperties['user_role'], 'admin');
expect(a.lastUserId, 'abc');
expect(b.lastUserId, 'abc');
});
test('setUserId(null) clears identity on every client', () async {
final a = _RecordingClient()..lastUserId = 'previous';
final repo = SfTrackingRepository(clients: [a]);
await repo.setUserId(null);
expect(a.lastUserId, isNull);
});
test('screen views broadcast independently from track events', () async {
final a = _RecordingClient();
final repo = SfTrackingRepository(clients: [a]);
await repo.trackScreenView(screenName: 'home');
await repo.trackScreenView(screenName: 'profile');
expect(a.screenViews, ['home', 'profile']);
expect(a.events, isEmpty);
});
});
}

View File

@@ -0,0 +1,10 @@
/// Returns the number of full years between [from] and [to], using the
/// classic "have we already passed the birthday this year?" rule. Returns
/// 0 if [from] is in the future relative to [to].
int yearsBetween(DateTime from, DateTime to) {
var years = to.year - from.year;
final hasNotReachedAnniversary =
to.month < from.month || (to.month == from.month && to.day < from.day);
if (hasNotReachedAnniversary) years -= 1;
return years < 0 ? 0 : years;
}

View File

@@ -1,2 +1,3 @@
export 'src/date_utils.dart';
export 'src/size_utils.dart';
export 'src/test.dart';