From 7b91447cad8f29a28f025d2fe6ce170772274b6b Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Tue, 7 Apr 2026 13:47:07 +0200 Subject: [PATCH] feat(tracking): add sf_tracking package and instrument legacy module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .vscode/launch.json | 18 ++- apps/mobile_app/android/app/build.gradle.kts | 7 ++ apps/mobile_app/lib/core/init_app.dart | 7 +- apps/mobile_app/lib/save_family_app.dart | 26 +++++ apps/mobile_app/pubspec.yaml | 2 + .../state/account_settings_view_model.dart | 8 ++ .../state/change_password_view_model.dart | 7 ++ .../state/delete_account_view_model.dart | 12 ++ .../state/linked_devices_view_model.dart | 7 ++ .../state/personal_data_view_model.dart | 7 ++ modules/legacy/modules/account/pubspec.yaml | 2 + .../state/control_panel_view_model.dart | 8 ++ .../legacy/modules/control_panel/pubspec.yaml | 2 + .../state/contact_view_model.dart | 9 ++ .../modules/customer_service/pubspec.yaml | 2 + .../state/activity_meter_view_model.dart | 8 ++ .../state/background_image_view_model.dart | 9 ++ .../state/contacts_view_model.dart | 11 ++ .../presentation/state/health_view_model.dart | 10 ++ .../state/locate_device_view_model.dart | 7 ++ .../state/remote_connection_view_model.dart | 10 ++ .../state/rewards_view_model.dart | 7 ++ .../scheduled_activities_view_model.dart | 11 ++ .../state/volume_control_view_model.dart | 13 +++ .../modules/device_management/pubspec.yaml | 2 + .../state/device_setup_view_model.dart | 28 +++++ .../state/link_phone_view_model.dart | 9 ++ .../presentation/state/login_view_model.dart | 16 +++ .../presentation/onboarding_view_model.dart | 7 ++ .../state/recover_password_view_model.dart | 9 ++ .../state/sign_up_view_model.dart | 9 ++ .../legacy/modules/legacy_auth/pubspec.yaml | 2 + .../state/location_map_view_model.dart | 14 ++- .../state/location_view_model.dart | 33 +++++- modules/legacy/modules/location/pubspec.yaml | 2 + .../presentation/state/alarm_view_model.dart | 11 ++ .../presentation/state/alerts_view_model.dart | 8 ++ .../state/battery_view_model.dart | 8 ++ .../state/block_phone_view_model.dart | 9 ++ .../state/disable_functions_view_model.dart | 12 ++ .../state/language_view_model.dart | 8 ++ .../state/sos_contacts_view_model.dart | 9 ++ .../presentation/state/sound_view_model.dart | 8 ++ .../state/sync_clock_view_model.dart | 6 + .../state/timezone_view_model.dart | 10 ++ .../state/wifi_settings_view_model.dart | 9 ++ modules/legacy/modules/settings/pubspec.yaml | 2 + packages/sf_tracking/analysis_options.yaml | 1 + packages/sf_tracking/lib/sf_tracking.dart | 31 +++++ .../src/clients/debug_tracking_client.dart | 42 +++++++ .../src/clients/firebase_tracking_client.dart | 64 ++++++++++ .../lib/src/mixins/account_tracking.dart | 39 +++++++ .../lib/src/mixins/auth_tracking.dart | 73 ++++++++++++ .../lib/src/mixins/contacts_tracking.dart | 16 +++ .../src/mixins/control_panel_tracking.dart | 16 +++ .../lib/src/mixins/dashboard_tracking.dart | 15 +++ .../lib/src/mixins/device_setup_tracking.dart | 35 ++++++ .../lib/src/mixins/device_tracking.dart | 79 +++++++++++++ .../lib/src/mixins/location_tracking.dart | 61 ++++++++++ .../lib/src/mixins/onboarding_tracking.dart | 12 ++ .../lib/src/mixins/settings_tracking.dart | 98 ++++++++++++++++ .../lib/src/mixins/support_tracking.dart | 14 +++ .../src/navigation/navigation_tracking.dart | 5 + .../src/navigation/sf_router_listener.dart | 49 ++++++++ .../src/providers/sf_tracking_provider.dart | 32 +++++ .../lib/src/sf_tracking_repository.dart | 109 ++++++++++++++++++ packages/sf_tracking/lib/src/tracking.dart | 22 ++++ .../sf_tracking/lib/src/tracking_client.dart | 29 +++++ .../user_info_tracking_listener.dart | 46 ++++++++ packages/sf_tracking/pubspec.yaml | 22 ++++ pubspec.yaml | 1 + 71 files changed, 1342 insertions(+), 10 deletions(-) create mode 100644 packages/sf_tracking/analysis_options.yaml create mode 100644 packages/sf_tracking/lib/sf_tracking.dart create mode 100644 packages/sf_tracking/lib/src/clients/debug_tracking_client.dart create mode 100644 packages/sf_tracking/lib/src/clients/firebase_tracking_client.dart create mode 100644 packages/sf_tracking/lib/src/mixins/account_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/auth_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/contacts_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/control_panel_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/dashboard_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/device_setup_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/device_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/location_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/onboarding_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/settings_tracking.dart create mode 100644 packages/sf_tracking/lib/src/mixins/support_tracking.dart create mode 100644 packages/sf_tracking/lib/src/navigation/navigation_tracking.dart create mode 100644 packages/sf_tracking/lib/src/navigation/sf_router_listener.dart create mode 100644 packages/sf_tracking/lib/src/providers/sf_tracking_provider.dart create mode 100644 packages/sf_tracking/lib/src/sf_tracking_repository.dart create mode 100644 packages/sf_tracking/lib/src/tracking.dart create mode 100644 packages/sf_tracking/lib/src/tracking_client.dart create mode 100644 packages/sf_tracking/lib/src/user_properties/user_info_tracking_listener.dart create mode 100644 packages/sf_tracking/pubspec.yaml diff --git a/.vscode/launch.json b/.vscode/launch.json index 551f7626..843adf91 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,8 @@ "cwd": "apps/mobile_app", "request": "launch", "type": "dart", - "args": [ + "program": "lib/main_development.dart", + "toolArgs": [ "--flavor", "development", "--dart-define-from-file=config/development.json", @@ -25,7 +26,8 @@ "cwd": "apps/mobile_app", "request": "launch", "type": "dart", - "args": [ + "program": "lib/main_development.dart", + "toolArgs": [ "--flavor", "development", "--dart-define-from-file=config/development.json", @@ -37,7 +39,8 @@ "cwd": "apps/mobile_app", "request": "launch", "type": "dart", - "args": [ + "program": "lib/main_staging.dart", + "toolArgs": [ "--flavor", "staging", "--dart-define-from-file=config/staging.json", @@ -49,7 +52,8 @@ "cwd": "apps/mobile_app", "request": "launch", "type": "dart", - "args": [ + "program": "lib/main_staging.dart", + "toolArgs": [ "--flavor", "staging", "--dart-define-from-file=config/staging.json", @@ -61,7 +65,8 @@ "cwd": "apps/mobile_app", "request": "launch", "type": "dart", - "args": [ + "program": "lib/main_production.dart", + "toolArgs": [ "--flavor", "production", "--dart-define-from-file=config/production.json", @@ -73,7 +78,8 @@ "cwd": "apps/mobile_app", "request": "launch", "type": "dart", - "args": [ + "program": "lib/main_production.dart", + "toolArgs": [ "--flavor", "production", "--dart-define-from-file=config/production.json", diff --git a/apps/mobile_app/android/app/build.gradle.kts b/apps/mobile_app/android/app/build.gradle.kts index 5d875c70..fe771d18 100644 --- a/apps/mobile_app/android/app/build.gradle.kts +++ b/apps/mobile_app/android/app/build.gradle.kts @@ -27,6 +27,9 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + // Required by flutter_local_notifications (and any future libs that + // need Java 8+ APIs on older Android API levels). + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -95,3 +98,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/apps/mobile_app/lib/core/init_app.dart b/apps/mobile_app/lib/core/init_app.dart index 996e3ca8..dfecbfda 100644 --- a/apps/mobile_app/lib/core/init_app.dart +++ b/apps/mobile_app/lib/core/init_app.dart @@ -21,12 +21,17 @@ Future initApp(EnvironmentEnum env) async { navigationModule(); scaTreezorModule(); - configureAppRouter(); 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. await setupFirebase(env); await setupNotifications(); + configureAppRouter(); + // TODO Fase 2: await initSentry(env); await configureDependencies( diff --git a/apps/mobile_app/lib/save_family_app.dart b/apps/mobile_app/lib/save_family_app.dart index eeee8a65..3a78957f 100644 --- a/apps/mobile_app/lib/save_family_app.dart +++ b/apps/mobile_app/lib/save_family_app.dart @@ -12,6 +12,7 @@ import 'package:sf_app_platform/providers/wallet_heartbeat_service.dart'; import 'package:get_it/get_it.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'package:sf_localizations/sf_localizations.dart'; import 'package:utils/utils.dart'; import 'package:fonts/fonts.dart'; @@ -27,12 +28,23 @@ class SaveFamilyAppState extends ConsumerState with WidgetsBindingObserver { WalletHeartbeatService? _walletHeartbeat; LegacyHeartbeatService? _legacyHeartbeat; + SfRouterListener? _trackingRouterListener; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _trackingRouterListener = SfRouterListener( + listenable: appRouter.routerDelegate, + currentScreenName: () { + final config = appRouter.routerDelegate.currentConfiguration; + if (config.matches.isEmpty) return null; + return config.last.route.name; + }, + tracking: sfTracking, + ); + if (isPaymentMode) { _walletHeartbeat = WalletHeartbeatService( repository: ref.read(treezorRepositoryProvider), @@ -75,6 +87,7 @@ class SaveFamilyAppState extends ConsumerState if (isLegacyMode) { appRouter.routerDelegate.removeListener(_onRouteChanged); } + _trackingRouterListener?.dispose(); _walletHeartbeat?.stop(); _legacyHeartbeat?.stop(); WidgetsBinding.instance.removeObserver(this); @@ -102,6 +115,19 @@ class SaveFamilyAppState extends ConsumerState Widget build(BuildContext context) { SizeUtils.init(context: context); + ref.listen>(userInfoProvider, (previous, next) { + next.whenData((user) { + UserInfoTrackingListener(ref.read(sfTrackingProvider)).onUserChanged( + userId: user.id, + role: user.role, + language: user.language, + createdAtMillis: user.createdAt, + hasPhone: user.phone.isNotEmpty, + hasApiKey: user.hasApiKey, + ); + }); + }); + return MaterialApp.router( title: 'SaveFamily', theme: ThemeData( diff --git a/apps/mobile_app/pubspec.yaml b/apps/mobile_app/pubspec.yaml index f526927e..d3b1e93e 100644 --- a/apps/mobile_app/pubspec.yaml +++ b/apps/mobile_app/pubspec.yaml @@ -79,6 +79,8 @@ dependencies: path: ../../packages/fonts sf_shared: path: ../../packages/sf_shared + sf_tracking: + path: ../../packages/sf_tracking sf_infrastructure: path: ../../packages/sf_infrastructure utils: diff --git a/modules/legacy/modules/account/lib/src/features/account_settings/presentation/state/account_settings_view_model.dart b/modules/legacy/modules/account/lib/src/features/account_settings/presentation/state/account_settings_view_model.dart index 3a01accf..36d1298a 100644 --- a/modules/legacy/modules/account/lib/src/features/account_settings/presentation/state/account_settings_view_model.dart +++ b/modules/legacy/modules/account/lib/src/features/account_settings/presentation/state/account_settings_view_model.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:account/src/features/account_settings/presentation/state/account_settings_view_state.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_auth/legacy_auth.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final accountSettingsViewModelProvider = NotifierProvider.autoDispose< @@ -10,8 +13,11 @@ final accountSettingsViewModelProvider = >(AccountSettingsViewModel.new); class AccountSettingsViewModel extends Notifier { + late final SfTrackingRepository _tracking; + @override AccountSettingsViewState build() { + _tracking = ref.read(sfTrackingProvider); return const AccountSettingsViewState(); } @@ -26,6 +32,8 @@ class AccountSettingsViewModel extends Notifier { await clearSessionData(); + unawaited(_tracking.legacyAuthLogout()); + state = state.copyWith(isLoggingOut: false); } } diff --git a/modules/legacy/modules/account/lib/src/features/change_password/presentation/state/change_password_view_model.dart b/modules/legacy/modules/account/lib/src/features/change_password/presentation/state/change_password_view_model.dart index 111adace..269e4cb7 100644 --- a/modules/legacy/modules/account/lib/src/features/change_password/presentation/state/change_password_view_model.dart +++ b/modules/legacy/modules/account/lib/src/features/change_password/presentation/state/change_password_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:account/src/features/change_password/domain/change_password_use_case.dart'; import 'package:account/src/features/change_password/domain/models/entities/change_password_request_entity.dart'; import 'package:account/src/features/change_password/presentation/providers/change_password_use_case_provider.dart'; @@ -5,6 +7,7 @@ import 'package:account/src/features/change_password/presentation/state/change_p import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final changePasswordViewModelProvider = NotifierProvider.autoDispose< @@ -14,6 +17,7 @@ final changePasswordViewModelProvider = class ChangePasswordViewModel extends Notifier { late final ChangePasswordUseCase _changePasswordUseCase; + late final SfTrackingRepository _tracking; late final TextEditingController newPasswordController; late final TextEditingController repeatPasswordController; @@ -22,6 +26,7 @@ class ChangePasswordViewModel extends Notifier { @override ChangePasswordViewState build() { _changePasswordUseCase = ref.read(changePasswordUseCaseProvider); + _tracking = ref.read(sfTrackingProvider); _initControllers(); @@ -123,6 +128,7 @@ class ChangePasswordViewModel extends Notifier { userId: user.id, request: request, ); + unawaited(_tracking.legacyAccountPasswordChanged()); state = state.copyWith(isLoading: false, isComplete: true); } catch (e) { if (!ref.mounted) return; @@ -132,6 +138,7 @@ class ChangePasswordViewModel extends Notifier { } void _finishWithError({required String message}) { + unawaited(_tracking.legacyAccountPasswordChangeFailed(message)); state = state.copyWith( isLoading: false, isComplete: false, diff --git a/modules/legacy/modules/account/lib/src/features/delete_account/presentation/state/delete_account_view_model.dart b/modules/legacy/modules/account/lib/src/features/delete_account/presentation/state/delete_account_view_model.dart index 71eb13c1..3f432977 100644 --- a/modules/legacy/modules/account/lib/src/features/delete_account/presentation/state/delete_account_view_model.dart +++ b/modules/legacy/modules/account/lib/src/features/delete_account/presentation/state/delete_account_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:account/src/core/domain/repositories/users_repository.dart'; import 'package:account/src/core/providers/users_repository_provider.dart'; import 'package:account/src/features/delete_account/presentation/state/delete_account_view_state.dart'; @@ -5,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final deleteAccountViewModelProvider = NotifierProvider.autoDispose< @@ -15,12 +18,16 @@ final deleteAccountViewModelProvider = class DeleteAccountViewModel extends Notifier { late final UsersRepository _usersRepository; late final SharedDevicesRepository _devicesRepository; + late final SfTrackingRepository _tracking; late final TextEditingController passwordController; @override DeleteAccountViewState build() { _usersRepository = ref.read(usersRepositoryProvider); _devicesRepository = ref.read(sharedDevicesRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); + + unawaited(_tracking.legacyAccountDeletionInitiated()); passwordController = TextEditingController(); passwordController.addListener(_onPasswordChanged); @@ -86,6 +93,7 @@ class DeleteAccountViewModel extends Notifier { } void resetConfirmStep() { + unawaited(_tracking.legacyAccountDeletionCancelled()); state = state.copyWith(confirmStep: 0); } @@ -95,9 +103,13 @@ class DeleteAccountViewModel extends Notifier { try { state = state.copyWith(isLoading: true, isComplete: false); + unawaited(_tracking.legacyAccountDeletionConfirmed()); + await _usersRepository.deleteUser(userId: state.loggedUser!.id); if (!ref.mounted) return; + unawaited(_tracking.legacyAccountDeletionCompleted()); + state = state.copyWith(isLoading: false, isComplete: true); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/account/lib/src/features/linked_devices/presentation/state/linked_devices_view_model.dart b/modules/legacy/modules/account/lib/src/features/linked_devices/presentation/state/linked_devices_view_model.dart index 1a9b2586..5813b70a 100644 --- a/modules/legacy/modules/account/lib/src/features/linked_devices/presentation/state/linked_devices_view_model.dart +++ b/modules/legacy/modules/account/lib/src/features/linked_devices/presentation/state/linked_devices_view_model.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:account/src/features/linked_devices/domain/entities/update_device_request_entity.dart'; import 'package:account/src/features/linked_devices/presentation/state/linked_devices_view_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../../../../core/domain/repositories/devices_repository.dart'; import '../../../../core/providers/devices_repository_provider.dart'; @@ -17,6 +20,7 @@ final linkedDevicesViewModelProvider = class LinkedDevicesViewModel extends Notifier { late final SharedDevicesRepository _getDevicesRepository; late final DevicesRepository _devicesRepository; + late final SfTrackingRepository _tracking; late final TextEditingController deviceNameController; @@ -24,6 +28,7 @@ class LinkedDevicesViewModel extends Notifier { LinkedDevicesViewState build() { _getDevicesRepository = ref.read(sharedDevicesRepositoryProvider); _devicesRepository = ref.read(devicesRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); _initControllers(); _init(); @@ -76,6 +81,8 @@ class LinkedDevicesViewModel extends Notifier { ref.invalidate(selectedDeviceProvider); } + unawaited(_tracking.legacyAccountLinkedDeviceUnlinked()); + state = state.copyWith( linkedDevices: newList, isLoading: false, diff --git a/modules/legacy/modules/account/lib/src/features/personal_data/presentation/state/personal_data_view_model.dart b/modules/legacy/modules/account/lib/src/features/personal_data/presentation/state/personal_data_view_model.dart index 577f33ca..36ec5684 100644 --- a/modules/legacy/modules/account/lib/src/features/personal_data/presentation/state/personal_data_view_model.dart +++ b/modules/legacy/modules/account/lib/src/features/personal_data/presentation/state/personal_data_view_model.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:account/src/features/personal_data/domain/entities/update_user_request_entity.dart'; import 'package:account/src/features/personal_data/presentation/state/personal_data_view_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../../../../core/domain/repositories/users_repository.dart'; import '../../../../core/providers/users_repository_provider.dart'; @@ -14,6 +17,7 @@ final personalDataViewModelProvider = class PersonalDataViewModel extends Notifier { late final UsersRepository _usersRepository; + late final SfTrackingRepository _tracking; late final TextEditingController firstNameController; late final TextEditingController lastNameController; @@ -22,6 +26,7 @@ class PersonalDataViewModel extends Notifier { @override PersonalDataViewState build() { _usersRepository = ref.read(usersRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); firstNameController = TextEditingController(); lastNameController = TextEditingController(); @@ -92,6 +97,8 @@ class PersonalDataViewModel extends Notifier { ); ref.read(userInfoProvider.notifier).setUser(updatedUser); + unawaited(_tracking.legacyAccountPersonalDataEdited()); + state = state.copyWith(isLoading: false, isComplete: true); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/account/pubspec.yaml b/modules/legacy/modules/account/pubspec.yaml index 3f9f06fc..b3a56828 100644 --- a/modules/legacy/modules/account/pubspec.yaml +++ b/modules/legacy/modules/account/pubspec.yaml @@ -29,6 +29,8 @@ dependencies: legacy_auth: path: ../../modules/legacy_auth #packages dependencies go here + sf_tracking: + path: ../../../../packages/sf_tracking design_system: path: ../../../../packages/design_system navigation: diff --git a/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/state/control_panel_view_model.dart b/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/state/control_panel_view_model.dart index bcece47a..8a11fe6d 100644 --- a/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/state/control_panel_view_model.dart +++ b/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/state/control_panel_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:control_panel/src/core/domain/entities/position_entity.dart'; import 'package:control_panel/src/core/domain/repositories/control_panel_repository.dart'; import 'package:control_panel/src/core/providers/control_panel_repository_provider.dart'; @@ -5,6 +7,7 @@ import 'package:control_panel/src/features/control_panel/presentation/state/cont import 'package:legacy_shared/legacy_shared.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final controlPanelViewModelProvider = NotifierProvider.autoDispose( @@ -15,12 +18,14 @@ class ControlPanelViewModel extends Notifier { late final ControlPanelRepository _controlPanelRepository; late final SharedDevicesRepository _devicesRepository; late final SelectedDeviceNotifier _selectedDeviceNotifier; + late final SfTrackingRepository _tracking; @override ControlPanelViewState build() { _controlPanelRepository = ref.read(controlPanelRepositoryProvider); _devicesRepository = ref.read(sharedDevicesRepositoryProvider); _selectedDeviceNotifier = ref.read(selectedDeviceProvider.notifier); + _tracking = ref.read(sfTrackingProvider); _init(); return const ControlPanelViewState(); } @@ -106,6 +111,7 @@ class ControlPanelViewModel extends Notifier { ); if (!ref.mounted) return; _applyPositions(positionLists); + unawaited(_tracking.legacyControlPanelPositionsRefreshed()); } catch (e) { if (!ref.mounted) return; state = state.copyWith(errorMessage: formatErrorMessage(e)); @@ -123,5 +129,7 @@ class ControlPanelViewModel extends Notifier { ); _selectedDeviceNotifier.setSelectedDevice(device); + + unawaited(_tracking.legacyControlPanelDeviceSelected()); } } diff --git a/modules/legacy/modules/control_panel/pubspec.yaml b/modules/legacy/modules/control_panel/pubspec.yaml index 5ae4db46..45ae3bbb 100644 --- a/modules/legacy/modules/control_panel/pubspec.yaml +++ b/modules/legacy/modules/control_panel/pubspec.yaml @@ -36,6 +36,8 @@ dependencies: legacy_dashboard_shell: path: ../../modules/legacy_dashboard_shell #packages dependencies go here + sf_tracking: + path: ../../../../packages/sf_tracking design_system: path: ../../../../packages/design_system navigation: diff --git a/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_model.dart b/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_model.dart index 4b34cf0e..8b8b250a 100644 --- a/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_model.dart +++ b/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_model.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:customer_service/src/presentation/state/contact_view_state.dart'; 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:url_launcher/url_launcher.dart'; final contactViewModelProvider = @@ -14,6 +17,7 @@ class ContactViewModel extends Notifier { late final TextEditingController emailController; late final TextEditingController subjectController; late final TextEditingController bodyController; + late final SfTrackingRepository _tracking; static final RegExp _emailRegex = RegExp( r'^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$', @@ -22,6 +26,8 @@ class ContactViewModel extends Notifier { @override ContactViewState build() { + _tracking = ref.read(sfTrackingProvider); + nameController = TextEditingController(); emailController = TextEditingController(); subjectController = TextEditingController(); @@ -116,6 +122,9 @@ class ContactViewModel extends Notifier { final Uri url = Uri.parse( 'mailto:$receiver?from=$sender&subject=$subject&body=$body', ); + + unawaited(_tracking.legacySupportContactInitiated('email')); + if (!await launchUrl(url)) { throw Exception('Could not launch $url'); } diff --git a/modules/legacy/modules/customer_service/pubspec.yaml b/modules/legacy/modules/customer_service/pubspec.yaml index 748408a2..65def90a 100644 --- a/modules/legacy/modules/customer_service/pubspec.yaml +++ b/modules/legacy/modules/customer_service/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: #modules dependencies go here #packages dependencies go here + sf_tracking: + path: ../../../../packages/sf_tracking design_system: path: ../../../../packages/design_system navigation: diff --git a/modules/legacy/modules/device_management/lib/src/features/activity_meter/presentation/state/activity_meter_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/activity_meter/presentation/state/activity_meter_view_model.dart index 7d3348bc..2163d96d 100644 --- a/modules/legacy/modules/device_management/lib/src/features/activity_meter/presentation/state/activity_meter_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/activity_meter/presentation/state/activity_meter_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../../../../core/data/datasources/health_query_builder.dart'; import '../../../../core/presentation/time_range.dart'; @@ -16,12 +19,14 @@ final activityMeterViewModelProvider = class ActivityMeterViewModel extends Notifier { late final StepsRepository _repository; + late final SfTrackingRepository _tracking; static const int _historyPageSize = 20; @override ActivityMeterViewState build() { _repository = ref.read(stepsRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); _init(); return const ActivityMeterViewState(); } @@ -262,6 +267,9 @@ class ActivityMeterViewModel extends Notifier { ); if (!ref.mounted) return false; ref.syncDeviceSettings(device, updatedSettings); + + unawaited(_tracking.legacyDeviceActivityPedometerToggled(enabled)); + return true; } catch (e) { if (!ref.mounted) return false; diff --git a/modules/legacy/modules/device_management/lib/src/features/background_image/presentation/state/background_image_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/background_image/presentation/state/background_image_view_model.dart index bb96fe85..9c26e703 100644 --- a/modules/legacy/modules/device_management/lib/src/features/background_image/presentation/state/background_image_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/background_image/presentation/state/background_image_view_model.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../../../../core/domain/repositories/background_image_repository.dart'; import '../../../../core/providers/background_image_repository_provider.dart'; @@ -16,11 +19,13 @@ final backgroundImageViewModelProvider = class BackgroundImageViewModel extends Notifier { late final BackgroundImageRepository _repository; late final SharedDevicesRepository _devicesRepository; + late final SfTrackingRepository _tracking; @override BackgroundImageViewState build() { _repository = ref.read(backgroundImageRepositoryProvider); _devicesRepository = ref.read(sharedDevicesRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const BackgroundImageViewState(); } @@ -85,6 +90,8 @@ class BackgroundImageViewModel extends Notifier { await _refreshDevice(device); if (!ref.mounted) return; + unawaited(_tracking.legacyDeviceBackgroundImageUploaded()); + state = state.copyWith( isSaving: false, successEvent: BackgroundImageSuccessEvent.uploaded, @@ -120,6 +127,8 @@ class BackgroundImageViewModel extends Notifier { await _refreshDevice(device); if (!ref.mounted) return; + unawaited(_tracking.legacyDeviceBackgroundImageChanged()); + state = state.copyWith( isSaving: false, successEvent: BackgroundImageSuccessEvent.backgroundSet, diff --git a/modules/legacy/modules/device_management/lib/src/features/contacts/presentation/state/contacts_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/contacts/presentation/state/contacts_view_model.dart index 7c478dff..301d31a6 100644 --- a/modules/legacy/modules/device_management/lib/src/features/contacts/presentation/state/contacts_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/contacts/presentation/state/contacts_view_model.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_localizations/sf_localizations.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'package:uuid/uuid.dart'; import '../../../../core/data/models/create_contact_request_model.dart'; @@ -20,10 +23,12 @@ class ContactsViewModel extends Notifier { static const _uuid = Uuid(); late final ContactsRepository _contactsRepository; + late final SfTrackingRepository _tracking; @override ContactsViewState build() { _contactsRepository = ref.read(contactsRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); _init(); return const ContactsViewState(); } @@ -85,6 +90,8 @@ class ContactsViewModel extends Notifier { await _contactsRepository.createContact(request: request); if (!ref.mounted) return false; + unawaited(_tracking.legacyContactsAdded()); + await _reload(); return true; } catch (e) { @@ -118,6 +125,8 @@ class ContactsViewModel extends Notifier { await _contactsRepository.updateContact(request: request); if (!ref.mounted) return false; + unawaited(_tracking.legacyContactsEdited()); + await _reload(); return true; } catch (e) { @@ -141,6 +150,8 @@ class ContactsViewModel extends Notifier { await _contactsRepository.deleteContact(contactId: contact.id); if (!ref.mounted) return false; + unawaited(_tracking.legacyContactsDeleted()); + await _reload(); return true; } catch (e) { diff --git a/modules/legacy/modules/device_management/lib/src/features/health/presentation/state/health_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/health/presentation/state/health_view_model.dart index cacf374f..44842c74 100644 --- a/modules/legacy/modules/device_management/lib/src/features/health/presentation/state/health_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/health/presentation/state/health_view_model.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../../../../core/data/datasources/health_query_builder.dart'; import '../../../../core/presentation/time_range.dart'; @@ -29,6 +30,7 @@ final healthViewModelProvider = class HealthViewModel extends Notifier { late final HealthRepository _repository; late final CommandsRepository _commandsRepository; + late final SfTrackingRepository _tracking; Timer? _measureTimer; static const int _historyPageSize = 20; @@ -38,6 +40,7 @@ class HealthViewModel extends Notifier { HealthViewState build() { _repository = ref.read(healthRepositoryProvider); _commandsRepository = ref.read(commandsRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); _init(); _resumeMeasureIfNeeded(); return const HealthViewState(); @@ -305,6 +308,11 @@ class HealthViewModel extends Notifier { ); if (!ref.mounted) return false; ref.syncDeviceSettings(device, updatedSettings); + + unawaited( + _tracking.legacyDeviceHealthHeartRateFrequencyChanged(frequency), + ); + return true; } catch (e) { if (!ref.mounted) return false; @@ -327,6 +335,8 @@ class HealthViewModel extends Notifier { await _commandsRepository.send(request: request); if (!ref.mounted) return; + unawaited(_tracking.legacyDeviceHealthMeasurementStarted()); + ref .read(_measureEndTimeProvider.notifier) .set( diff --git a/modules/legacy/modules/device_management/lib/src/features/locate_device/presentation/state/locate_device_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/locate_device/presentation/state/locate_device_view_model.dart index 7ffe36eb..ff961e84 100644 --- a/modules/legacy/modules/device_management/lib/src/features/locate_device/presentation/state/locate_device_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/locate_device/presentation/state/locate_device_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'locate_device_view_state.dart'; @@ -10,10 +13,12 @@ final locateDeviceViewModelProvider = class LocateDeviceViewModel extends Notifier { late final CommandsRepository _commandsRepository; + late final SfTrackingRepository _tracking; @override LocateDeviceViewState build() { _commandsRepository = ref.read(commandsRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); final device = ref.read(selectedDeviceProvider); @@ -36,6 +41,8 @@ class LocateDeviceViewModel extends Notifier { ); await _commandsRepository.send(request: request); + unawaited(_tracking.legacyDeviceLocateRequested()); + state = state.copyWith(isLoading: false, isComplete: true); } catch (e) { state = state.copyWith( diff --git a/modules/legacy/modules/device_management/lib/src/features/remote_connection/presentation/state/remote_connection_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/remote_connection/presentation/state/remote_connection_view_model.dart index f78fd4c3..76f3f2ac 100644 --- a/modules/legacy/modules/device_management/lib/src/features/remote_connection/presentation/state/remote_connection_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/remote_connection/presentation/state/remote_connection_view_model.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:device_management/src/features/remote_connection/presentation/state/remote_connection_view_state.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../../../../core/domain/repositories/pictures_repository.dart'; @@ -18,6 +19,7 @@ class RemoteConnectionViewModel extends Notifier { late final TextEditingController phoneController; late final CommandsRepository _commandsRepository; late final PicturesRepository _picturesRepository; + late final SfTrackingRepository _tracking; Timer? _photoTimer; static final RegExp _phoneRegex = RegExp(r'^\+?\d{6,15}$'); @@ -27,6 +29,9 @@ class RemoteConnectionViewModel extends Notifier { RemoteConnectionViewState build() { _commandsRepository = ref.read(commandsRepositoryProvider); _picturesRepository = ref.read(picturesRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); + + unawaited(_tracking.legacyDeviceRemoteConnectionStarted()); phoneController = TextEditingController(); phoneController.addListener(_onPhoneChanged); @@ -109,6 +114,8 @@ class RemoteConnectionViewModel extends Notifier { await _commandsRepository.send(request: request); if (!ref.mounted) return; + unawaited(_tracking.legacyDeviceRemoteConnectionPhotoTaken()); + state = state.copyWith( isTakingPicture: false, isWaitingForPhoto: true, @@ -188,6 +195,9 @@ class RemoteConnectionViewModel extends Notifier { await _commandsRepository.send(request: request); if (!ref.mounted) return; + + unawaited(_tracking.legacyDeviceRemoteConnectionCallInitiated()); + state = state.copyWith(isCalling: false); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/device_management/lib/src/features/rewards/presentation/state/rewards_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/rewards/presentation/state/rewards_view_model.dart index 153dbad9..e50f92ad 100644 --- a/modules/legacy/modules/device_management/lib/src/features/rewards/presentation/state/rewards_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/rewards/presentation/state/rewards_view_model.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'rewards_view_state.dart'; @@ -14,10 +17,12 @@ class RewardsViewModel extends Notifier { late final TextEditingController amountController; late final CommandsRepository _commandsRepository; + late final SfTrackingRepository _tracking; @override RewardsViewState build() { _commandsRepository = ref.read(commandsRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); amountController = TextEditingController(text: '1'); amountController.addListener(_onAmountChanged); @@ -66,6 +71,8 @@ class RewardsViewModel extends Notifier { await _commandsRepository.send(request: request); + unawaited(_tracking.legacyDeviceRewardsGranted(amount: state.amount)); + state = state.copyWith(isLoading: false, isComplete: true); } catch (e) { state = state.copyWith( diff --git a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_model.dart index 14ef7fef..f934c483 100644 --- a/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/scheduled_activities/presentation/state/scheduled_activities_view_model.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'package:uuid/uuid.dart'; import '../../../../core/data/models/create_scheduled_activity_request_model.dart'; @@ -19,11 +22,13 @@ final scheduledActivitiesViewModelProvider = class ScheduledActivitiesViewModel extends Notifier { late final ScheduledActivitiesRepository _repository; + late final SfTrackingRepository _tracking; static const _uuid = Uuid(); @override ScheduledActivitiesViewState build() { _repository = ref.read(scheduledActivitiesRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); _init(); return const ScheduledActivitiesViewState(); } @@ -98,6 +103,8 @@ class ScheduledActivitiesViewModel await _repository.createActivity(request: request); if (!ref.mounted) return; + unawaited(_tracking.legacyDeviceScheduledActivityAdded()); + await _reload(); } catch (e) { if (!ref.mounted) return; @@ -138,6 +145,8 @@ class ScheduledActivitiesViewModel ); if (!ref.mounted) return; + unawaited(_tracking.legacyDeviceScheduledActivityUpdated()); + await _reload(); } catch (e) { if (!ref.mounted) return; @@ -152,6 +161,8 @@ class ScheduledActivitiesViewModel await _repository.deleteActivity(activityId: activityId); if (!ref.mounted) return; + unawaited(_tracking.legacyDeviceScheduledActivityRemoved()); + await _reload(); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/device_management/lib/src/features/volume_control/presentation/state/volume_control_view_model.dart b/modules/legacy/modules/device_management/lib/src/features/volume_control/presentation/state/volume_control_view_model.dart index c0df8bed..bb6241c4 100644 --- a/modules/legacy/modules/device_management/lib/src/features/volume_control/presentation/state/volume_control_view_model.dart +++ b/modules/legacy/modules/device_management/lib/src/features/volume_control/presentation/state/volume_control_view_model.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'volume_control_view_state.dart'; @@ -12,10 +15,12 @@ final volumeControlViewModelProvider = class VolumeControlViewModel extends Notifier { late final DeviceSettingsUpdateDatasource _datasource; + late final SfTrackingRepository _tracking; @override VolumeControlViewState build() { _datasource = ref.read(deviceSettingsUpdateProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(() => _load()); return const VolumeControlViewState(); } @@ -70,6 +75,14 @@ class VolumeControlViewModel extends Notifier { if (!ref.mounted) return; ref.syncDeviceSettings(device, updatedSettings); + + unawaited( + _tracking.legacyDeviceVolumeControlChanged( + type: 'media', + level: state.media, + ), + ); + state = state.copyWith(isLoading: false, isComplete: true); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/device_management/pubspec.yaml b/modules/legacy/modules/device_management/pubspec.yaml index 1d98a9b0..25375956 100644 --- a/modules/legacy/modules/device_management/pubspec.yaml +++ b/modules/legacy/modules/device_management/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: #modules dependencies go here #packages dependencies go here + sf_tracking: + path: ../../../../packages/sf_tracking design_system: path: ../../../../packages/design_system navigation: diff --git a/modules/legacy/modules/legacy_auth/lib/src/features/device_setup/presentation/state/device_setup_view_model.dart b/modules/legacy/modules/legacy_auth/lib/src/features/device_setup/presentation/state/device_setup_view_model.dart index 96aed3b8..5d98209d 100644 --- a/modules/legacy/modules/legacy_auth/lib/src/features/device_setup/presentation/state/device_setup_view_model.dart +++ b/modules/legacy/modules/legacy_auth/lib/src/features/device_setup/presentation/state/device_setup_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:legacy_auth/src/core/domain/repositories/device_setup_repository.dart'; import 'package:legacy_auth/src/core/providers/device_setup_repository_provider.dart'; import 'package:legacy_auth/src/core/utils/date_format_utils.dart'; @@ -7,6 +9,7 @@ import 'package:legacy_auth/src/features/device_setup/presentation/enums/add_kid 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'; final legacyDeviceSetupViewModelProvider = NotifierProvider( @@ -15,6 +18,7 @@ final legacyDeviceSetupViewModelProvider = class LegacyDeviceSetupViewModel extends Notifier { late final LegacyDeviceSetupRepository _deviceSetupRepository; + late final SfTrackingRepository _tracking; late final TextEditingController firstNameController; late final TextEditingController lastNameController; @@ -29,6 +33,9 @@ class LegacyDeviceSetupViewModel extends Notifier { final initial = const LegacyDeviceSetupViewState(); _initControllers(initial); _addListeners(); + _tracking = ref.read(sfTrackingProvider); + + unawaited(_tracking.legacyDeviceSetupStarted()); ref.onDispose(disposeControllers); @@ -62,9 +69,11 @@ class LegacyDeviceSetupViewModel extends Notifier { Future next() async { switch (state.step) { case LegacyAddKidStep.intro: + unawaited(_tracking.legacyDeviceSetupStepCompleted('intro')); state = state.copyWith(step: LegacyAddKidStep.linkInfo); return; case LegacyAddKidStep.linkInfo: + unawaited(_tracking.legacyDeviceSetupStepCompleted('link_info')); state = state.copyWith(step: LegacyAddKidStep.scanWatch); return; case LegacyAddKidStep.scanWatch: @@ -90,9 +99,16 @@ class LegacyDeviceSetupViewModel extends Notifier { identificator: identificator, ); if (!ref.mounted) return; + unawaited(_tracking.legacyDeviceSetupStepCompleted('scan_watch')); state = state.copyWith(isLoading: false, step: LegacyAddKidStep.profile); } catch (e) { if (!ref.mounted) return; + unawaited( + _tracking.legacyDeviceSetupFailed( + atStep: 'scan_watch', + reason: e.toString(), + ), + ); state = state.copyWith(isLoading: false, errorMessage: e.toString()); } } @@ -102,12 +118,15 @@ class LegacyDeviceSetupViewModel extends Notifier { case LegacyAddKidStep.intro: return; case LegacyAddKidStep.linkInfo: + unawaited(_tracking.legacyDeviceSetupCancelled('link_info')); state = state.copyWith(step: LegacyAddKidStep.intro, errorMessage: ''); return; case LegacyAddKidStep.scanWatch: + unawaited(_tracking.legacyDeviceSetupCancelled('scan_watch')); state = state.copyWith(step: LegacyAddKidStep.linkInfo); return; case LegacyAddKidStep.profile: + unawaited(_tracking.legacyDeviceSetupCancelled('profile')); state = state.copyWith(step: LegacyAddKidStep.scanWatch); return; } @@ -154,11 +173,20 @@ class LegacyDeviceSetupViewModel extends Notifier { if (!ref.mounted) return false; + unawaited(_tracking.legacyDeviceSetupCompleted()); + state = state.copyWith(isLoading: false, isSuccess: true); return true; } catch (e) { if (!ref.mounted) return false; + unawaited( + _tracking.legacyDeviceSetupFailed( + atStep: 'profile', + reason: e.toString(), + ), + ); + state = state.copyWith(isLoading: false, errorMessage: e.toString()); return false; } diff --git a/modules/legacy/modules/legacy_auth/lib/src/features/link_phone/presentation/state/link_phone_view_model.dart b/modules/legacy/modules/legacy_auth/lib/src/features/link_phone/presentation/state/link_phone_view_model.dart index 9362af64..18662f54 100644 --- a/modules/legacy/modules/legacy_auth/lib/src/features/link_phone/presentation/state/link_phone_view_model.dart +++ b/modules/legacy/modules/legacy_auth/lib/src/features/link_phone/presentation/state/link_phone_view_model.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:legacy_auth/src/features/link_phone/presentation/providers/link_phone_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'package:legacy_auth/src/features/link_phone/domain/use_cases/link_phone_use_case.dart'; import 'package:legacy_auth/src/features/link_phone/presentation/state/link_phone_view_state.dart'; @@ -13,12 +16,14 @@ final legacyLinkPhoneViewModelProvider = class LegacyLinkPhoneViewModel extends Notifier { late final LegacyLinkPhoneUseCase _linkPhoneUseCase; + late final SfTrackingRepository _tracking; late final TextEditingController phoneNumberController; late final TextEditingController codeController; @override LegacyLinkPhoneViewState build() { _linkPhoneUseCase = ref.read(legacyLinkPhoneUseCaseProvider); + _tracking = ref.read(sfTrackingProvider); phoneNumberController = TextEditingController(); phoneNumberController.addListener(_onPhoneNumberChanged); @@ -76,6 +81,8 @@ class LegacyLinkPhoneViewModel extends Notifier { await _linkPhoneUseCase.requestCode(phone: fullPhone); if (!ref.mounted) return; + unawaited(_tracking.legacyAuthLinkPhoneCodeRequested()); + state = state.copyWith( isLoading: false, errorMessage: '', @@ -125,6 +132,8 @@ class LegacyLinkPhoneViewModel extends Notifier { await _linkPhoneUseCase.verifyCode(phone: fullPhone, code: code); if (!ref.mounted) return; + unawaited(_tracking.legacyAuthLinkPhoneCodeVerified()); + state = state.copyWith( isLoading: false, errorMessage: '', diff --git a/modules/legacy/modules/legacy_auth/lib/src/features/login/presentation/state/login_view_model.dart b/modules/legacy/modules/legacy_auth/lib/src/features/login/presentation/state/login_view_model.dart index 6f62f7d1..84cb66b0 100644 --- a/modules/legacy/modules/legacy_auth/lib/src/features/login/presentation/state/login_view_model.dart +++ b/modules/legacy/modules/legacy_auth/lib/src/features/login/presentation/state/login_view_model.dart @@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final legacyLoginViewModelProvider = NotifierProvider.autoDispose( @@ -18,6 +19,7 @@ class LegacyLoginViewModel extends Notifier with LoginFormValidation { late final LegacyLoginRepository _repository; late final GetUserInfoUseCase _getUserInfoUseCase; + late final SfTrackingRepository _tracking; late final TextEditingController emailController; late final TextEditingController passwordController; @@ -30,6 +32,7 @@ class LegacyLoginViewModel extends Notifier LegacyLoginViewState build() { _repository = ref.read(legacyLoginRepositoryProvider); _getUserInfoUseCase = ref.read(getUserInfoUseCaseProvider); + _tracking = ref.read(sfTrackingProvider); emailController = TextEditingController(); emailController.addListener(_onEmailChanged); @@ -102,6 +105,8 @@ class LegacyLoginViewModel extends Notifier twoFAVerified: false, ); + unawaited(_tracking.legacyAuthLoginAttempt()); + try { final response = await _repository.login( email: email, @@ -110,6 +115,8 @@ class LegacyLoginViewModel extends Notifier if (!ref.mounted) return; + unawaited(_tracking.legacyAuthLoginSuccess()); + state = state.copyWith( token: response.token, availableMethods: response.availableMethods, @@ -118,6 +125,8 @@ class LegacyLoginViewModel extends Notifier } catch (e) { if (!ref.mounted) return; + unawaited(_tracking.legacyAuthLoginFailure(formatErrorMessage(e))); + state = state.copyWith( isLoading: false, errorMessage: formatErrorMessage(e), @@ -153,6 +162,8 @@ class LegacyLoginViewModel extends Notifier if (!ref.mounted) return; + unawaited(_tracking.legacyAuth2faRequested()); + _startResendCooldown(); state = state.copyWith( twoFARequested: true, @@ -207,6 +218,8 @@ class LegacyLoginViewModel extends Notifier if (!ref.mounted) return; + unawaited(_tracking.legacyAuth2faVerified()); + await _getUserInfoUseCase.getUserInfo(); if (!ref.mounted) return; @@ -223,6 +236,8 @@ class LegacyLoginViewModel extends Notifier } catch (e) { if (!ref.mounted) return; + unawaited(_tracking.legacyAuth2faFailure(formatErrorMessage(e))); + state = state.copyWith( isLoading: false, errorMessage: formatErrorMessage(e), @@ -231,6 +246,7 @@ class LegacyLoginViewModel extends Notifier } Future resendCode() async { + unawaited(_tracking.legacyAuth2faResend()); state = state.copyWith(code: '', isLoading: true); await _requestTwoFACode(updateLoading: true); } diff --git a/modules/legacy/modules/legacy_auth/lib/src/features/onboarding/presentation/onboarding_view_model.dart b/modules/legacy/modules/legacy_auth/lib/src/features/onboarding/presentation/onboarding_view_model.dart index 798c48fd..81cbeee4 100644 --- a/modules/legacy/modules/legacy_auth/lib/src/features/onboarding/presentation/onboarding_view_model.dart +++ b/modules/legacy/modules/legacy_auth/lib/src/features/onboarding/presentation/onboarding_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_auth/src/features/onboarding/presentation/onboarding_view_state.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final legacyOnBoardingViewModelProvider = NotifierProvider.autoDispose< @@ -8,12 +11,16 @@ final legacyOnBoardingViewModelProvider = >(LegacyOnBoardingViewModel.new); class LegacyOnBoardingViewModel extends Notifier { + late final SfTrackingRepository _tracking; + @override LegacyOnboardingViewState build() { + _tracking = ref.read(sfTrackingProvider); return const LegacyOnboardingViewState(); } void onPageChanged(int index) { + unawaited(_tracking.legacyOnboardingStepChanged(index)); state = state.copyWith(cardIndex: index); } } diff --git a/modules/legacy/modules/legacy_auth/lib/src/features/recover_password/presentation/state/recover_password_view_model.dart b/modules/legacy/modules/legacy_auth/lib/src/features/recover_password/presentation/state/recover_password_view_model.dart index 8bb39893..4a27d11e 100644 --- a/modules/legacy/modules/legacy_auth/lib/src/features/recover_password/presentation/state/recover_password_view_model.dart +++ b/modules/legacy/modules/legacy_auth/lib/src/features/recover_password/presentation/state/recover_password_view_model.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:legacy_auth/src/features/recover_password/domain/use_cases/recover_password_use_case.dart'; import 'package:legacy_auth/src/features/recover_password/presentation/state/recover_password_view_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../providers/recover_password_provider.dart'; @@ -14,6 +17,7 @@ final legacyRecoverPasswordViewModelProvider = class LegacyRecoverPasswordViewModel extends Notifier { late final LegacyRecoverPasswordUseCase _recoverPasswordUseCase; + late final SfTrackingRepository _tracking; late final TextEditingController phoneNumberController; late final TextEditingController emailController; late final TextEditingController passwordController; @@ -23,6 +27,7 @@ class LegacyRecoverPasswordViewModel @override LegacyRecoverPasswordViewState build() { _recoverPasswordUseCase = ref.read(legacyRecoverPasswordUseCaseProvider); + _tracking = ref.read(sfTrackingProvider); phoneNumberController = TextEditingController(); phoneNumberController.addListener(_onPhoneNumberChanged); @@ -126,6 +131,8 @@ class LegacyRecoverPasswordViewModel recoveryRequested: false, ); + unawaited(_tracking.legacyAuthPasswordResetRequested()); + if (email.isNotEmpty) { await requestEmail(); } else { @@ -146,6 +153,8 @@ class LegacyRecoverPasswordViewModel ); if (!ref.mounted) return; + unawaited(_tracking.legacyAuthPasswordResetEmailSent()); + state = state.copyWith( isLoading: false, errorMessage: '', diff --git a/modules/legacy/modules/legacy_auth/lib/src/features/sign_up/presentation/state/sign_up_view_model.dart b/modules/legacy/modules/legacy_auth/lib/src/features/sign_up/presentation/state/sign_up_view_model.dart index 3d4ad4fa..1607737a 100644 --- a/modules/legacy/modules/legacy_auth/lib/src/features/sign_up/presentation/state/sign_up_view_model.dart +++ b/modules/legacy/modules/legacy_auth/lib/src/features/sign_up/presentation/state/sign_up_view_model.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sealed_countries/sealed_countries.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final legacySignUpViewModelProvider = NotifierProvider.autoDispose( @@ -22,6 +23,7 @@ final legacySignUpViewModelProvider = class LegacySignUpViewModel extends Notifier with SignUpFormValidation { late final LegacySignUpRepository _repository; + late final SfTrackingRepository _tracking; late final TextEditingController firstNameController; late final TextEditingController lastNameController; @@ -50,6 +52,7 @@ class LegacySignUpViewModel extends Notifier @override LegacySignUpViewState build() { _repository = ref.read(legacySignUpRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); final initial = LegacySignUpViewState(); _initControllers(initial); @@ -537,12 +540,16 @@ class LegacySignUpViewModel extends Notifier showAccountCreated: false, ); + unawaited(_tracking.legacyAuthSignupStarted()); + try { final request = _toRequest(); final response = await _repository.signUp(request: request); if (!ref.mounted) return false; + unawaited(_tracking.legacyAuthSignupCompleted()); + state = state.copyWith( isLoading: false, isCreated: response.isCreated, @@ -558,6 +565,8 @@ class LegacySignUpViewModel extends Notifier ? I18n.errorEmailAlreadyRegistered : msg; + unawaited(_tracking.legacyAuthSignupFailed(errorMsg)); + state = state.copyWith(isLoading: false, errorMessage: errorMsg); return false; } diff --git a/modules/legacy/modules/legacy_auth/pubspec.yaml b/modules/legacy/modules/legacy_auth/pubspec.yaml index 2b26e163..2bc7bcff 100644 --- a/modules/legacy/modules/legacy_auth/pubspec.yaml +++ b/modules/legacy/modules/legacy_auth/pubspec.yaml @@ -25,6 +25,8 @@ dependencies: path: ../../../../packages/utils sf_shared: path: ../../../../packages/sf_shared + sf_tracking: + path: ../../../../packages/sf_tracking legacy_shared: path: ../../packages/legacy_shared fonts: diff --git a/modules/legacy/modules/location/lib/src/features/location/presentation/state/location_map_view_model.dart b/modules/legacy/modules/location/lib/src/features/location/presentation/state/location_map_view_model.dart index ffcd00a0..45e489e2 100644 --- a/modules/legacy/modules/location/lib/src/features/location/presentation/state/location_map_view_model.dart +++ b/modules/legacy/modules/location/lib/src/features/location/presentation/state/location_map_view_model.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:control_panel/control_panel.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; import 'package:location/src/core/domain/entities/geofence_entity.dart'; import 'package:location/src/core/domain/entities/frequent_place_entity.dart'; import 'package:location/src/features/location/presentation/state/location_map_view_state.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final locationMapViewModelProvider = NotifierProvider.autoDispose( @@ -11,17 +14,24 @@ final locationMapViewModelProvider = ); class LocationMapViewModel extends Notifier { + late final SfTrackingRepository _tracking; + @override LocationMapViewState build() { + _tracking = ref.read(sfTrackingProvider); return const LocationMapViewState(); } void toggleGeofences() { - state = state.copyWith(showGeofences: !state.showGeofences); + final newVisible = !state.showGeofences; + unawaited(_tracking.legacyLocationMapGeofencesToggled(newVisible)); + state = state.copyWith(showGeofences: newVisible); } void toggleFrequentPlaces() { - state = state.copyWith(showFrequentPlaces: !state.showFrequentPlaces); + final newVisible = !state.showFrequentPlaces; + unawaited(_tracking.legacyLocationMapFrequentPlacesToggled(newVisible)); + state = state.copyWith(showFrequentPlaces: newVisible); } void startPlacing(PlacingMode mode) { diff --git a/modules/legacy/modules/location/lib/src/features/location/presentation/state/location_view_model.dart b/modules/legacy/modules/location/lib/src/features/location/presentation/state/location_view_model.dart index 10e65934..3f291a5b 100644 --- a/modules/legacy/modules/location/lib/src/features/location/presentation/state/location_view_model.dart +++ b/modules/legacy/modules/location/lib/src/features/location/presentation/state/location_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:location/src/core/data/models/create_frequent_place_request_model.dart'; @@ -11,6 +13,7 @@ import 'package:location/src/core/domain/repositories/location_repository.dart'; import 'package:location/src/core/providers/location_repository_provider.dart'; import 'package:location/src/features/location/presentation/state/location_view_state.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'package:uuid/uuid.dart'; final locationViewModelProvider = @@ -20,10 +23,12 @@ final locationViewModelProvider = class LocationViewModel extends Notifier { late final LocationRepository _locationRepository; + late final SfTrackingRepository _tracking; @override LocationViewState build() { _locationRepository = ref.read(locationRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); final device = ref.read(selectedDeviceProvider); if (device != null) { _fetchData(device.id); @@ -80,6 +85,9 @@ class LocationViewModel extends Notifier { request: request, ); if (!ref.mounted) return false; + + unawaited(_tracking.legacyLocationGeofenceCreated()); + state = state.copyWith( geofences: [...state.geofences, created], isSubmitting: false, @@ -117,6 +125,9 @@ class LocationViewModel extends Notifier { request: request, ); if (!ref.mounted) return false; + + unawaited(_tracking.legacyLocationGeofenceUpdated()); + state = state.copyWith( geofences: state.geofences .map((g) => g.id == id ? updated : g) @@ -135,6 +146,9 @@ class LocationViewModel extends Notifier { try { await _locationRepository.deleteGeofence(geofenceId: id); if (!ref.mounted) return false; + + unawaited(_tracking.legacyLocationGeofenceDeleted()); + state = state.copyWith( geofences: state.geofences.where((g) => g.id != id).toList(), successMessage: LocationSuccessEvent.geofenceDeleted, @@ -182,6 +196,9 @@ class LocationViewModel extends Notifier { request: request, ); if (!ref.mounted) return false; + + unawaited(_tracking.legacyLocationFrequentPlaceCreated()); + state = state.copyWith( frequentPlaces: [...state.frequentPlaces, created], isSubmitting: false, @@ -225,6 +242,9 @@ class LocationViewModel extends Notifier { request: request, ); if (!ref.mounted) return false; + + unawaited(_tracking.legacyLocationFrequentPlaceUpdated()); + state = state.copyWith( frequentPlaces: state.frequentPlaces .map((f) => f.id == id ? updated : f) @@ -243,6 +263,9 @@ class LocationViewModel extends Notifier { try { await _locationRepository.deleteFrequentPlace(frequentPlaceId: id); if (!ref.mounted) return false; + + unawaited(_tracking.legacyLocationFrequentPlaceDeleted()); + state = state.copyWith( frequentPlaces: state.frequentPlaces.where((f) => f.id != id).toList(), successMessage: LocationSuccessEvent.frequentPlaceDeleted, @@ -268,6 +291,9 @@ class LocationViewModel extends Notifier { to: to, ); if (!ref.mounted) return; + + unawaited(_tracking.legacyLocationHistoryLoaded()); + state = state.copyWith( positionHistory: positions, isLoadingHistory: false, @@ -287,7 +313,9 @@ class LocationViewModel extends Notifier { } void toggleRouteTrail() { - state = state.copyWith(showRouteTrail: !state.showRouteTrail); + final newVisible = !state.showRouteTrail; + unawaited(_tracking.legacyLocationMapRouteTrailToggled(newVisible)); + state = state.copyWith(showRouteTrail: newVisible); } Future updateLocationFrequency({required int frequency}) async { @@ -307,6 +335,9 @@ class LocationViewModel extends Notifier { if (!ref.mounted) return false; ref.syncDeviceSettings(device, updatedSettings); + + unawaited(_tracking.legacyLocationFrequencyUpdated(frequency)); + state = state.copyWith(isSubmitting: false); return true; } catch (e) { diff --git a/modules/legacy/modules/location/pubspec.yaml b/modules/legacy/modules/location/pubspec.yaml index f733e32c..367dfd3c 100644 --- a/modules/legacy/modules/location/pubspec.yaml +++ b/modules/legacy/modules/location/pubspec.yaml @@ -23,6 +23,8 @@ dependencies: path: ../../packages/legacy_shared sf_shared: path: ../../../../packages/sf_shared + sf_tracking: + path: ../../../../packages/sf_tracking utils: path: ../../../../packages/utils get_it: ^9.0.5 diff --git a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_model.dart b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_model.dart index 64a708bc..f8344356 100644 --- a/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/alarm/presentation/state/alarm_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:settings/src/core/data/models/create_alarm_request_model.dart'; @@ -7,6 +9,7 @@ import 'package:settings/src/core/providers/alarm_repository_provider.dart'; import 'package:settings/src/features/alarm/domain/entities/alarm_entity.dart'; import 'package:settings/src/features/alarm/presentation/state/alarm_view_state.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'package:uuid/uuid.dart'; final alarmViewModelProvider = @@ -16,10 +19,12 @@ final alarmViewModelProvider = class AlarmViewModel extends Notifier { late final AlarmRepository _repository; + late final SfTrackingRepository _tracking; @override AlarmViewState build() { _repository = ref.read(alarmRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const AlarmViewState(); } @@ -63,6 +68,8 @@ class AlarmViewModel extends Notifier { order: state.alarms.length + 1, ); + unawaited(_tracking.legacySettingsAlarmAdded()); + state = state.copyWith( alarms: [...state.alarms, updatedAlarm], isSaving: false, @@ -93,6 +100,8 @@ class AlarmViewModel extends Notifier { .map((a) => a.id == alarm.id ? alarm : a) .toList(); + unawaited(_tracking.legacySettingsAlarmUpdated()); + state = state.copyWith( alarms: updatedAlarms, isSaving: false, @@ -114,6 +123,8 @@ class AlarmViewModel extends Notifier { final updatedAlarms = state.alarms.where((a) => a.id != alarmId).toList(); + unawaited(_tracking.legacySettingsAlarmRemoved()); + state = state.copyWith( alarms: updatedAlarms, isSaving: false, diff --git a/modules/legacy/modules/settings/lib/src/features/alerts/presentation/state/alerts_view_model.dart b/modules/legacy/modules/settings/lib/src/features/alerts/presentation/state/alerts_view_model.dart index 43532879..90c4283d 100644 --- a/modules/legacy/modules/settings/lib/src/features/alerts/presentation/state/alerts_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/alerts/presentation/state/alerts_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'alerts_view_state.dart'; @@ -10,10 +13,12 @@ final alertsViewModelProvider = class AlertsViewModel extends Notifier { late final DeviceSettingsUpdateDatasource _datasource; + late final SfTrackingRepository _tracking; @override AlertsViewState build() { _datasource = ref.read(deviceSettingsUpdateProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const AlertsViewState(); } @@ -62,6 +67,9 @@ class AlertsViewModel extends Notifier { ); if (!ref.mounted) return; ref.syncDeviceSettings(device, updatedSettings); + + unawaited(_tracking.legacySettingsAlertsConfigured()); + state = state.copyWith(isSaving: false, saveSuccess: true); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/settings/lib/src/features/battery/presentation/state/battery_view_model.dart b/modules/legacy/modules/settings/lib/src/features/battery/presentation/state/battery_view_model.dart index a9b94d94..9851abf7 100644 --- a/modules/legacy/modules/settings/lib/src/features/battery/presentation/state/battery_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/battery/presentation/state/battery_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'battery_view_state.dart'; @@ -10,10 +13,12 @@ final batteryViewModelProvider = class BatteryViewModel extends Notifier { late final DeviceSettingsUpdateDatasource _datasource; + late final SfTrackingRepository _tracking; @override BatteryViewState build() { _datasource = ref.read(deviceSettingsUpdateProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const BatteryViewState(); } @@ -46,6 +51,9 @@ class BatteryViewModel extends Notifier { ); if (!ref.mounted) return; ref.syncDeviceSettings(device, updatedSettings); + + unawaited(_tracking.legacySettingsBatteryNightModeToggled(value)); + state = state.copyWith( nightMode: value, isSaving: false, diff --git a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/block_phone_view_model.dart b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/block_phone_view_model.dart index bec86932..6464fe67 100644 --- a/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/block_phone_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/block_phone/presentation/state/block_phone_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart'; @@ -5,6 +7,7 @@ import 'package:settings/src/core/domain/repositories/block_phone_repository.dar import 'package:settings/src/core/providers/block_phone_repository_provider.dart'; import 'package:settings/src/features/block_phone/presentation/state/block_phone_view_state.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final blockPhoneViewModelProvider = NotifierProvider.autoDispose( @@ -13,10 +16,12 @@ final blockPhoneViewModelProvider = class BlockPhoneViewModel extends Notifier { late final BlockPhoneRepository _repository; + late final SfTrackingRepository _tracking; @override BlockPhoneViewState build() { _repository = ref.read(blockPhoneRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const BlockPhoneViewState(); } @@ -51,6 +56,8 @@ class BlockPhoneViewModel extends Notifier { contacts: updatedContacts, ); + unawaited(_tracking.legacySettingsBlockPhoneContactAdded()); + state = state.copyWith( contacts: updatedContacts, isSaving: false, @@ -79,6 +86,8 @@ class BlockPhoneViewModel extends Notifier { contacts: updatedContacts, ); + unawaited(_tracking.legacySettingsBlockPhoneContactRemoved()); + state = state.copyWith( contacts: updatedContacts, isSaving: false, diff --git a/modules/legacy/modules/settings/lib/src/features/disable_functions/presentation/state/disable_functions_view_model.dart b/modules/legacy/modules/settings/lib/src/features/disable_functions/presentation/state/disable_functions_view_model.dart index f9345883..b35e3a9f 100644 --- a/modules/legacy/modules/settings/lib/src/features/disable_functions/presentation/state/disable_functions_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/disable_functions/presentation/state/disable_functions_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'disable_functions_view_state.dart'; @@ -11,10 +14,12 @@ final disableFunctionsViewModelProvider = class DisableFunctionsViewModel extends Notifier { late final DeviceSettingsUpdateDatasource _datasource; + late final SfTrackingRepository _tracking; @override DisableFunctionsViewState build() { _datasource = ref.read(deviceSettingsUpdateProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const DisableFunctionsViewState(); } @@ -59,6 +64,13 @@ class DisableFunctionsViewModel extends Notifier { ); if (!ref.mounted) return; ref.syncDeviceSettings(device, updatedSettings); + + unawaited(_tracking.legacySettingsDisableFunctionsChanged()); + unawaited( + _tracking.legacySettingsDisableFunctionsKeyboardToggled(state.keyboard), + ); + unawaited(_tracking.legacySettingsDisableFunctionsGpsToggled(state.gps)); + state = state.copyWith(isSaving: false, saveSuccess: true); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/settings/lib/src/features/language/presentation/state/language_view_model.dart b/modules/legacy/modules/settings/lib/src/features/language/presentation/state/language_view_model.dart index 10365c70..69caec42 100644 --- a/modules/legacy/modules/settings/lib/src/features/language/presentation/state/language_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/language/presentation/state/language_view_model.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:settings/src/core/domain/repositories/language_repository.dart'; import 'package:settings/src/core/providers/language_repository_provider.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'language_view_state.dart'; final languageViewModelProvider = @@ -11,10 +14,12 @@ final languageViewModelProvider = class LanguageViewModel extends Notifier { late final LanguageRepository _languageRepository; + late final SfTrackingRepository _tracking; @override LanguageViewState build() { _languageRepository = ref.read(languageRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(() => load()); return const LanguageViewState(isLoading: true); } @@ -61,6 +66,9 @@ class LanguageViewModel extends Notifier { device, device.settings.copyWith(language: state.language), ); + + unawaited(_tracking.legacySettingsLanguageChanged(state.language)); + state = state.copyWith(isLoading: false, isComplete: true); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart index fde5dfbc..6fc8a1c9 100644 --- a/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/sos_contacts/presentation/state/sos_contacts_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:settings/src/core/domain/entities/contact_list_contact_entity.dart'; @@ -5,6 +7,7 @@ import 'package:settings/src/core/domain/repositories/sos_contacts_repository.da import 'package:settings/src/core/providers/sos_contacts_repository_provider.dart'; import 'package:settings/src/features/sos_contacts/presentation/state/sos_contacts_view_state.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_tracking/sf_tracking.dart'; final sosContactsViewModelProvider = NotifierProvider.autoDispose( @@ -13,10 +16,12 @@ final sosContactsViewModelProvider = class SosContactsViewModel extends Notifier { late final SosContactsRepository _repository; + late final SfTrackingRepository _tracking; @override SosContactsViewState build() { _repository = ref.read(sosContactsRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const SosContactsViewState(); } @@ -53,6 +58,8 @@ class SosContactsViewModel extends Notifier { contacts: updatedContacts, ); + unawaited(_tracking.legacySettingsSosContactAdded()); + state = state.copyWith( contacts: updatedContacts, isSaving: false, @@ -81,6 +88,8 @@ class SosContactsViewModel extends Notifier { contacts: updatedContacts, ); + unawaited(_tracking.legacySettingsSosContactRemoved()); + state = state.copyWith( contacts: updatedContacts, isSaving: false, diff --git a/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_model.dart b/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_model.dart index fdd87cdb..96910d88 100644 --- a/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/sound/presentation/state/sound_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'sound_view_state.dart'; @@ -10,10 +13,12 @@ final soundViewModelProvider = class SoundViewModel extends Notifier { late final DeviceSettingsUpdateDatasource _datasource; + late final SfTrackingRepository _tracking; @override SoundViewState build() { _datasource = ref.read(deviceSettingsUpdateProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(() => load()); return const SoundViewState(); } @@ -61,6 +66,9 @@ class SoundViewModel extends Notifier { if (!ref.mounted) return; ref.syncDeviceSettings(device, updatedSettings); + + unawaited(_tracking.legacySettingsSoundChanged()); + state = state.copyWith(isLoading: false, isComplete: true); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_model.dart b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_model.dart index a88dc7e6..b2bdcc48 100644 --- a/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/sync_clock/presentation/state/sync_clock_view_model.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../../domain/sync_clock_use_case.dart'; import '../providers/set_sound_use_case_provider.dart'; @@ -13,10 +16,12 @@ final syncClockViewModelProvider = class SyncClockViewModel extends Notifier { late final SyncClockUseCase _syncClockUseCase; + late final SfTrackingRepository _tracking; @override SyncClockViewState build() { _syncClockUseCase = ref.read(syncClockUseCaseProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(() => load()); @@ -38,6 +43,7 @@ class SyncClockViewModel extends Notifier { try { state = state.copyWith(isLoading: true); _syncClockUseCase.syncClock(deviceId: state.deviceId); + unawaited(_tracking.legacySettingsSyncClockTriggered()); } catch (e) { state = state.copyWith(isLoading: false, errorMessage: e.toString()); } diff --git a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_model.dart b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_model.dart index b0216cf5..2e57f065 100644 --- a/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/timezone/presentation/state/timezone_view_model.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import 'timezone_view_state.dart'; @@ -10,10 +13,12 @@ final timezoneViewModelProvider = class TimezoneViewModel extends Notifier { late final DeviceSettingsUpdateDatasource _datasource; + late final SfTrackingRepository _tracking; @override TimezoneViewState build() { _datasource = ref.read(deviceSettingsUpdateProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const TimezoneViewState(); } @@ -57,6 +62,11 @@ class TimezoneViewModel extends Notifier { ); if (!ref.mounted) return; ref.syncDeviceSettings(device, updatedSettings); + + unawaited( + _tracking.legacySettingsTimezoneChanged(state.timezone.toString()), + ); + state = state.copyWith(isSaving: false, saveSuccess: true); } catch (e) { if (!ref.mounted) return; diff --git a/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/state/wifi_settings_view_model.dart b/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/state/wifi_settings_view_model.dart index cd4bab45..f3660614 100644 --- a/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/state/wifi_settings_view_model.dart +++ b/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/state/wifi_settings_view_model.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:legacy_shared/legacy_shared.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../../../../core/domain/repositories/wifi_repository.dart'; import '../../../../core/providers/wifi_repository_provider.dart'; @@ -13,10 +16,12 @@ final wifiSettingsViewModelProvider = class WifiSettingsViewModel extends Notifier { late final WifiRepository _repository; + late final SfTrackingRepository _tracking; @override WifiSettingsViewState build() { _repository = ref.read(wifiRepositoryProvider); + _tracking = ref.read(sfTrackingProvider); Future.microtask(_load); return const WifiSettingsViewState(); } @@ -51,6 +56,8 @@ class WifiSettingsViewModel extends Notifier { final networks = await _repository.getWifiNetworks(deviceId: device.id); + unawaited(_tracking.legacySettingsWifiAdded()); + state = state.copyWith( networks: networks, isSaving: false, @@ -75,6 +82,8 @@ class WifiSettingsViewModel extends Notifier { final networks = await _repository.getWifiNetworks(deviceId: device.id); + unawaited(_tracking.legacySettingsWifiRemoved()); + state = state.copyWith( networks: networks, isSaving: false, diff --git a/modules/legacy/modules/settings/pubspec.yaml b/modules/legacy/modules/settings/pubspec.yaml index 460e3599..d73c8680 100644 --- a/modules/legacy/modules/settings/pubspec.yaml +++ b/modules/legacy/modules/settings/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: #modules dependencies go here #packages dependencies go here + sf_tracking: + path: ../../../../packages/sf_tracking design_system: path: ../../../../packages/design_system navigation: diff --git a/packages/sf_tracking/analysis_options.yaml b/packages/sf_tracking/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/packages/sf_tracking/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/packages/sf_tracking/lib/sf_tracking.dart b/packages/sf_tracking/lib/sf_tracking.dart new file mode 100644 index 00000000..06837817 --- /dev/null +++ b/packages/sf_tracking/lib/sf_tracking.dart @@ -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'; diff --git a/packages/sf_tracking/lib/src/clients/debug_tracking_client.dart b/packages/sf_tracking/lib/src/clients/debug_tracking_client.dart new file mode 100644 index 00000000..d993d9e3 --- /dev/null +++ b/packages/sf_tracking/lib/src/clients/debug_tracking_client.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 setAnalyticsStatus({bool enabled = true}) async { + debugPrint('[Tracking][debug] setAnalyticsStatus enabled=$enabled'); + } + + @override + Future setConsentStatus(bool hasConsent) async { + debugPrint('[Tracking][debug] setConsentStatus hasConsent=$hasConsent'); + } + + @override + Future setUserId(String? userId) async { + debugPrint('[Tracking][debug] setUserId $userId'); + } + + @override + Future setUserProperty(String name, String value) async { + debugPrint('[Tracking][debug] setUserProperty $name=$value'); + } + + @override + Future track(String name, [Map? parameters]) async { + debugPrint('[Tracking][debug] event $name params=$parameters'); + } + + @override + Future trackScreenView(String screenName, [Map? parameters]) async { + debugPrint('[Tracking][debug] screen_view $screenName params=$parameters'); + } +} diff --git a/packages/sf_tracking/lib/src/clients/firebase_tracking_client.dart b/packages/sf_tracking/lib/src/clients/firebase_tracking_client.dart new file mode 100644 index 00000000..decd5ee7 --- /dev/null +++ b/packages/sf_tracking/lib/src/clients/firebase_tracking_client.dart @@ -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 setAnalyticsStatus({bool enabled = true}) { + return _analytics.setAnalyticsCollectionEnabled(enabled); + } + + @override + Future setConsentStatus(bool hasConsent) async { + _hasConsent = hasConsent; + await _analytics.setConsent( + adPersonalizationSignalsConsentGranted: hasConsent, + adUserDataConsentGranted: hasConsent, + adStorageConsentGranted: hasConsent, + analyticsStorageConsentGranted: hasConsent, + ); + } + + @override + Future setUserId(String? userId) { + return _analytics.setUserId(id: userId); + } + + @override + Future setUserProperty(String name, String value) { + return _analytics.setUserProperty(name: name, value: value); + } + + @override + Future track(String name, [Map? parameters]) { + return _analytics.logEvent( + name: name, + parameters: _withConsent(parameters), + ); + } + + @override + Future trackScreenView(String screenName, [Map? parameters]) { + return _analytics.logScreenView( + screenName: screenName, + parameters: _withConsent(parameters), + ); + } + + Map _withConsent(Map? parameters) { + return { + 'consent_status': _hasConsent.toString(), + ...?parameters, + }; + } +} diff --git a/packages/sf_tracking/lib/src/mixins/account_tracking.dart b/packages/sf_tracking/lib/src/mixins/account_tracking.dart new file mode 100644 index 00000000..6ba2bf76 --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/account_tracking.dart @@ -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 legacyAccountPersonalDataEdited() => + trackEvent('${_prefix}_personal_data_edited'); + + /// User changed their password successfully. + Future legacyAccountPasswordChanged() => + trackEvent('${_prefix}_password_changed'); + + /// Password change failed (wrong current password, validation, server error, etc). + Future legacyAccountPasswordChangeFailed(String reason) => + trackEvent('${_prefix}_password_change_failed', {'reason': reason}); + + /// User unlinked a device from their account. + Future legacyAccountLinkedDeviceUnlinked() => + trackEvent('${_prefix}_linked_device_unlinked'); + + /// ⚠️ CHURN SIGNAL — User opened the account deletion flow. + Future legacyAccountDeletionInitiated() => + trackEvent('${_prefix}_deletion_initiated'); + + /// ⚠️ CHURN SIGNAL — User confirmed and the deletion API call is in flight. + Future legacyAccountDeletionConfirmed() => + trackEvent('${_prefix}_deletion_confirmed'); + + /// ⚠️ CHURN SIGNAL — Account deletion completed on the backend. + Future legacyAccountDeletionCompleted() => + trackEvent('${_prefix}_deletion_completed'); + + /// User backed out of the deletion flow before confirming. + Future legacyAccountDeletionCancelled() => + trackEvent('${_prefix}_deletion_cancelled'); +} diff --git a/packages/sf_tracking/lib/src/mixins/auth_tracking.dart b/packages/sf_tracking/lib/src/mixins/auth_tracking.dart new file mode 100644 index 00000000..664a191e --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/auth_tracking.dart @@ -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 legacyAuthLoginAttempt() => trackEvent('${_prefix}_login_attempt'); + + /// Backend accepted the credentials. 2FA may still be required afterwards. + Future legacyAuthLoginSuccess() => trackEvent('${_prefix}_login_success'); + + /// Backend rejected the credentials or threw an error. + Future legacyAuthLoginFailure(String reason) => + trackEvent('${_prefix}_login_failure', {'reason': reason}); + + // ─── 2FA ───────────────────────────────────────────────────────────────── + + /// 2FA code was successfully requested from the backend. + Future legacyAuth2faRequested() => trackEvent('${_prefix}_2fa_requested'); + + /// 2FA code was verified and the session is now authenticated. + Future legacyAuth2faVerified() => trackEvent('${_prefix}_2fa_verified'); + + /// 2FA verification failed (wrong code, expired, etc). + Future legacyAuth2faFailure(String reason) => + trackEvent('${_prefix}_2fa_failure', {'reason': reason}); + + /// User asked to resend the 2FA code. + Future legacyAuth2faResend() => trackEvent('${_prefix}_2fa_resend'); + + // ─── Signup ────────────────────────────────────────────────────────────── + + /// User started the signup flow (form submitted, request in flight). + Future legacyAuthSignupStarted() => + trackEvent('${_prefix}_signup_started'); + + /// Backend accepted the signup. Account is created. + Future legacyAuthSignupCompleted() => + trackEvent('${_prefix}_signup_completed'); + + /// Backend rejected the signup. + Future legacyAuthSignupFailed(String reason) => + trackEvent('${_prefix}_signup_failed', {'reason': reason}); + + // ─── Password recovery ─────────────────────────────────────────────────── + + /// User requested a password reset (form submitted, request in flight). + Future legacyAuthPasswordResetRequested() => + trackEvent('${_prefix}_password_reset_requested'); + + /// Backend confirmed it sent the recovery email. + Future legacyAuthPasswordResetEmailSent() => + trackEvent('${_prefix}_password_reset_email_sent'); + + // ─── Phone linking ─────────────────────────────────────────────────────── + + /// User submitted a phone number to receive an OTP code for phone linking. + Future legacyAuthLinkPhoneCodeRequested() => + trackEvent('${_prefix}_link_phone_code_requested'); + + /// User successfully verified the OTP code and the phone is linked. + Future legacyAuthLinkPhoneCodeVerified() => + trackEvent('${_prefix}_link_phone_code_verified'); + + // ─── Logout ────────────────────────────────────────────────────────────── + + /// User logged out and the session has been cleared. + Future legacyAuthLogout() => trackEvent('${_prefix}_logout'); +} diff --git a/packages/sf_tracking/lib/src/mixins/contacts_tracking.dart b/packages/sf_tracking/lib/src/mixins/contacts_tracking.dart new file mode 100644 index 00000000..9ad1334b --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/contacts_tracking.dart @@ -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 legacyContactsAdded() => trackEvent('${_prefix}_added'); + + /// User edited an existing contact entry. + Future legacyContactsEdited() => trackEvent('${_prefix}_edited'); + + /// User deleted a contact entry from the device. + Future legacyContactsDeleted() => trackEvent('${_prefix}_deleted'); +} diff --git a/packages/sf_tracking/lib/src/mixins/control_panel_tracking.dart b/packages/sf_tracking/lib/src/mixins/control_panel_tracking.dart new file mode 100644 index 00000000..c2113182 --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/control_panel_tracking.dart @@ -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 legacyControlPanelDeviceSelected() => + trackEvent('${_prefix}_device_selected'); + + /// User triggered a manual refresh of device positions (pull-to-refresh + /// or refresh button). + Future legacyControlPanelPositionsRefreshed() => + trackEvent('${_prefix}_positions_refreshed'); +} diff --git a/packages/sf_tracking/lib/src/mixins/dashboard_tracking.dart b/packages/sf_tracking/lib/src/mixins/dashboard_tracking.dart new file mode 100644 index 00000000..6532ec0b --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/dashboard_tracking.dart @@ -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 legacyDashboardTabChanged(String tabName) => + trackEvent('${_prefix}_tab_changed', {'tab_name': tabName}); +} diff --git a/packages/sf_tracking/lib/src/mixins/device_setup_tracking.dart b/packages/sf_tracking/lib/src/mixins/device_setup_tracking.dart new file mode 100644 index 00000000..86e97968 --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/device_setup_tracking.dart @@ -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 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 legacyDeviceSetupStepCompleted(String step) => + trackEvent('${_prefix}_step_completed', {'step': step}); + + /// User finished the wizard and the device was successfully created. + Future 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 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 legacyDeviceSetupCancelled(String atStep) => + trackEvent('${_prefix}_cancelled', {'at_step': atStep}); +} diff --git a/packages/sf_tracking/lib/src/mixins/device_tracking.dart b/packages/sf_tracking/lib/src/mixins/device_tracking.dart new file mode 100644 index 00000000..0b41de48 --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/device_tracking.dart @@ -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 legacyDeviceLocateRequested() => + trackEvent('${_prefix}_locate_requested'); + + /// User entered the remote connection screen (active session start). + Future 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 legacyDeviceVolumeControlChanged({ + required String type, + required int level, + }) => + trackEvent('${_prefix}_volume_control_changed', { + 'type': type, + 'level': level, + }); + + /// User changed the device background image. + Future legacyDeviceBackgroundImageChanged() => + trackEvent('${_prefix}_background_image_changed'); + + /// User added a scheduled activity (alarm, reminder, etc on the device). + Future legacyDeviceScheduledActivityAdded() => + trackEvent('${_prefix}_scheduled_activity_added'); + + /// User removed a scheduled activity. + Future legacyDeviceScheduledActivityRemoved() => + trackEvent('${_prefix}_scheduled_activity_removed'); + + /// User assigned reward minutes to the device. + Future legacyDeviceRewardsGranted({required int amount}) => + trackEvent('${_prefix}_rewards_granted', {'amount': amount}); + + /// User triggered the remote camera to take a photo via remote connection. + Future legacyDeviceRemoteConnectionPhotoTaken() => + trackEvent('${_prefix}_remote_connection_photo_taken'); + + /// User initiated a two-way call from the remote connection screen. + Future legacyDeviceRemoteConnectionCallInitiated() => + trackEvent('${_prefix}_remote_connection_call_initiated'); + + /// User uploaded a custom photo to use as device background. + Future legacyDeviceBackgroundImageUploaded() => + trackEvent('${_prefix}_background_image_uploaded'); + + /// User edited a previously created scheduled activity. + Future legacyDeviceScheduledActivityUpdated() => + trackEvent('${_prefix}_scheduled_activity_updated'); + + /// User toggled the pedometer (step counter) on the device. + Future legacyDeviceActivityPedometerToggled(bool enabled) => + trackEvent('${_prefix}_activity_pedometer_toggled', { + 'enabled': enabled.toString(), + }); + + /// User changed the heart-rate sampling frequency. + Future legacyDeviceHealthHeartRateFrequencyChanged(int seconds) => + trackEvent('${_prefix}_health_heart_rate_frequency_changed', { + 'frequency_seconds': seconds, + }); + + /// User triggered an on-demand health measurement (heart rate / SpO2). + Future legacyDeviceHealthMeasurementStarted() => + trackEvent('${_prefix}_health_measurement_started'); +} diff --git a/packages/sf_tracking/lib/src/mixins/location_tracking.dart b/packages/sf_tracking/lib/src/mixins/location_tracking.dart new file mode 100644 index 00000000..95749564 --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/location_tracking.dart @@ -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 legacyLocationGeofenceCreated() => + trackEvent('${_prefix}_geofence_created'); + + Future legacyLocationGeofenceUpdated() => + trackEvent('${_prefix}_geofence_updated'); + + Future legacyLocationGeofenceDeleted() => + trackEvent('${_prefix}_geofence_deleted'); + + // ─── Frequent places ───────────────────────────────────────────────────── + + Future legacyLocationFrequentPlaceCreated() => + trackEvent('${_prefix}_frequent_place_created'); + + Future legacyLocationFrequentPlaceUpdated() => + trackEvent('${_prefix}_frequent_place_updated'); + + Future legacyLocationFrequentPlaceDeleted() => + trackEvent('${_prefix}_frequent_place_deleted'); + + // ─── Position history ──────────────────────────────────────────────────── + + /// User loaded position history for a date range. + Future legacyLocationHistoryLoaded() => + trackEvent('${_prefix}_history_loaded'); + + // ─── Location frequency / privacy controls ─────────────────────────────── + + /// User changed how often the device sends location updates. + Future legacyLocationFrequencyUpdated(int seconds) => + trackEvent('${_prefix}_frequency_updated', { + 'frequency_seconds': seconds, + }); + + // ─── Map layer toggles ─────────────────────────────────────────────────── + + Future legacyLocationMapGeofencesToggled(bool visible) => + trackEvent('${_prefix}_map_geofences_toggled', { + 'visible': visible.toString(), + }); + + Future legacyLocationMapFrequentPlacesToggled(bool visible) => + trackEvent('${_prefix}_map_frequent_places_toggled', { + 'visible': visible.toString(), + }); + + Future legacyLocationMapRouteTrailToggled(bool visible) => + trackEvent('${_prefix}_map_route_trail_toggled', { + 'visible': visible.toString(), + }); +} diff --git a/packages/sf_tracking/lib/src/mixins/onboarding_tracking.dart b/packages/sf_tracking/lib/src/mixins/onboarding_tracking.dart new file mode 100644 index 00000000..3887bd8d --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/onboarding_tracking.dart @@ -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 legacyOnboardingStepChanged(int stepIndex) => + trackEvent('${_prefix}_step_changed', {'step_index': stepIndex}); +} diff --git a/packages/sf_tracking/lib/src/mixins/settings_tracking.dart b/packages/sf_tracking/lib/src/mixins/settings_tracking.dart new file mode 100644 index 00000000..52b02e4e --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/settings_tracking.dart @@ -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 legacySettingsAlarmAdded() => + trackEvent('${_prefix}_alarm_added'); + + Future legacySettingsAlarmRemoved() => + trackEvent('${_prefix}_alarm_removed'); + + // ─── SOS contacts ──────────────────────────────────────────────────────── + + Future legacySettingsSosContactAdded() => + trackEvent('${_prefix}_sos_contact_added'); + + Future legacySettingsSosContactRemoved() => + trackEvent('${_prefix}_sos_contact_removed'); + + // ─── Block phone whitelist ─────────────────────────────────────────────── + + Future legacySettingsBlockPhoneContactAdded() => + trackEvent('${_prefix}_block_phone_contact_added'); + + Future legacySettingsBlockPhoneContactRemoved() => + trackEvent('${_prefix}_block_phone_contact_removed'); + + // ─── Disable functions (parental controls) ─────────────────────────────── + + /// User toggled a function (e.g. `keyboard`, `gps`). + Future legacySettingsDisableFunctionsChanged() => + trackEvent('${_prefix}_disable_functions_changed'); + + // ─── Language ──────────────────────────────────────────────────────────── + + Future legacySettingsLanguageChanged(String language) => + trackEvent('${_prefix}_language_changed', {'language': language}); + + // ─── Alerts ────────────────────────────────────────────────────────────── + + Future legacySettingsAlertsConfigured() => + trackEvent('${_prefix}_alerts_configured'); + + // ─── Timezone ──────────────────────────────────────────────────────────── + + Future legacySettingsTimezoneChanged(String timezone) => + trackEvent('${_prefix}_timezone_changed', {'timezone': timezone}); + + // ─── WiFi ──────────────────────────────────────────────────────────────── + + Future legacySettingsWifiAdded() => + trackEvent('${_prefix}_wifi_added'); + + Future legacySettingsWifiRemoved() => + trackEvent('${_prefix}_wifi_removed'); + + // ─── Sound ─────────────────────────────────────────────────────────────── + + Future legacySettingsSoundChanged() => + trackEvent('${_prefix}_sound_changed'); + + // ─── Sync clock ────────────────────────────────────────────────────────── + + Future legacySettingsSyncClockTriggered() => + trackEvent('${_prefix}_sync_clock_triggered'); + + // ─── Alarms (extra) ────────────────────────────────────────────────────── + + /// User edited an existing alarm. + Future legacySettingsAlarmUpdated() => + trackEvent('${_prefix}_alarm_updated'); + + // ─── Battery ───────────────────────────────────────────────────────────── + + /// User toggled night mode (battery saving) on the device. + Future legacySettingsBatteryNightModeToggled(bool enabled) => + trackEvent('${_prefix}_battery_night_mode_toggled', { + 'enabled': enabled.toString(), + }); + + // ─── Disable functions (granular) ──────────────────────────────────────── + + /// Granular: user toggled the device keyboard off/on. + Future legacySettingsDisableFunctionsKeyboardToggled(bool enabled) => + trackEvent('${_prefix}_disable_functions_keyboard_toggled', { + 'enabled': enabled.toString(), + }); + + /// Granular: user toggled GPS off/on. + Future legacySettingsDisableFunctionsGpsToggled(bool enabled) => + trackEvent('${_prefix}_disable_functions_gps_toggled', { + 'enabled': enabled.toString(), + }); +} diff --git a/packages/sf_tracking/lib/src/mixins/support_tracking.dart b/packages/sf_tracking/lib/src/mixins/support_tracking.dart new file mode 100644 index 00000000..a4b16d60 --- /dev/null +++ b/packages/sf_tracking/lib/src/mixins/support_tracking.dart @@ -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 legacySupportContactInitiated(String channel) => + trackEvent('${_prefix}_contact_initiated', {'channel': channel}); +} diff --git a/packages/sf_tracking/lib/src/navigation/navigation_tracking.dart b/packages/sf_tracking/lib/src/navigation/navigation_tracking.dart new file mode 100644 index 00000000..307102a8 --- /dev/null +++ b/packages/sf_tracking/lib/src/navigation/navigation_tracking.dart @@ -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 trackScreenView({required String screenName, Map? parameters}); +} diff --git a/packages/sf_tracking/lib/src/navigation/sf_router_listener.dart b/packages/sf_tracking/lib/src/navigation/sf_router_listener.dart new file mode 100644 index 00000000..9444e4eb --- /dev/null +++ b/packages/sf_tracking/lib/src/navigation/sf_router_listener.dart @@ -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); + } +} diff --git a/packages/sf_tracking/lib/src/providers/sf_tracking_provider.dart b/packages/sf_tracking/lib/src/providers/sf_tracking_provider.dart new file mode 100644 index 00000000..c1869eaf --- /dev/null +++ b/packages/sf_tracking/lib/src/providers/sf_tracking_provider.dart @@ -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: [ + // 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((ref) => sfTracking); diff --git a/packages/sf_tracking/lib/src/sf_tracking_repository.dart b/packages/sf_tracking/lib/src/sf_tracking_repository.dart new file mode 100644 index 00000000..cd96f901 --- /dev/null +++ b/packages/sf_tracking/lib/src/sf_tracking_repository.dart @@ -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 clients}) : _clients = clients; + + final List _clients; + + @override + Future 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 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 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 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 trackEvent(String name, [Map? 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 trackScreenView({required String screenName, Map? parameters}) async { + for (final client in _clients) { + try { + await client.trackScreenView(screenName, parameters); + } catch (e) { + debugPrint('[SfTracking] trackScreenView($screenName) failed on ${client.runtimeType}: $e'); + } + } + } +} diff --git a/packages/sf_tracking/lib/src/tracking.dart b/packages/sf_tracking/lib/src/tracking.dart new file mode 100644 index 00000000..6c18a15e --- /dev/null +++ b/packages/sf_tracking/lib/src/tracking.dart @@ -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 trackEvent(String name, [Map? parameters]); + + Future setUserProperty(String name, String value); + + Future setUserId(String? userId); + + Future setConsentStatus(bool hasConsent); + + Future setAnalyticsStatus({bool enabled = true}); +} diff --git a/packages/sf_tracking/lib/src/tracking_client.dart b/packages/sf_tracking/lib/src/tracking_client.dart new file mode 100644 index 00000000..6f765994 --- /dev/null +++ b/packages/sf_tracking/lib/src/tracking_client.dart @@ -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 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 setConsentStatus(bool hasConsent); + + /// Sets a user-level property (e.g. `user_role`, `user_language`). + Future setUserProperty(String name, String value); + + /// Identifies the current user. Pass `null` on logout to clear it. + Future setUserId(String? userId); + + /// Logs a custom event. + Future track(String name, [Map? 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 trackScreenView(String screenName, [Map? parameters]); +} diff --git a/packages/sf_tracking/lib/src/user_properties/user_info_tracking_listener.dart b/packages/sf_tracking/lib/src/user_properties/user_info_tracking_listener.dart new file mode 100644 index 00000000..727557ba --- /dev/null +++ b/packages/sf_tracking/lib/src/user_properties/user_info_tracking_listener.dart @@ -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 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 onUserCleared() async { + await _tracking.setUserId(null); + } +} diff --git a/packages/sf_tracking/pubspec.yaml b/packages/sf_tracking/pubspec.yaml new file mode 100644 index 00000000..3d356a0d --- /dev/null +++ b/packages/sf_tracking/pubspec.yaml @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 2fbae4e0..a533d838 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ workspace: - packages/sf_infrastructure - packages/sf_localizations - packages/sf_shared + - packages/sf_tracking - packages/utils dependencies: