feat(tracking): add sf_tracking package and instrument legacy module

Introduces packages/sf_tracking — a multi-client, GDPR-first analytics layer with feature mixins, a GoRouter listener for automatic screen views, and a user properties helper that runs on login.
Wires the package into the legacy module 61 events
This commit is contained in:
2026-04-07 13:47:07 +02:00
parent d84c856ce7
commit 7b91447cad
71 changed files with 1342 additions and 10 deletions

View File

@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml

View File

@@ -0,0 +1,31 @@
/// 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';
export 'src/clients/firebase_tracking_client.dart';
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';
export 'src/mixins/onboarding_tracking.dart';
export 'src/mixins/settings_tracking.dart';
export 'src/mixins/support_tracking.dart';
export 'src/navigation/navigation_tracking.dart';
export 'src/navigation/sf_router_listener.dart';
export 'src/providers/sf_tracking_provider.dart';
export 'src/sf_tracking_repository.dart';
export 'src/tracking.dart';
export 'src/tracking_client.dart';
export 'src/user_properties/user_info_tracking_listener.dart';

View File

@@ -0,0 +1,42 @@
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.
class DebugTrackingClient implements TrackingClient {
const DebugTrackingClient();
@override
Future<void> setAnalyticsStatus({bool enabled = true}) async {
debugPrint('[Tracking][debug] setAnalyticsStatus enabled=$enabled');
}
@override
Future<void> setConsentStatus(bool hasConsent) async {
debugPrint('[Tracking][debug] setConsentStatus hasConsent=$hasConsent');
}
@override
Future<void> setUserId(String? userId) async {
debugPrint('[Tracking][debug] setUserId $userId');
}
@override
Future<void> setUserProperty(String name, String value) async {
debugPrint('[Tracking][debug] setUserProperty $name=$value');
}
@override
Future<void> track(String name, [Map<String, Object>? parameters]) async {
debugPrint('[Tracking][debug] event $name params=$parameters');
}
@override
Future<void> trackScreenView(String screenName, [Map<String, Object>? parameters]) async {
debugPrint('[Tracking][debug] screen_view $screenName params=$parameters');
}
}

View File

