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:
1
packages/sf_tracking/analysis_options.yaml
Normal file
1
packages/sf_tracking/analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
31
packages/sf_tracking/lib/sf_tracking.dart
Normal file
31
packages/sf_tracking/lib/sf_tracking.dart
Normal 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';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
packages/sf_tracking/lib/src/mixins/account_tracking.dart
Normal file
39
packages/sf_tracking/lib/src/mixins/account_tracking.dart
Normal 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');
|
||||
}
|
||||
73
packages/sf_tracking/lib/src/mixins/auth_tracking.dart
Normal file
73
packages/sf_tracking/lib/src/mixins/auth_tracking.dart
Normal 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');
|
||||
}
|
||||
16
packages/sf_tracking/lib/src/mixins/contacts_tracking.dart
Normal file
16
packages/sf_tracking/lib/src/mixins/contacts_tracking.dart
Normal 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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
15
packages/sf_tracking/lib/src/mixins/dashboard_tracking.dart
Normal file
15
packages/sf_tracking/lib/src/mixins/dashboard_tracking.dart
Normal 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});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
79
packages/sf_tracking/lib/src/mixins/device_tracking.dart
Normal file
79
packages/sf_tracking/lib/src/mixins/device_tracking.dart
Normal 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');
|
||||
}
|
||||
61
packages/sf_tracking/lib/src/mixins/location_tracking.dart
Normal file
61
packages/sf_tracking/lib/src/mixins/location_tracking.dart
Normal 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(),
|
||||
});
|
||||
}
|
||||
12
packages/sf_tracking/lib/src/mixins/onboarding_tracking.dart
Normal file
12
packages/sf_tracking/lib/src/mixins/onboarding_tracking.dart
Normal 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});
|
||||
}
|
||||
98
packages/sf_tracking/lib/src/mixins/settings_tracking.dart
Normal file
98
packages/sf_tracking/lib/src/mixins/settings_tracking.dart
Normal 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(),
|
||||
});
|
||||
}
|
||||
14
packages/sf_tracking/lib/src/mixins/support_tracking.dart
Normal file
14
packages/sf_tracking/lib/src/mixins/support_tracking.dart
Normal 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});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
109
packages/sf_tracking/lib/src/sf_tracking_repository.dart
Normal file
109
packages/sf_tracking/lib/src/sf_tracking_repository.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
packages/sf_tracking/lib/src/tracking.dart
Normal file
22
packages/sf_tracking/lib/src/tracking.dart
Normal 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});
|
||||
}
|
||||
29
packages/sf_tracking/lib/src/tracking_client.dart
Normal file
29
packages/sf_tracking/lib/src/tracking_client.dart
Normal 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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
22
packages/sf_tracking/pubspec.yaml
Normal file
22
packages/sf_tracking/pubspec.yaml
Normal 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:
|
||||
Reference in New Issue
Block a user