feat(tracking): expand legacy module analytics coverage

This commit is contained in:
2026-04-07 16:17:53 +02:00
parent 7b91447cad
commit 4728e25803
38 changed files with 887 additions and 142 deletions

View File

@@ -1,8 +1,11 @@
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/app_users/presentation/state/app_users_view_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
final appUsersViewModelProvider =
NotifierProvider.autoDispose<AppUsersViewModel, AppUsersViewState>(
@@ -11,10 +14,12 @@ final appUsersViewModelProvider =
class AppUsersViewModel extends Notifier<AppUsersViewState> {
late final UsersRepository _usersRepository;
late final SfTrackingRepository _tracking;
@override
AppUsersViewState build() {
_usersRepository = ref.read(usersRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
_init();
@@ -42,6 +47,7 @@ class AppUsersViewModel extends Notifier<AppUsersViewState> {
}
void deleteUser() {
unawaited(_tracking.legacyAccountAppUserDeleteTriggered());
_usersRepository.deleteUser(userId: state.loggedUser!.id);
}
}

View File

@@ -110,6 +110,7 @@ class LinkedDevicesViewModel extends Notifier<LinkedDevicesViewState> {
try {
_devicesRepository.updateDevice(request: _toRequest(device));
unawaited(_tracking.legacyAccountLinkedDeviceRenamed());
} catch (e) {
state = state.copyWith(
isLoading: false,

View File

@@ -130,6 +130,10 @@ class ControlPanelViewModel extends Notifier<ControlPanelViewState> {
_selectedDeviceNotifier.setSelectedDevice(device);
unawaited(_tracking.legacyControlPanelDeviceSelected());
unawaited(
_tracking.legacyControlPanelDeviceSelected(
totalDevices: state.devices.length,
),
);
}
}

View File

@@ -123,7 +123,12 @@ class ContactViewModel extends Notifier<ContactViewState> {
'mailto:$receiver?from=$sender&subject=$subject&body=$body',
);
unawaited(_tracking.legacySupportContactInitiated('email'));
unawaited(
_tracking.legacySupportContactInitiated(
channel: 'email',
country: country,
),
);
if (!await launchUrl(url)) {
throw Exception('Could not launch $url');

View File

@@ -36,6 +36,11 @@ class ActivityMeterViewModel extends Notifier<ActivityMeterViewState> {
Future<void> selectTimeRange(TimeRange range) async {
if (range == state.timeRange) return;
state = state.copyWith(timeRange: range, isLoading: true);
unawaited(
_tracking.legacyDeviceActivityMeterTimeRangeChanged(
_timeRangeName(range),
),
);
await _loadFilteredData();
}
@@ -46,9 +51,25 @@ class ActivityMeterViewModel extends Notifier<ActivityMeterViewState> {
customEnd: end,
isLoading: true,
);
unawaited(
_tracking.legacyDeviceActivityMeterTimeRangeChanged('custom'),
);
await _loadFilteredData();
}
String _timeRangeName(TimeRange range) {
switch (range) {
case TimeRange.today:
return 'today';
case TimeRange.sevenDays:
return 'seven_days';
case TimeRange.thirtyDays:
return 'thirty_days';
case TimeRange.custom:
return 'custom';
}
}
Future<void> loadMoreHistory() async {
if (state.isLoadingMore || !state.hasMoreHistory) return;
final identificator = _identificator;

View File

@@ -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/domain/entities/app_usage_time_entity.dart';
@@ -15,12 +18,14 @@ final appsUseViewModelProvider =
class AppsUseViewModel extends Notifier<AppsUseViewState> {
late final AppUsageTimesRepository _repository;
late final SfTrackingRepository _tracking;
static const int _pageSize = 20;
@override
AppsUseViewState build() {
_repository = ref.read(appUsageTimesRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
Future.microtask(_init);
return const AppsUseViewState();
}
@@ -31,6 +36,7 @@ class AppsUseViewModel extends Notifier<AppsUseViewState> {
if (range == state.timeRange) return;
state = state.copyWith(timeRange: range, isLoading: true);
await _loadData();
_fireTimeRangeEvent();
}
Future<void> selectCustomRange(DateTime start, DateTime end) async {
@@ -41,6 +47,33 @@ class AppsUseViewModel extends Notifier<AppsUseViewState> {
isLoading: true,
);
await _loadData();
_fireTimeRangeEvent();
}
void _fireTimeRangeEvent() {
final topAppName = state.topApps.isNotEmpty
? state.topApps.first.name
: '';
unawaited(
_tracking.legacyDeviceAppsUseTimeRangeChanged(
range: _timeRangeName(state.timeRange),
totalDurationSeconds: state.totalDuration,
topAppName: topAppName,
),
);
}
String _timeRangeName(TimeRange range) {
switch (range) {
case TimeRange.today:
return 'today';
case TimeRange.sevenDays:
return 'seven_days';
case TimeRange.thirtyDays:
return 'thirty_days';
case TimeRange.custom:
return 'custom';
}
}
Future<void> loadMore() async {

View File

@@ -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 '../../data/call_history_datasource.dart';
import '../../data/call_history_datasource_provider.dart';
@@ -13,10 +16,12 @@ final callHistoryViewModelProvider =
class CallHistoryViewModel extends Notifier<CallHistoryViewState> {
late final CallHistoryDatasource _datasource;
late final SfTrackingRepository _tracking;
@override
CallHistoryViewState build() {
_datasource = ref.read(callHistoryDatasourceProvider);
_tracking = ref.read(sfTrackingProvider);
Future.microtask(() => _load());
return const CallHistoryViewState();
}
@@ -46,6 +51,12 @@ class CallHistoryViewModel extends Notifier<CallHistoryViewState> {
}
void setFilter(CallFilter filter) {
if (filter == state.filter) return;
unawaited(
_tracking.legacyDeviceCallHistoryFilterChanged(_filterName(filter)),
);
state = state.copyWith(
filter: filter,
filteredCalls: _applyFilter(state.calls, filter),
@@ -67,4 +78,17 @@ class CallHistoryViewModel extends Notifier<CallHistoryViewState> {
return calls.where((c) => !c.isAccepted).toList();
}
}
String _filterName(CallFilter filter) {
switch (filter) {
case CallFilter.all:
return 'all';
case CallFilter.incoming:
return 'incoming';
case CallFilter.outgoing:
return 'outgoing';
case CallFilter.missed:
return 'missed';
}
}
}

View File

@@ -90,7 +90,9 @@ class ContactsViewModel extends Notifier<ContactsViewState> {
await _contactsRepository.createContact(request: request);
if (!ref.mounted) return false;
unawaited(_tracking.legacyContactsAdded());
unawaited(
_tracking.legacyContactsAdded(totalCount: state.contacts.length + 1),
);
await _reload();
return true;
@@ -150,7 +152,11 @@ class ContactsViewModel extends Notifier<ContactsViewState> {
await _contactsRepository.deleteContact(contactId: contact.id);
if (!ref.mounted) return false;
unawaited(_tracking.legacyContactsDeleted());
unawaited(
_tracking.legacyContactsDeleted(
totalCount: (state.contacts.length - 1).clamp(0, 999),
),
);
await _reload();
return true;

View File

@@ -70,6 +70,9 @@ class HealthViewModel extends Notifier<HealthViewState> {
Future<void> selectTimeRange(TimeRange range) async {
if (range == state.timeRange) return;
state = state.copyWith(timeRange: range, isLoading: true, errorEvent: null);
unawaited(
_tracking.legacyDeviceHealthTimeRangeChanged(_timeRangeName(range)),
);
await _loadFilteredData();
}
@@ -81,9 +84,23 @@ class HealthViewModel extends Notifier<HealthViewState> {
isLoading: true,
errorEvent: null,
);
unawaited(_tracking.legacyDeviceHealthTimeRangeChanged('custom'));
await _loadFilteredData();
}
String _timeRangeName(TimeRange range) {
switch (range) {
case TimeRange.today:
return 'today';
case TimeRange.sevenDays:
return 'seven_days';
case TimeRange.thirtyDays:
return 'thirty_days';
case TimeRange.custom:
return 'custom';
}
}
Future<void> loadMoreHistory() async {
if (state.isLoadingMore || !state.hasMoreHistory) return;
final identificator = _identificator;

View File

@@ -28,6 +28,8 @@ class LocateDeviceViewModel extends Notifier<LocateDeviceViewState> {
Future<void> locateDevice() async {
if (state.isLoading) return;
unawaited(_tracking.legacyDeviceLocateRequested());
try {
state = state.copyWith(
isLoading: true,
@@ -41,10 +43,12 @@ class LocateDeviceViewModel extends Notifier<LocateDeviceViewState> {
);
await _commandsRepository.send(request: request);
unawaited(_tracking.legacyDeviceLocateRequested());
unawaited(_tracking.legacyDeviceLocateSuccess());
state = state.copyWith(isLoading: false, isComplete: true);
} catch (e) {
unawaited(_tracking.legacyDeviceLocateFailure(e.toString()));
state = state.copyWith(
isLoading: false,
isComplete: false,

View File

@@ -81,16 +81,19 @@ class RemoteConnectionViewModel extends Notifier<RemoteConnectionViewState> {
pictureIndex = state.pictures.length - 1;
}
unawaited(_tracking.legacyDeviceRemoteConnectionPictureViewed('prev'));
state = state.copyWith(pictureIndex: pictureIndex);
}
void nextPicture() {
int pictureIndex = (state.pictureIndex + 1) % state.pictures.length;
unawaited(_tracking.legacyDeviceRemoteConnectionPictureViewed('next'));
state = state.copyWith(pictureIndex: pictureIndex);
}
void setPictureIndex(int value) {
unawaited(_tracking.legacyDeviceRemoteConnectionPictureViewed('direct'));
state = state.copyWith(pictureIndex: value);
}

View File

@@ -103,7 +103,12 @@ class ScheduledActivitiesViewModel
await _repository.createActivity(request: request);
if (!ref.mounted) return;
unawaited(_tracking.legacyDeviceScheduledActivityAdded());
unawaited(
_tracking.legacyDeviceScheduledActivityAdded(
weekDay: weekDay,
period: period,
),
);
await _reload();
} catch (e) {
@@ -145,7 +150,12 @@ class ScheduledActivitiesViewModel
);
if (!ref.mounted) return;
unawaited(_tracking.legacyDeviceScheduledActivityUpdated());
unawaited(
_tracking.legacyDeviceScheduledActivityUpdated(
weekDay: weekDay,
period: period,
),
);
await _reload();
} catch (e) {

View File

@@ -53,6 +53,14 @@ class VolumeControlViewModel extends Notifier<VolumeControlViewState> {
final device = state.device;
if (device == null) return;
// Capture which volume types actually changed compared to the device's
// previous settings, so we only emit analytics for the ones the user
// actually moved.
final previous = device.settings.volume;
final mediaChanged = previous.media != state.media;
final ringtoneChanged = previous.ringtone != state.ringtone;
final alarmChanged = previous.alarm != state.alarm;
try {
state = state.copyWith(
isLoading: true,
@@ -76,12 +84,30 @@ class VolumeControlViewModel extends Notifier<VolumeControlViewState> {
if (!ref.mounted) return;
ref.syncDeviceSettings(device, updatedSettings);
unawaited(
_tracking.legacyDeviceVolumeControlChanged(
type: 'media',
level: state.media,
),
);
if (mediaChanged) {
unawaited(
_tracking.legacyDeviceVolumeControlChanged(
type: 'media',
level: state.media,
),
);
}
if (ringtoneChanged) {
unawaited(
_tracking.legacyDeviceVolumeControlChanged(
type: 'ringtone',
level: state.ringtone,
),
);
}
if (alarmChanged) {
unawaited(
_tracking.legacyDeviceVolumeControlChanged(
type: 'alarm',
level: state.alarm,
),
);
}
state = state.copyWith(isLoading: false, isComplete: true);
} catch (e) {

View File

@@ -28,12 +28,18 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
late final TextEditingController watchCodeController;
late final TextEditingController activationKeyController;
/// When the user entered the current step. Used to compute how long they
/// spent on each step and fire `step_completed` with a `duration_seconds`
/// param — super useful for marketing/UX to see where users get stuck.
DateTime _currentStepEnteredAt = DateTime.now();
@override
LegacyDeviceSetupViewState build() {
final initial = const LegacyDeviceSetupViewState();
_initControllers(initial);
_addListeners();
_tracking = ref.read(sfTrackingProvider);
_currentStepEnteredAt = DateTime.now();
unawaited(_tracking.legacyDeviceSetupStarted());
@@ -42,6 +48,19 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
return initial;
}
/// Emits a `step_completed` event for the step the user is LEAVING and
/// resets the step timer so the next step starts fresh.
void _completeCurrentStep(String stepName) {
final duration = DateTime.now().difference(_currentStepEnteredAt).inSeconds;
unawaited(
_tracking.legacyDeviceSetupStepCompleted(
step: stepName,
durationSeconds: duration,
),
);
_currentStepEnteredAt = DateTime.now();
}
void _initControllers(LegacyDeviceSetupViewState s) {
_deviceSetupRepository = ref.read(legacyDeviceSetupRepositoryProvider);
@@ -69,11 +88,11 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
Future<void> next() async {
switch (state.step) {
case LegacyAddKidStep.intro:
unawaited(_tracking.legacyDeviceSetupStepCompleted('intro'));
_completeCurrentStep('intro');
state = state.copyWith(step: LegacyAddKidStep.linkInfo);
return;
case LegacyAddKidStep.linkInfo:
unawaited(_tracking.legacyDeviceSetupStepCompleted('link_info'));
_completeCurrentStep('link_info');
state = state.copyWith(step: LegacyAddKidStep.scanWatch);
return;
case LegacyAddKidStep.scanWatch:
@@ -85,6 +104,11 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
state = state.copyWith(errorMessage: I18n.errorScanWatchRequired);
return;
}
// Distinguish QR vs manual code entry: if watchCode is populated and
// watchQr is empty, the user typed it manually.
if (state.watchCode.isNotEmpty && state.watchQr.isEmpty) {
unawaited(_tracking.legacyDeviceSetupManualCodeEntered());
}
await _generateActivationKey(identificator);
return;
case LegacyAddKidStep.profile:
@@ -99,7 +123,7 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
identificator: identificator,
);
if (!ref.mounted) return;
unawaited(_tracking.legacyDeviceSetupStepCompleted('scan_watch'));
_completeCurrentStep('scan_watch');
state = state.copyWith(isLoading: false, step: LegacyAddKidStep.profile);
} catch (e) {
if (!ref.mounted) return;
@@ -133,6 +157,10 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
}
void onWatchQrScanned(String qr) {
unawaited(_tracking.legacyDeviceSetupQrScanned());
// The QR scan jumps directly from scan_watch to profile, so complete
// the scan_watch step with its timer too.
_completeCurrentStep('scan_watch');
state = state.copyWith(watchQr: qr, step: LegacyAddKidStep.profile);
}
@@ -173,7 +201,22 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
if (!ref.mounted) return false;
unawaited(_tracking.legacyDeviceSetupCompleted());
final now = DateTime.now();
final ageYears =
now.year -
birth.year -
((now.month < birth.month ||
(now.month == birth.month && now.day < birth.day))
? 1
: 0);
unawaited(
_tracking.legacyDeviceSetupCompleted(
childGender: genrer,
relationType: relationType,
childAgeYears: ageYears,
),
);
state = state.copyWith(isLoading: false, isSuccess: true);
return true;
@@ -285,6 +328,9 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
}
void resetForNewKid() {
unawaited(_tracking.legacyDeviceSetupResetForNewKid());
_currentStepEnteredAt = DateTime.now();
firstNameController.clear();
lastNameController.clear();
bornAtController.clear();

View File

@@ -91,6 +91,8 @@ class LegacyLinkPhoneViewModel extends Notifier<LegacyLinkPhoneViewState> {
} catch (e) {
if (!ref.mounted) return;
unawaited(_tracking.legacyAuthLinkPhoneCodeRequestFailed(e.toString()));
state = state.copyWith(
isLoading: false,
errorMessage: e.toString(),
@@ -142,6 +144,10 @@ class LegacyLinkPhoneViewModel extends Notifier<LegacyLinkPhoneViewState> {
} catch (e) {
if (!ref.mounted) return;
unawaited(
_tracking.legacyAuthLinkPhoneCodeVerificationFailed(e.toString()),
);
state = state.copyWith(
isLoading: false,
errorMessage: e.toString(),

View File

@@ -179,6 +179,7 @@ class LegacyRecoverPasswordViewModel
final String password = state.password;
if (!state.equalPasswords) {
unawaited(_tracking.legacyAuthPasswordResetFailed('unequal_passwords'));
state = state.copyWith(
errorMessage: 'errorMessageUnequalPasswords',
passwordChanged: false,
@@ -187,6 +188,7 @@ class LegacyRecoverPasswordViewModel
}
if (!state.securityChecks['min']!) {
unawaited(_tracking.legacyAuthPasswordResetFailed('too_short'));
state = state.copyWith(
errorMessage: 'errorMessagePasswordTooShort',
passwordChanged: false,
@@ -195,6 +197,7 @@ class LegacyRecoverPasswordViewModel
}
if (!state.securityChecks['capital']!) {
unawaited(_tracking.legacyAuthPasswordResetFailed('no_capitals'));
state = state.copyWith(
errorMessage: 'errorMessagePasswordNoCapitals',
passwordChanged: false,
@@ -203,6 +206,7 @@ class LegacyRecoverPasswordViewModel
}
if (!state.securityChecks['number']!) {
unawaited(_tracking.legacyAuthPasswordResetFailed('no_numbers'));
state = state.copyWith(
errorMessage: 'errorMessagePasswordNoNumbers',
passwordChanged: false,
@@ -211,6 +215,7 @@ class LegacyRecoverPasswordViewModel
}
if (!state.securityChecks['special']!) {
unawaited(_tracking.legacyAuthPasswordResetFailed('no_special_chars'));
state = state.copyWith(
errorMessage: 'errorMessagePasswordNoSpecialChars',
passwordChanged: false,
@@ -224,8 +229,10 @@ class LegacyRecoverPasswordViewModel
newPassword: password,
token: state.token,
);
unawaited(_tracking.legacyAuthPasswordResetCompleted());
state = state.copyWith(isLoading: false, passwordChanged: true);
} catch (error) {
unawaited(_tracking.legacyAuthPasswordResetFailed(error.toString()));
state = state.copyWith(
errorMessage: error.toString(),
isLoading: false,

View File

@@ -348,22 +348,28 @@ class LegacySignUpViewModel extends Notifier<LegacySignUpViewState>
void next() {
if (state.isLoading) return;
final ok = switch (state.currentIndex) {
final currentStep = state.currentIndex;
final ok = switch (currentStep) {
0 => _validateStep0(),
1 => _validateStep1(),
2 => _validateStep2(),
_ => true,
};
if (!ok) return;
if (!ok) {
unawaited(_tracking.legacyAuthSignupStepValidationFailed(currentStep));
return;
}
if (state.currentIndex >= _lastIndex) {
unawaited(_tracking.legacyAuthSignupStepCompleted(currentStep));
if (currentStep >= _lastIndex) {
unawaited(signUp());
return;
}
state = state.copyWith(
currentIndex: (state.currentIndex + 1).clamp(0, _lastIndex),
currentIndex: (currentStep + 1).clamp(0, _lastIndex),
);
}
@@ -371,6 +377,8 @@ class LegacySignUpViewModel extends Notifier<LegacySignUpViewState>
if (state.isLoading) return;
if (state.currentIndex <= 0) return;
unawaited(_tracking.legacyAuthSignupStepBack(state.currentIndex));
state = state.copyWith(
currentIndex: (state.currentIndex - 1).clamp(0, _lastIndex),
);

View File

@@ -16,12 +16,21 @@ final locationMapViewModelProvider =
class LocationMapViewModel extends Notifier<LocationMapViewState> {
late final SfTrackingRepository _tracking;
/// Debounce timer for map zoom tracking. We do NOT want to fire an event
/// for every zoom delta during a pinch gesture; we wait until the user
/// stops zooming for a brief moment and then log the final zoom level.
Timer? _zoomDebounce;
static const Duration _zoomDebounceDelay = Duration(seconds: 1);
@override
LocationMapViewState build() {
_tracking = ref.read(sfTrackingProvider);
ref.onDispose(() => _zoomDebounce?.cancel());
return const LocationMapViewState();
}
// ─── Layer toggles ─────────────────────────────────────────────────────
void toggleGeofences() {
final newVisible = !state.showGeofences;
unawaited(_tracking.legacyLocationMapGeofencesToggled(newVisible));
@@ -34,11 +43,33 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(showFrequentPlaces: newVisible);
}
// ─── Place creation funnel ────────────────────────────────────────────
void startPlacing(PlacingMode mode) {
unawaited(_tracking.legacyLocationPlaceCreationStarted(_modeName(mode)));
state = state.copyWith(placingMode: mode);
}
void cancelPlacing() {
// Infer mode + step from current state BEFORE we reset it so we can
// send meaningful funnel drop-off data to analytics.
final wasPicking = state.placingMode != PlacingMode.none;
final wasAdjustingRadius = state.adjustingRadius;
if (wasPicking || wasAdjustingRadius) {
final mode = wasPicking
? _modeName(state.placingMode)
// Only geofences reach the radius-adjust step.
: 'geofence';
final atStep = wasPicking ? 'picking_point' : 'adjusting_radius';
unawaited(
_tracking.legacyLocationPlaceCreationCancelled(
mode: mode,
atStep: atStep,
),
);
}
state = state.copyWith(
placingMode: PlacingMode.none,
adjustingRadius: false,
@@ -47,6 +78,7 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void confirmGeofencePlacement(LatLng center) {
unawaited(_tracking.legacyLocationPointConfirmed('geofence'));
state = state.copyWith(
placingMode: PlacingMode.none,
adjustingRadius: true,
@@ -56,14 +88,24 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void confirmFrequentPlacePlacement() {
unawaited(_tracking.legacyLocationPointConfirmed('frequent_place'));
state = state.copyWith(placingMode: PlacingMode.none, previewPoint: null);
}
void updatePreviewRadius(double radius) {
// Intentionally NOT tracked: this fires on every slider tick. The final
// radius is captured in [confirmRadius] once the user settles.
state = state.copyWith(previewRadius: radius);
}
void confirmRadius() {
final isEditing = state.editingGeofence != null;
unawaited(
_tracking.legacyLocationRadiusConfirmed(
radius: state.previewRadius,
isEditing: isEditing,
),
);
state = state.copyWith(adjustingRadius: false, editingGeofence: null);
}
@@ -71,7 +113,10 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(previewPoint: null);
}
// ─── Exploration / selection ──────────────────────────────────────────
void selectGeofence(GeofenceEntity geofence) {
unawaited(_tracking.legacyLocationGeofenceSelected());
state = state.copyWith(
selectedGeofence: geofence,
selectedFrequentPlace: null,
@@ -79,10 +124,14 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void clearSelectedGeofence() {
if (state.selectedGeofence != null) {
unawaited(_tracking.legacyLocationGeofenceDismissed());
}
state = state.copyWith(selectedGeofence: null);
}
void startEditingGeofence(GeofenceEntity geofence) {
unawaited(_tracking.legacyLocationGeofenceEditStarted());
state = state.copyWith(
selectedGeofence: null,
editingGeofence: geofence,
@@ -93,6 +142,7 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void selectFrequentPlace(FrequentPlaceEntity place) {
unawaited(_tracking.legacyLocationFrequentPlaceSelected());
state = state.copyWith(
selectedFrequentPlace: place,
selectedGeofence: null,
@@ -100,27 +150,47 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void clearSelectedFrequentPlace() {
if (state.selectedFrequentPlace != null) {
unawaited(_tracking.legacyLocationFrequentPlaceDismissed());
}
state = state.copyWith(selectedFrequentPlace: null);
}
void selectHistoryPosition(PositionEntity position) {
unawaited(_tracking.legacyLocationHistoryPositionSelected());
state = state.copyWith(selectedHistoryPosition: position);
}
void clearSelectedHistoryPosition() {
if (state.selectedHistoryPosition != null) {
unawaited(_tracking.legacyLocationHistoryPositionDismissed());
}
state = state.copyWith(selectedHistoryPosition: null);
}
// ─── Live tracking ────────────────────────────────────────────────────
void toggleFollowing() {
state = state.copyWith(isFollowing: !state.isFollowing);
final newFollowing = !state.isFollowing;
unawaited(_tracking.legacyLocationFollowingToggled(newFollowing));
state = state.copyWith(isFollowing: newFollowing);
}
void stopFollowing() {
// Called automatically when the user pans the map manually. We track
// this as a "following disabled" event for consistency with toggle.
if (state.isFollowing) {
unawaited(_tracking.legacyLocationFollowingToggled(false));
}
state = state.copyWith(isFollowing: false);
}
// ─── UI chrome ────────────────────────────────────────────────────────
void toggleActionsExpanded() {
state = state.copyWith(actionsExpanded: !state.actionsExpanded);
final newExpanded = !state.actionsExpanded;
unawaited(_tracking.legacyLocationMapActionsExpanded(newExpanded));
state = state.copyWith(actionsExpanded: newExpanded);
}
void toggleFrequencyExpanded() {
@@ -131,7 +201,30 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(frequencyExpanded: false);
}
// ─── Map zoom (debounced) ─────────────────────────────────────────────
void updateMapZoom(double zoom) {
state = state.copyWith(mapZoom: zoom);
// Debounce: cancel any pending fire and schedule a new one. If the user
// keeps zooming, the timer keeps resetting and we only fire once the
// zoom settles for [_zoomDebounceDelay].
_zoomDebounce?.cancel();
_zoomDebounce = Timer(_zoomDebounceDelay, () {
unawaited(_tracking.legacyLocationMapZoomed(zoom));
});
}
// ─── Helpers ──────────────────────────────────────────────────────────
String _modeName(PlacingMode mode) {
switch (mode) {
case PlacingMode.geofence:
return 'geofence';
case PlacingMode.frequentPlace:
return 'frequent_place';
case PlacingMode.none:
return 'none';
}
}
}

View File

@@ -309,6 +309,9 @@ class LocationViewModel extends Notifier<LocationViewState> {
}
void clearPositionHistory() {
if (state.positionHistory.isNotEmpty) {
unawaited(_tracking.legacyLocationHistoryCleared());
}
state = state.copyWith(positionHistory: [], showRouteTrail: false);
}

View File

@@ -1,10 +1,14 @@
import 'dart:async';
import 'package:control_panel/control_panel.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:location/src/core/domain/entities/geofence_entity.dart';
import 'package:location/src/core/domain/entities/frequent_place_entity.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_tracking/sf_tracking.dart';
class LocationListSheet extends StatefulWidget {
class LocationListSheet extends ConsumerStatefulWidget {
final List<GeofenceEntity> geofences;
final List<FrequentPlaceEntity> frequentPlaces;
final List<PositionEntity> positionHistory;
@@ -25,10 +29,10 @@ class LocationListSheet extends StatefulWidget {
});
@override
State<LocationListSheet> createState() => _LocationListSheetState();
ConsumerState<LocationListSheet> createState() => _LocationListSheetState();
}
class _LocationListSheetState extends State<LocationListSheet> {
class _LocationListSheetState extends ConsumerState<LocationListSheet> {
String? _selectedType;
List<PositionEntity> get _filteredHistory {
@@ -170,7 +174,14 @@ class _LocationListSheetState extends State<LocationListSheet> {
Widget _buildFilterChip({required String label, required String? value}) {
final isSelected = _selectedType == value;
return GestureDetector(
onTap: () => setState(() => _selectedType = value),
onTap: () {
unawaited(
ref
.read(sfTrackingProvider)
.legacyLocationHistoryTypeFilterChanged(value ?? 'all'),
);
setState(() => _selectedType = value);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),

View File

@@ -15,6 +15,7 @@ import 'package:location/src/features/location/presentation/state/location_map_v
import 'package:location/src/features/location/presentation/state/location_view_model.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'package:utils/utils.dart';
import 'device_banner.dart';
@@ -162,6 +163,9 @@ class _LocationMapState extends ConsumerState<LocationMap>
void _centerOnDevice() {
if (widget.selectedPosition == null) return;
unawaited(
ref.read(sfTrackingProvider).legacyLocationMapCenterTapped(),
);
_animatedMove(
LatLng(
widget.selectedPosition!.latitude,
@@ -217,6 +221,8 @@ class _LocationMapState extends ConsumerState<LocationMap>
if (addressStr.isNotEmpty) text.writeln(addressStr);
text.writeln(mapsUrl);
unawaited(ref.read(sfTrackingProvider).legacyLocationShared());
Share.share(text.toString().trim());
}
@@ -318,6 +324,7 @@ class _LocationMapState extends ConsumerState<LocationMap>
}
void _showListSheet() {
unawaited(ref.read(sfTrackingProvider).legacyLocationListSheetOpened());
final locationState = ref.read(locationViewModelProvider);
final mapState = ref.read(locationMapViewModelProvider);
showModalBottomSheet(
@@ -650,7 +657,12 @@ class _LocationMapState extends ConsumerState<LocationMap>
onAddGeofence: () => _vm.startPlacing(PlacingMode.geofence),
onAddFrequentPlace: () => _vm.startPlacing(PlacingMode.frequentPlace),
onShareTap: _shareLocation,
onRefreshTap: widget.onRefreshPosition,
onRefreshTap: () {
unawaited(
ref.read(sfTrackingProvider).legacyLocationMapRefreshTapped(),
);
widget.onRefreshPosition();
},
onCenterTap: _centerOnDevice,
),
),

View File

@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
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';
const _labelKeys = {
MapStyle.standard: I18n.locationMapStyleStandard,
@@ -65,6 +68,11 @@ class _MapStyleSelectorState extends ConsumerState<MapStyleSelector> {
return InkWell(
onTap: () {
ref.read(mapStyleProvider.notifier).setStyle(style);
unawaited(
ref
.read(sfTrackingProvider)
.legacyLocationMapStyleChanged(style.name),
);
setState(() => _expanded = false);
},
child: Container(

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
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';
@@ -29,6 +30,12 @@ class AlarmViewModel extends Notifier<AlarmViewState> {
return const AlarmViewState();
}
String _formatTime(TimeOfDay time) {
final hh = time.hour.toString().padLeft(2, '0');
final mm = time.minute.toString().padLeft(2, '0');
return '$hh:$mm';
}
Future<void> _load() async {
try {
final device = ref.read(selectedDeviceProvider);
@@ -68,7 +75,9 @@ class AlarmViewModel extends Notifier<AlarmViewState> {
order: state.alarms.length + 1,
);
unawaited(_tracking.legacySettingsAlarmAdded());
unawaited(
_tracking.legacySettingsAlarmAdded(time: _formatTime(alarm.time)),
);
state = state.copyWith(
alarms: [...state.alarms, updatedAlarm],
@@ -100,7 +109,9 @@ class AlarmViewModel extends Notifier<AlarmViewState> {
.map((a) => a.id == alarm.id ? alarm : a)
.toList();
unawaited(_tracking.legacySettingsAlarmUpdated());
unawaited(
_tracking.legacySettingsAlarmUpdated(time: _formatTime(alarm.time)),
);
state = state.copyWith(
alarms: updatedAlarms,

View File

@@ -68,7 +68,16 @@ class AlertsViewModel extends Notifier<AlertsViewState> {
if (!ref.mounted) return;
ref.syncDeviceSettings(device, updatedSettings);
unawaited(_tracking.legacySettingsAlertsConfigured());
final alertsJoined = state.activeAlerts.join(',');
final truncated = alertsJoined.length > 100
? alertsJoined.substring(0, 100)
: alertsJoined;
unawaited(
_tracking.legacySettingsAlertsConfigured(
alertCount: state.activeAlerts.length,
alertsEnabled: truncated,
),
);
state = state.copyWith(isSaving: false, saveSuccess: true);
} catch (e) {

View File

@@ -56,7 +56,11 @@ class BlockPhoneViewModel extends Notifier<BlockPhoneViewState> {
contacts: updatedContacts,
);
unawaited(_tracking.legacySettingsBlockPhoneContactAdded());
unawaited(
_tracking.legacySettingsBlockPhoneContactAdded(
totalCount: updatedContacts.length,
),
);
state = state.copyWith(
contacts: updatedContacts,
@@ -86,7 +90,11 @@ class BlockPhoneViewModel extends Notifier<BlockPhoneViewState> {
contacts: updatedContacts,
);
unawaited(_tracking.legacySettingsBlockPhoneContactRemoved());
unawaited(
_tracking.legacySettingsBlockPhoneContactRemoved(
totalCount: updatedContacts.length,
),
);
state = state.copyWith(
contacts: updatedContacts,

View File

@@ -1,5 +1,9 @@
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 'remote_management_view_state.dart';
final remoteManagementViewModelProvider =
@@ -10,10 +14,12 @@ final remoteManagementViewModelProvider =
class RemoteManagementViewModel extends Notifier<RemoteManagementViewState> {
late final CommandsRepository _commandsRepository;
late final SfTrackingRepository _tracking;
@override
RemoteManagementViewState build() {
_commandsRepository = ref.read(commandsRepositoryProvider);
_tracking = ref.read(sfTrackingProvider);
state = const RemoteManagementViewState();
@@ -46,14 +52,17 @@ class RemoteManagementViewModel extends Notifier<RemoteManagementViewState> {
}
Future<void> shutdown() async {
unawaited(_tracking.legacySettingsRemoteManagementShutdown());
await tryCommand(DeviceCommand.shutdown);
}
Future<void> restart() async {
unawaited(_tracking.legacySettingsRemoteManagementRestart());
await tryCommand(DeviceCommand.restart);
}
Future<void> factoryReset() async {
unawaited(_tracking.legacySettingsRemoteManagementFactoryReset());
await tryCommand(DeviceCommand.factory);
}
}

View File

@@ -58,7 +58,11 @@ class SosContactsViewModel extends Notifier<SosContactsViewState> {
contacts: updatedContacts,
);
unawaited(_tracking.legacySettingsSosContactAdded());
unawaited(
_tracking.legacySettingsSosContactAdded(
totalCount: updatedContacts.length,
),
);
state = state.copyWith(
contacts: updatedContacts,
@@ -88,7 +92,11 @@ class SosContactsViewModel extends Notifier<SosContactsViewState> {
contacts: updatedContacts,
);
unawaited(_tracking.legacySettingsSosContactRemoved());
unawaited(
_tracking.legacySettingsSosContactRemoved(
totalCount: updatedContacts.length,
),
);
state = state.copyWith(
contacts: updatedContacts,

View File

@@ -67,7 +67,9 @@ class SoundViewModel extends Notifier<SoundViewState> {
if (!ref.mounted) return;
ref.syncDeviceSettings(device, updatedSettings);
unawaited(_tracking.legacySettingsSoundChanged());
unawaited(
_tracking.legacySettingsSoundChanged(mode: state.soundOption ?? ''),
);
state = state.copyWith(isLoading: false, isComplete: true);
} catch (e) {

View File

@@ -56,7 +56,9 @@ class WifiSettingsViewModel extends Notifier<WifiSettingsViewState> {
final networks = await _repository.getWifiNetworks(deviceId: device.id);
unawaited(_tracking.legacySettingsWifiAdded());
unawaited(
_tracking.legacySettingsWifiAdded(totalCount: networks.length),
);
state = state.copyWith(
networks: networks,
@@ -82,7 +84,9 @@ class WifiSettingsViewModel extends Notifier<WifiSettingsViewState> {
final networks = await _repository.getWifiNetworks(deviceId: device.id);
unawaited(_tracking.legacySettingsWifiRemoved());
unawaited(
_tracking.legacySettingsWifiRemoved(totalCount: networks.length),
);
state = state.copyWith(
networks: networks,

View File

@@ -21,6 +21,17 @@ mixin AccountTracking on Tracking {
Future<void> legacyAccountLinkedDeviceUnlinked() =>
trackEvent('${_prefix}_linked_device_unlinked');
/// User renamed a linked device (edited the carrier name on the linked
/// devices screen).
Future<void> legacyAccountLinkedDeviceRenamed() =>
trackEvent('${_prefix}_linked_device_renamed');
/// User tapped "delete" on the app users screen. Note: the current
/// implementation is known to delete the logged user (placeholder logic);
/// the event is still useful to measure demand for this feature.
Future<void> legacyAccountAppUserDeleteTriggered() =>
trackEvent('${_prefix}_app_user_delete_triggered');
/// ⚠️ CHURN SIGNAL — User opened the account deletion flow.
Future<void> legacyAccountDeletionInitiated() =>
trackEvent('${_prefix}_deletion_initiated');

View File

@@ -46,6 +46,25 @@ mixin AuthTracking on Tracking {
Future<void> legacyAuthSignupFailed(String reason) =>
trackEvent('${_prefix}_signup_failed', {'reason': reason});
/// User successfully advanced past a signup form step (validation passed
/// and they moved to the next page). [stepIndex] is 0-based.
Future<void> legacyAuthSignupStepCompleted(int stepIndex) =>
trackEvent('${_prefix}_signup_step_completed', {
'step_index': stepIndex,
});
/// User went back to a previous signup step. [stepIndex] is the step they
/// JUST LEFT (i.e. the one they came from).
Future<void> legacyAuthSignupStepBack(int stepIndex) =>
trackEvent('${_prefix}_signup_step_back', {'step_index': stepIndex});
/// User tried to advance past a step but client-side validation failed.
/// [stepIndex] is 0-based.
Future<void> legacyAuthSignupStepValidationFailed(int stepIndex) =>
trackEvent('${_prefix}_signup_step_validation_failed', {
'step_index': stepIndex,
});
// ─── Password recovery ───────────────────────────────────────────────────
/// User requested a password reset (form submitted, request in flight).
@@ -56,16 +75,38 @@ mixin AuthTracking on Tracking {
Future<void> legacyAuthPasswordResetEmailSent() =>
trackEvent('${_prefix}_password_reset_email_sent');
/// ⭐ User finished the password recovery flow (new password saved on
/// backend). This is the final conversion of the recovery funnel.
Future<void> legacyAuthPasswordResetCompleted() =>
trackEvent('${_prefix}_password_reset_completed');
/// Password recovery failed — either client validation (weak password) or
/// backend error.
Future<void> legacyAuthPasswordResetFailed(String reason) =>
trackEvent('${_prefix}_password_reset_failed', {'reason': reason});
// ─── Phone linking ───────────────────────────────────────────────────────
/// User submitted a phone number to receive an OTP code for phone linking.
Future<void> legacyAuthLinkPhoneCodeRequested() =>
trackEvent('${_prefix}_link_phone_code_requested');
/// Requesting the OTP code from the backend failed.
Future<void> legacyAuthLinkPhoneCodeRequestFailed(String reason) =>
trackEvent('${_prefix}_link_phone_code_request_failed', {
'reason': reason,
});
/// User successfully verified the OTP code and the phone is linked.
Future<void> legacyAuthLinkPhoneCodeVerified() =>
trackEvent('${_prefix}_link_phone_code_verified');
/// OTP verification for phone linking failed (wrong code, expired, etc).
Future<void> legacyAuthLinkPhoneCodeVerificationFailed(String reason) =>
trackEvent('${_prefix}_link_phone_code_verification_failed', {
'reason': reason,
});
// ─── Logout ──────────────────────────────────────────────────────────────
/// User logged out and the session has been cleared.

View File

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

View File

@@ -6,8 +6,14 @@ const _prefix = 'legacy_control_panel';
/// device cards and a position map). Mixed into `SfTrackingRepository`.
mixin ControlPanelTracking on Tracking {
/// User switched the active device (which kid they're monitoring).
Future<void> legacyControlPanelDeviceSelected() =>
trackEvent('${_prefix}_device_selected');
/// [totalDevices] is how many devices the user has linked — a proxy for
/// family size (more kids = stickier segment).
Future<void> legacyControlPanelDeviceSelected({
required int totalDevices,
}) =>
trackEvent('${_prefix}_device_selected', {
'total_devices': totalDevices,
});
/// User triggered a manual refresh of device positions (pull-to-refresh
/// or refresh button).

View File

@@ -11,11 +11,30 @@ mixin DeviceSetupTracking on Tracking {
/// User completed an individual step of the wizard. [step] is the name of
/// the step they just left (intro, link_info, scan_watch, profile, etc).
Future<void> legacyDeviceSetupStepCompleted(String step) =>
trackEvent('${_prefix}_step_completed', {'step': step});
/// [durationSeconds] is how long they spent on that step.
Future<void> legacyDeviceSetupStepCompleted({
required String step,
required int durationSeconds,
}) =>
trackEvent('${_prefix}_step_completed', {
'step': step,
'duration_seconds': durationSeconds,
});
/// User finished the wizard and the device was successfully created.
Future<void> legacyDeviceSetupCompleted() => trackEvent('${_prefix}_completed');
/// User finished the wizard and the device was successfully created.
/// Enriched with demographics of the kid: gender, relation type
/// (mother/father/etc) and age in years — all super valuable for marketing
/// personas.
Future<void> legacyDeviceSetupCompleted({
required String childGender,
required String relationType,
required int childAgeYears,
}) =>
trackEvent('${_prefix}_completed', {
'child_gender': childGender,
'relation_type': relationType,
'child_age_years': childAgeYears,
});
/// Wizard failed to complete due to an error. [atStep] is the step where it
/// failed and [reason] is the error message.
@@ -32,4 +51,20 @@ mixin DeviceSetupTracking on Tracking {
/// step they were on when they cancelled.
Future<void> legacyDeviceSetupCancelled(String atStep) =>
trackEvent('${_prefix}_cancelled', {'at_step': atStep});
/// User successfully scanned the watch QR code (instead of typing the code
/// manually).
Future<void> legacyDeviceSetupQrScanned() =>
trackEvent('${_prefix}_qr_scanned');
/// User advanced past the scan-watch step using a manually typed code
/// instead of scanning the QR.
Future<void> legacyDeviceSetupManualCodeEntered() =>
trackEvent('${_prefix}_manual_code_entered');
/// ⭐ After successfully creating a device, user tapped "add another kid"
/// which resets the wizard. This is the strongest signal of families with
/// multiple kids — a retention goldmine.
Future<void> legacyDeviceSetupResetForNewKid() =>
trackEvent('${_prefix}_reset_for_new_kid');
}

View File

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

View File

@@ -58,4 +58,127 @@ mixin LocationTracking on Tracking {
trackEvent('${_prefix}_map_route_trail_toggled', {
'visible': visible.toString(),
});
// ─── Place creation funnel ───────────────────────────────────────────────
/// User tapped the "add geofence" or "add frequent place" button and
/// entered placing mode. [mode] is `geofence` or `frequent_place`.
Future<void> legacyLocationPlaceCreationStarted(String mode) =>
trackEvent('${_prefix}_place_creation_started', {'mode': mode});
/// User confirmed the point on the map (tapped to drop the pin). For
/// geofences, this leads to the radius-adjusting step. For frequent places,
/// this triggers the actual creation flow.
Future<void> legacyLocationPointConfirmed(String mode) =>
trackEvent('${_prefix}_point_confirmed', {'mode': mode});
/// User confirmed the geofence radius (last step before API call).
/// [isEditing] distinguishes editing an existing geofence from creating a
/// new one.
Future<void> legacyLocationRadiusConfirmed({
required double radius,
required bool isEditing,
}) =>
trackEvent('${_prefix}_radius_confirmed', {
'radius': radius,
'is_editing': isEditing.toString(),
});
/// User abandoned the creation or edit flow before completing it. [atStep]
/// is `picking_point` or `adjusting_radius`. [mode] is the mode that was
/// active when cancelled.
Future<void> legacyLocationPlaceCreationCancelled({
required String mode,
required String atStep,
}) =>
trackEvent('${_prefix}_place_creation_cancelled', {
'mode': mode,
'at_step': atStep,
});
// ─── Exploration / edit entry points ─────────────────────────────────────
/// User tapped a geofence on the map to inspect it.
Future<void> legacyLocationGeofenceSelected() =>
trackEvent('${_prefix}_geofence_selected');
/// User tapped "edit" on a selected geofence.
Future<void> legacyLocationGeofenceEditStarted() =>
trackEvent('${_prefix}_geofence_edit_started');
/// User tapped a frequent place on the map to inspect it.
Future<void> legacyLocationFrequentPlaceSelected() =>
trackEvent('${_prefix}_frequent_place_selected');
/// User tapped a position pin from the route history to see its details.
Future<void> legacyLocationHistoryPositionSelected() =>
trackEvent('${_prefix}_history_position_selected');
/// User cleared the route history trail from the map.
Future<void> legacyLocationHistoryCleared() =>
trackEvent('${_prefix}_history_cleared');
// ─── Dismissals (complete the exploration funnels) ───────────────────────
Future<void> legacyLocationGeofenceDismissed() =>
trackEvent('${_prefix}_geofence_dismissed');
Future<void> legacyLocationFrequentPlaceDismissed() =>
trackEvent('${_prefix}_frequent_place_dismissed');
Future<void> legacyLocationHistoryPositionDismissed() =>
trackEvent('${_prefix}_history_position_dismissed');
// ─── Live tracking + UI engagement ───────────────────────────────────────
/// User toggled "follow device" mode on the map.
Future<void> legacyLocationFollowingToggled(bool enabled) =>
trackEvent('${_prefix}_following_toggled', {
'enabled': enabled.toString(),
});
/// User expanded or collapsed the actions drawer on the map.
Future<void> legacyLocationMapActionsExpanded(bool expanded) =>
trackEvent('${_prefix}_map_actions_expanded', {
'expanded': expanded.toString(),
});
// ─── Map zoom (debounced at the view model to avoid flooding) ────────────
/// User finished a zoom interaction. The view model debounces rapid zoom
/// changes (e.g. continuous pinch gestures) so this fires once the zoom has
/// settled, not on every delta.
Future<void> legacyLocationMapZoomed(double zoom) =>
trackEvent('${_prefix}_map_zoomed', {'zoom': zoom});
// ─── Map style + top-level map actions ──────────────────────────────────
/// User changed the visual style of the map. [style] is one of
/// `standard`, `voyager`, `light`, `dark`, `satellite`.
Future<void> legacyLocationMapStyleChanged(String style) =>
trackEvent('${_prefix}_map_style_changed', {'style': style});
/// ⭐ User tapped "share location" — typically sends the kid's position
/// via the native share sheet to a messaging app. This is one of the most
/// viral actions of the product.
Future<void> legacyLocationShared() => trackEvent('${_prefix}_shared');
/// User tapped the "center on device" / my-location button on the map.
Future<void> legacyLocationMapCenterTapped() =>
trackEvent('${_prefix}_map_center_tapped');
/// User tapped the refresh button on the map (different from the pull-to-
/// refresh in the control panel — this is the in-map refresh action).
Future<void> legacyLocationMapRefreshTapped() =>
trackEvent('${_prefix}_map_refresh_tapped');
/// User opened the bottom list sheet containing geofences, frequent
/// places and position history.
Future<void> legacyLocationListSheetOpened() =>
trackEvent('${_prefix}_list_sheet_opened');
/// User filtered the position-history list by type. [type] is the raw
/// position type string (e.g. `gps`, `wifi`, `sos`) or `all` when cleared.
Future<void> legacyLocationHistoryTypeFilterChanged(String type) =>
trackEvent('${_prefix}_history_type_filter_changed', {'type': type});
}

View File

@@ -3,87 +3,50 @@ 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`.
/// alerts, timezone, wifi, remote management, etc). Mixed into
/// `SfTrackingRepository`.
mixin SettingsTracking on Tracking {
// ─── Alarms ──────────────────────────────────────────────────────────────
Future<void> legacySettingsAlarmAdded() =>
trackEvent('${_prefix}_alarm_added');
/// User created a new alarm on the device. [time] is `HH:mm`.
Future<void> legacySettingsAlarmAdded({required String time}) =>
trackEvent('${_prefix}_alarm_added', {'time': time});
/// User edited an existing alarm. [time] is the new `HH:mm`.
Future<void> legacySettingsAlarmUpdated({required String time}) =>
trackEvent('${_prefix}_alarm_updated', {'time': time});
Future<void> legacySettingsAlarmRemoved() =>
trackEvent('${_prefix}_alarm_removed');
// ─── SOS contacts ────────────────────────────────────────────────────────
Future<void> legacySettingsSosContactAdded() =>
trackEvent('${_prefix}_sos_contact_added');
Future<void> legacySettingsSosContactAdded({required int totalCount}) =>
trackEvent('${_prefix}_sos_contact_added', {'total_count': totalCount});
Future<void> legacySettingsSosContactRemoved() =>
trackEvent('${_prefix}_sos_contact_removed');
Future<void> legacySettingsSosContactRemoved({required int totalCount}) =>
trackEvent('${_prefix}_sos_contact_removed', {'total_count': totalCount});
// ─── Block phone whitelist ───────────────────────────────────────────────
Future<void> legacySettingsBlockPhoneContactAdded() =>
trackEvent('${_prefix}_block_phone_contact_added');
Future<void> legacySettingsBlockPhoneContactAdded({required int totalCount}) =>
trackEvent('${_prefix}_block_phone_contact_added', {
'total_count': totalCount,
});
Future<void> legacySettingsBlockPhoneContactRemoved() =>
trackEvent('${_prefix}_block_phone_contact_removed');
Future<void> legacySettingsBlockPhoneContactRemoved({
required int totalCount,
}) =>
trackEvent('${_prefix}_block_phone_contact_removed', {
'total_count': totalCount,
});
// ─── Disable functions (parental controls) ───────────────────────────────
/// User toggled a function (e.g. `keyboard`, `gps`).
/// Legacy aggregate event: user saved the disable functions screen.
Future<void> legacySettingsDisableFunctionsChanged() =>
trackEvent('${_prefix}_disable_functions_changed');
// ─── Language ────────────────────────────────────────────────────────────
Future<void> legacySettingsLanguageChanged(String language) =>
trackEvent('${_prefix}_language_changed', {'language': language});
// ─── Alerts ──────────────────────────────────────────────────────────────
Future<void> legacySettingsAlertsConfigured() =>
trackEvent('${_prefix}_alerts_configured');
// ─── Timezone ────────────────────────────────────────────────────────────
Future<void> legacySettingsTimezoneChanged(String timezone) =>
trackEvent('${_prefix}_timezone_changed', {'timezone': timezone});
// ─── WiFi ────────────────────────────────────────────────────────────────
Future<void> legacySettingsWifiAdded() =>
trackEvent('${_prefix}_wifi_added');
Future<void> legacySettingsWifiRemoved() =>
trackEvent('${_prefix}_wifi_removed');
// ─── Sound ───────────────────────────────────────────────────────────────
Future<void> legacySettingsSoundChanged() =>
trackEvent('${_prefix}_sound_changed');
// ─── Sync clock ──────────────────────────────────────────────────────────
Future<void> legacySettingsSyncClockTriggered() =>
trackEvent('${_prefix}_sync_clock_triggered');
// ─── Alarms (extra) ──────────────────────────────────────────────────────
/// User edited an existing alarm.
Future<void> legacySettingsAlarmUpdated() =>
trackEvent('${_prefix}_alarm_updated');
// ─── Battery ─────────────────────────────────────────────────────────────
/// User toggled night mode (battery saving) on the device.
Future<void> legacySettingsBatteryNightModeToggled(bool enabled) =>
trackEvent('${_prefix}_battery_night_mode_toggled', {
'enabled': enabled.toString(),
});
// ─── Disable functions (granular) ────────────────────────────────────────
/// Granular: user toggled the device keyboard off/on.
Future<void> legacySettingsDisableFunctionsKeyboardToggled(bool enabled) =>
trackEvent('${_prefix}_disable_functions_keyboard_toggled', {
@@ -95,4 +58,71 @@ mixin SettingsTracking on Tracking {
trackEvent('${_prefix}_disable_functions_gps_toggled', {
'enabled': enabled.toString(),
});
// ─── Language ────────────────────────────────────────────────────────────
Future<void> legacySettingsLanguageChanged(String language) =>
trackEvent('${_prefix}_language_changed', {'language': language});
// ─── Alerts ──────────────────────────────────────────────────────────────
/// User saved the alerts configuration. [alertCount] is how many alerts
/// are currently enabled. [alertsEnabled] is the comma-joined list of the
/// active alert type names, truncated to a safe length.
Future<void> legacySettingsAlertsConfigured({
required int alertCount,
required String alertsEnabled,
}) =>
trackEvent('${_prefix}_alerts_configured', {
'alert_count': alertCount,
'alerts_enabled': alertsEnabled,
});
// ─── Timezone ────────────────────────────────────────────────────────────
Future<void> legacySettingsTimezoneChanged(String timezone) =>
trackEvent('${_prefix}_timezone_changed', {'timezone': timezone});
// ─── WiFi ────────────────────────────────────────────────────────────────
Future<void> legacySettingsWifiAdded({required int totalCount}) =>
trackEvent('${_prefix}_wifi_added', {'total_count': totalCount});
Future<void> legacySettingsWifiRemoved({required int totalCount}) =>
trackEvent('${_prefix}_wifi_removed', {'total_count': totalCount});
// ─── Sound ───────────────────────────────────────────────────────────────
/// User changed the sound mode of the device. [mode] is typically
/// `normal`, `silent` or `vibrate`.
Future<void> legacySettingsSoundChanged({required String mode}) =>
trackEvent('${_prefix}_sound_changed', {'mode': mode});
// ─── Sync clock ──────────────────────────────────────────────────────────
Future<void> legacySettingsSyncClockTriggered() =>
trackEvent('${_prefix}_sync_clock_triggered');
// ─── Battery ─────────────────────────────────────────────────────────────
/// User toggled night mode (battery saving) on the device.
Future<void> legacySettingsBatteryNightModeToggled(bool enabled) =>
trackEvent('${_prefix}_battery_night_mode_toggled', {
'enabled': enabled.toString(),
});
// ─── Remote management (destructive commands) ───────────────────────────
/// User triggered a remote shutdown of the device.
Future<void> legacySettingsRemoteManagementShutdown() =>
trackEvent('${_prefix}_remote_management_shutdown');
/// User triggered a remote restart of the device.
Future<void> legacySettingsRemoteManagementRestart() =>
trackEvent('${_prefix}_remote_management_restart');
/// ⚠️ CHURN SIGNAL — user triggered a factory reset of the device.
/// This effectively wipes the device and often precedes unlinking.
Future<void> legacySettingsRemoteManagementFactoryReset() =>
trackEvent('${_prefix}_remote_management_factory_reset');
}

View File

@@ -6,9 +6,17 @@ const _prefix = 'legacy_support';
/// `SfTrackingRepository`.
mixin SupportTracking on Tracking {
/// User tapped a button to contact support. [channel] is `email`, `phone`,
/// `whatsapp`, etc — depends on the support screen UX. Note that this only
/// tracks the *intent* (the button tap that opens the external client). We
/// can't confirm the message was actually sent.
Future<void> legacySupportContactInitiated(String channel) =>
trackEvent('${_prefix}_contact_initiated', {'channel': channel});
/// `whatsapp`, etc — depends on the support screen UX. [country] is the
/// country the user picked in the support form (may be empty if not
/// selected). Note that this only tracks the *intent* (the button tap
/// that opens the external client). We can't confirm the message was
/// actually sent.
Future<void> legacySupportContactInitiated({
required String channel,
required String country,
}) =>
trackEvent('${_prefix}_contact_initiated', {
'channel': channel,
'country': country,
});
}