@@ -0,0 +1,64 @@
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`.
class FirebaseTrackingClient implements TrackingClient {
FirebaseTrackingClient({required bool consentAnalytics})
: _hasConsent = consentAnalytics;
final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
bool _hasConsent;
@override
Future<void> setAnalyticsStatus({bool enabled = true}) {
return _analytics.setAnalyticsCollectionEnabled(enabled);
}
@override
Future<void> setConsentStatus(bool hasConsent) async {
_hasConsent = hasConsent;
await _analytics.setConsent(
adPersonalizationSignalsConsentGranted: hasConsent,
adUserDataConsentGranted: hasConsent,
adStorageConsentGranted: hasConsent,
analyticsStorageConsentGranted: hasConsent,
);
}
@override
Future<void> setUserId(String? userId) {
return _analytics.setUserId(id: userId);
}
@override
Future<void> setUserProperty(String name, String value) {
return _analytics.setUserProperty(name: name, value: value);
}
@override
Future<void> track(String name, [Map<String, Object>? parameters]) {
return _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),
);
}
Map<String, Object> _withConsent(Map<String, Object>? parameters) {
return {
'consent_status': _hasConsent.toString(),
...?parameters,
};
}
}

View File

@@ -0,0 +1,39 @@
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');
/// ⚠️ 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

@@ -0,0 +1,73 @@
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 ───────────────────────────────────────────────────────────────
/// User submitted the login form (after client-side validation passed).
Future<void> legacyAuthLoginAttempt() => trackEvent('${_prefix}_login_attempt');
/// 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 ─────────────────────────────────────────────────────────────────
/// 2FA code was successfully requested from the backend.
Future<void> legacyAuth2faRequested() => trackEvent('${_prefix}_2fa_requested');
/// 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});
// ─── 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');
// ─── Phone linking ───────────────────────────────────────────────────────
/// User submitted a phone number to receive an OTP code for phone linking.
Future<void> legacyAuthLinkPhoneCodeRequested() =>
trackEvent('${_prefix}_link_phone_code_requested');
/// User successfully verified the OTP code and the phone is linked.
Future<void> legacyAuthLinkPhoneCodeVerified() =>
trackEvent('${_prefix}_link_phone_code_verified');
// ─── Logout ──────────────────────────────────────────────────────────────
/// User logged out and the session has been cleared.
Future<void> legacyAuthLogout() => trackEvent('${_prefix}_logout');
}

View File

@@ -0,0 +1,16 @@
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.
Future<void> legacyContactsAdded() => trackEvent('${_prefix}_added');
/// User edited an existing contact entry.
Future<void> legacyContactsEdited() => trackEvent('${_prefix}_edited');
/// User deleted a contact entry from the device.
Future<void> legacyContactsDeleted() => trackEvent('${_prefix}_deleted');
}

View File

@@ -0,0 +1,16 @@
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).
Future<void> legacyControlPanelDeviceSelected() =>
trackEvent('${_prefix}_device_selected');
/// User triggered a manual refresh of device positions (pull-to-refresh
/// or refresh button).
Future<void> legacyControlPanelPositionsRefreshed() =>
trackEvent('${_prefix}_positions_refreshed');
}

View File

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,35 @@
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).
Future<void> legacyDeviceSetupStepCompleted(String step) =>
trackEvent('${_prefix}_step_completed', {'step': step});
/// User finished the wizard and the device was successfully created.
Future<void> legacyDeviceSetupCompleted() => trackEvent('${_prefix}_completed');
/// 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,
}) =>
trackEvent('${_prefix}_failed', {
'at_step': atStep,
'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});
}

View File

@@ -0,0 +1,79 @@
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).
/// Mixed into `SfTrackingRepository`.
///
/// Pure screen views (health, apps_use, activity_meter, call_history,
/// background_image viewing) are tracked automatically by `SfRouterListener`
/// — only explicit user actions are listed here.
mixin DeviceTracking on Tracking {
/// User triggered a "locate device" command.
Future<void> legacyDeviceLocateRequested() =>
trackEvent('${_prefix}_locate_requested');
/// User entered the remote connection screen (active session start).
Future<void> legacyDeviceRemoteConnectionStarted() =>
trackEvent('${_prefix}_remote_connection_started');
/// 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,
}) =>
trackEvent('${_prefix}_volume_control_changed', {
'type': type,
'level': level,
});
/// User changed the device background image.
Future<void> legacyDeviceBackgroundImageChanged() =>
trackEvent('${_prefix}_background_image_changed');
/// User added a scheduled activity (alarm, reminder, etc on the device).
Future<void> legacyDeviceScheduledActivityAdded() =>
trackEvent('${_prefix}_scheduled_activity_added');
/// User removed a scheduled activity.
Future<void> legacyDeviceScheduledActivityRemoved() =>
trackEvent('${_prefix}_scheduled_activity_removed');
/// User assigned reward minutes to the device.
Future<void> legacyDeviceRewardsGranted({required int amount}) =>
trackEvent('${_prefix}_rewards_granted', {'amount': amount});
/// 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 uploaded a custom photo to use as device background.
Future<void> legacyDeviceBackgroundImageUploaded() =>
trackEvent('${_prefix}_background_image_uploaded');
/// User edited a previously created scheduled activity.
Future<void> legacyDeviceScheduledActivityUpdated() =>
trackEvent('${_prefix}_scheduled_activity_updated');
/// 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 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');
}

View File

@@ -0,0 +1,61 @@
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');
Future<void> legacyLocationGeofenceUpdated() =>
trackEvent('${_prefix}_geofence_updated');
Future<void> legacyLocationGeofenceDeleted() =>
trackEvent('${_prefix}_geofence_deleted');
// ─── Frequent places ─────────────────────────────────────────────────────
Future<void> legacyLocationFrequentPlaceCreated() =>
trackEvent('${_prefix}_frequent_place_created');
Future<void> legacyLocationFrequentPlaceUpdated() =>
trackEvent('${_prefix}_frequent_place_updated');
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(),
});
Future<void> legacyLocationMapFrequentPlacesToggled(bool visible) =>
trackEvent('${_prefix}_map_frequent_places_toggled', {
'visible': visible.toString(),
});
Future<void> legacyLocationMapRouteTrailToggled(bool visible) =>
trackEvent('${_prefix}_map_route_trail_toggled', {
'visible': visible.toString(),
});
}

View File

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,98 @@
import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_settings';
/// Tracking events for the legacy settings module (alarms, SOS, language,
/// alerts, timezone, wifi, etc). Mixed into `SfTrackingRepository`.
mixin SettingsTracking on Tracking {
// ─── Alarms ──────────────────────────────────────────────────────────────
Future<void> legacySettingsAlarmAdded() =>
trackEvent('${_prefix}_alarm_added');
Future<void> legacySettingsAlarmRemoved() =>
trackEvent('${_prefix}_alarm_removed');
// ─── SOS contacts ────────────────────────────────────────────────────────
Future<void> legacySettingsSosContactAdded() =>
trackEvent('${_prefix}_sos_contact_added');
Future<void> legacySettingsSosContactRemoved() =>
trackEvent('${_prefix}_sos_contact_removed');
// ─── Block phone whitelist ───────────────────────────────────────────────
Future<void> legacySettingsBlockPhoneContactAdded() =>
trackEvent('${_prefix}_block_phone_contact_added');
Future<void> legacySettingsBlockPhoneContactRemoved() =>
trackEvent('${_prefix}_block_phone_contact_removed');
// ─── Disable functions (parental controls) ───────────────────────────────
/// User toggled a function (e.g. `keyboard`, `gps`).
Future<void> legacySettingsDisableFunctionsChanged() =>
trackEvent('${_prefix}_disable_functions_changed');
// ─── Language ────────────────────────────────────────────────────────────
Future<void> legacySettingsLanguageChanged(String language) =>
trackEvent('${_prefix}_language_changed', {'language': language});
// ─── Alerts ──────────────────────────────────────────────────────────────
Future<void> legacySettingsAlertsConfigured() =>
trackEvent('${_prefix}_alerts_configured');
// ─── Timezone ────────────────────────────────────────────────────────────
Future<void> legacySettingsTimezoneChanged(String timezone) =>
trackEvent('${_prefix}_timezone_changed', {'timezone': timezone});
// ─── WiFi ────────────────────────────────────────────────────────────────
Future<void> legacySettingsWifiAdded() =>
trackEvent('${_prefix}_wifi_added');
Future<void> legacySettingsWifiRemoved() =>
trackEvent('${_prefix}_wifi_removed');
// ─── Sound ───────────────────────────────────────────────────────────────
Future<void> legacySettingsSoundChanged() =>
trackEvent('${_prefix}_sound_changed');
// ─── Sync clock ──────────────────────────────────────────────────────────
Future<void> legacySettingsSyncClockTriggered() =>
trackEvent('${_prefix}_sync_clock_triggered');
// ─── Alarms (extra) ──────────────────────────────────────────────────────
/// User edited an existing alarm.
Future<void> legacySettingsAlarmUpdated() =>
trackEvent('${_prefix}_alarm_updated');
// ─── Battery ─────────────────────────────────────────────────────────────
/// User toggled night mode (battery saving) on the device.
Future<void> legacySettingsBatteryNightModeToggled(bool enabled) =>
trackEvent('${_prefix}_battery_night_mode_toggled', {
'enabled': enabled.toString(),
});
// ─── Disable functions (granular) ────────────────────────────────────────
/// 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(),
});
}

View File

@@ -0,0 +1,14 @@
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. Note that this only
/// tracks the *intent* (the button tap that opens the external client). We
/// can't confirm the message was actually sent.
Future<void> legacySupportContactInitiated(String channel) =>
trackEvent('${_prefix}_contact_initiated', {'channel': channel});
}

View File

@@ -0,0 +1,5 @@
/// Minimal interface for components that only need to log screen views
/// (typically navigation observers). Implemented by `SfTrackingRepository`.
abstract class NavigationTracking {
Future<void> trackScreenView({required String screenName, Map<String, Object>? parameters});
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/foundation.dart';
import 'package:sf_tracking/src/navigation/navigation_tracking.dart';
/// Listens to a router's [Listenable] (typically `GoRouter.routerDelegate`)
/// and forwards route changes to a [NavigationTracking] as `screen_view`
/// events.
///
/// We use a router listener instead of a `RouteObserver` because go_router
/// 17.x does not propagate the `name:` field of a `GoRoute` to
/// `Route.settings.name`, which means a standard `RouteObserver` always sees
/// `null` screen names. The listener pattern reads the canonical name
/// directly from the router's current configuration.
///
/// Subscribing to the routerDelegate also gives us tab changes inside a
/// `StatefulShellRoute.indexedStack` for free, because `goBranch(...)`
/// updates the router configuration just like a normal navigation.
class SfRouterListener {
SfRouterListener({
required Listenable listenable,
required String? Function() currentScreenName,
required NavigationTracking tracking,
}) : _listenable = listenable,
_currentScreenName = currentScreenName,
_tracking = tracking {
_listenable.addListener(_onRouteChanged);
}
final Listenable _listenable;
final String? Function() _currentScreenName;
final NavigationTracking _tracking;
String? _lastScreenName;
void _onRouteChanged() {
final screenName = _currentScreenName();
if (screenName == null || screenName.isEmpty) return;
if (screenName == _lastScreenName) return;
_lastScreenName = screenName;
_tracking
.trackScreenView(screenName: screenName)
.catchError(
(Object error) => debugPrint('[SfRouterListener] $screenName failed: $error'),
);
}
void dispose() {
_listenable.removeListener(_onRouteChanged);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_tracking/src/clients/debug_tracking_client.dart';
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(),
],
);
/// 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

@@ -0,0 +1,109 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
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';
import 'package:sf_tracking/src/mixins/onboarding_tracking.dart';
import 'package:sf_tracking/src/mixins/settings_tracking.dart';
import 'package:sf_tracking/src/mixins/support_tracking.dart';
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.
class SfTrackingRepository extends Tracking
with
AuthTracking,
DashboardTracking,
DeviceTracking,
DeviceSetupTracking,
ContactsTracking,
AccountTracking,
SettingsTracking,
SupportTracking,
OnboardingTracking,
LocationTracking,
ControlPanelTracking
implements NavigationTracking {
SfTrackingRepository({required List<TrackingClient> clients}) : _clients = clients;
final List<TrackingClient> _clients;
@override
Future<void> setAnalyticsStatus({bool enabled = true}) async {
for (final client in _clients) {
try {
await client.setAnalyticsStatus(enabled: enabled);
} catch (e) {
debugPrint('[SfTracking] setAnalyticsStatus 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');
}
}
}
@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');
}
}
}
@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');
}
}
}
@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');
}
}
}
@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');
}
}
}
}

View File

@@ -0,0 +1,22 @@
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).
abstract class Tracking {
@protected
Future<void> trackEvent(String name, [Map<String, Object>? parameters]);
Future<void> setUserProperty(String name, String value);
Future<void> setUserId(String? userId);
Future<void> setConsentStatus(bool hasConsent);
Future<void> setAnalyticsStatus({bool enabled = true});
}

View File

@@ -0,0 +1,29 @@
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.
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.
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.
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]);
}

View File

@@ -0,0 +1,46 @@
import 'package:sf_tracking/src/sf_tracking_repository.dart';
/// Helper that translates a domain user object into Firebase Analytics 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.
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,
required String language,
required int createdAtMillis,
required bool hasPhone,
required bool hasApiKey,
}) async {
await _tracking.setUserId(userId);
await _tracking.setUserProperty('user_role', role);
await _tracking.setUserProperty('user_language', language);
await _tracking.setUserProperty(
'user_signup_date',
DateTime.fromMillisecondsSinceEpoch(createdAtMillis)
.toUtc()
.toIso8601String(),
);
await _tracking.setUserProperty('user_has_phone', hasPhone.toString());
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,22 @@
name: sf_tracking
description: "Tracking and analytics abstraction for SaveFamily. Multi-client (Firebase, future AppsFlyer/etc), GDPR-first."
version: 0.0.1
resolution: workspace
publish_to: 'none'
environment:
sdk: ^3.9.2
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
firebase_analytics: ^12.2.0
flutter_riverpod: ^3.0.3
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter: