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:
@@ -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();
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class ContactViewModel extends Notifier<ContactViewState> {
|
||||
unawaited(
|
||||
_tracking.legacySupportContactInitiated(
|
||||
channel: 'email',
|
||||
country: country,
|
||||
country: country.isEmpty ? 'unknown' : country,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
147
packages/sf_tracking/test/sf_tracking_repository_test.dart
Normal file
147
packages/sf_tracking/test/sf_tracking_repository_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
10
packages/utils/lib/src/date_utils.dart
Normal file
10
packages/utils/lib/src/date_utils.dart
Normal 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;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'src/date_utils.dart';
|
||||
export 'src/size_utils.dart';
|
||||
export 'src/test.dart';
|
||||
|
||||
Reference in New Issue
Block a user