refactor(tracking): tighten sf_tracking package

- Lazy-init sfTracking to avoid touching Firebase at import time
- DRY SfTrackingRepository with a single _broadcast helper
- Drop empty DashboardTracking, fix double step_completed in device_setup
- Move yearsBetween to packages/utils
- Add 5 unit tests for SfTrackingRepository
- Strip noisy comments from mixins and view models
This commit is contained in:
2026-04-07 16:59:38 +02:00
parent 4728e25803
commit 42ec003b05
27 changed files with 295 additions and 517 deletions

View File

@@ -126,7 +126,7 @@ class ContactViewModel extends Notifier<ContactViewState> {
unawaited(
_tracking.legacySupportContactInitiated(
channel: 'email',
country: country,
country: country.isEmpty ? 'unknown' : country,
),
);

View File

@@ -53,9 +53,6 @@ class VolumeControlViewModel extends Notifier<VolumeControlViewState> {
final device = state.device;
if (device == null) return;
// Capture which volume types actually changed compared to the device's
// previous settings, so we only emit analytics for the ones the user
// actually moved.
final previous = device.settings.volume;
final mediaChanged = previous.media != state.media;
final ringtoneChanged = previous.ringtone != state.ringtone;

View File

@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'package:utils/utils.dart';
final legacyDeviceSetupViewModelProvider =
NotifierProvider<LegacyDeviceSetupViewModel, LegacyDeviceSetupViewState>(
@@ -28,10 +29,7 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
late final TextEditingController watchCodeController;
late final TextEditingController activationKeyController;
/// When the user entered the current step. Used to compute how long they
/// spent on each step and fire `step_completed` with a `duration_seconds`
/// param — super useful for marketing/UX to see where users get stuck.
DateTime _currentStepEnteredAt = DateTime.now();
DateTime? _currentStepEnteredAt;
@override
LegacyDeviceSetupViewState build() {
@@ -48,10 +46,10 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
return initial;
}
/// Emits a `step_completed` event for the step the user is LEAVING and
/// resets the step timer so the next step starts fresh.
void _completeCurrentStep(String stepName) {
final duration = DateTime.now().difference(_currentStepEnteredAt).inSeconds;
final enteredAt = _currentStepEnteredAt;
if (enteredAt == null) return;
final duration = DateTime.now().difference(enteredAt).inSeconds;
unawaited(
_tracking.legacyDeviceSetupStepCompleted(
step: stepName,
@@ -104,8 +102,6 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
state = state.copyWith(errorMessage: I18n.errorScanWatchRequired);
return;
}
// Distinguish QR vs manual code entry: if watchCode is populated and
// watchQr is empty, the user typed it manually.
if (state.watchCode.isNotEmpty && state.watchQr.isEmpty) {
unawaited(_tracking.legacyDeviceSetupManualCodeEntered());
}
@@ -158,8 +154,6 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
void onWatchQrScanned(String qr) {
unawaited(_tracking.legacyDeviceSetupQrScanned());
// The QR scan jumps directly from scan_watch to profile, so complete
// the scan_watch step with its timer too.
_completeCurrentStep('scan_watch');
state = state.copyWith(watchQr: qr, step: LegacyAddKidStep.profile);
}
@@ -201,20 +195,11 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
if (!ref.mounted) return false;
final now = DateTime.now();
final ageYears =
now.year -
birth.year -
((now.month < birth.month ||
(now.month == birth.month && now.day < birth.day))
? 1
: 0);
unawaited(
_tracking.legacyDeviceSetupCompleted(
childGender: genrer,
relationType: relationType,
childAgeYears: ageYears,
childAgeYears: yearsBetween(birth, DateTime.now()),
),
);

View File

@@ -16,9 +16,6 @@ final locationMapViewModelProvider =
class LocationMapViewModel extends Notifier<LocationMapViewState> {
late final SfTrackingRepository _tracking;
/// Debounce timer for map zoom tracking. We do NOT want to fire an event
/// for every zoom delta during a pinch gesture; we wait until the user
/// stops zooming for a brief moment and then log the final zoom level.
Timer? _zoomDebounce;
static const Duration _zoomDebounceDelay = Duration(seconds: 1);
@@ -29,8 +26,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
return const LocationMapViewState();
}
// ─── Layer toggles ─────────────────────────────────────────────────────
void toggleGeofences() {
final newVisible = !state.showGeofences;
unawaited(_tracking.legacyLocationMapGeofencesToggled(newVisible));
@@ -43,29 +38,18 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(showFrequentPlaces: newVisible);
}
// ─── Place creation funnel ────────────────────────────────────────────
void startPlacing(PlacingMode mode) {
unawaited(_tracking.legacyLocationPlaceCreationStarted(_modeName(mode)));
state = state.copyWith(placingMode: mode);
}
void cancelPlacing() {
// Infer mode + step from current state BEFORE we reset it so we can
// send meaningful funnel drop-off data to analytics.
final wasPicking = state.placingMode != PlacingMode.none;
final wasAdjustingRadius = state.adjustingRadius;
if (wasPicking || wasAdjustingRadius) {
final mode = wasPicking
? _modeName(state.placingMode)
// Only geofences reach the radius-adjust step.
: 'geofence';
final atStep = wasPicking ? 'picking_point' : 'adjusting_radius';
final cancellation = _inferCancellationContext();
if (cancellation != null) {
unawaited(
_tracking.legacyLocationPlaceCreationCancelled(
mode: mode,
atStep: atStep,
mode: cancellation.mode,
atStep: cancellation.atStep,
),
);
}
@@ -77,6 +61,16 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
);
}
_CancellationContext? _inferCancellationContext() {
final isPicking = state.placingMode != PlacingMode.none;
final isAdjustingRadius = state.adjustingRadius;
if (!isPicking && !isAdjustingRadius) return null;
final mode = isPicking ? _modeName(state.placingMode) : 'geofence';
final atStep = isPicking ? 'picking_point' : 'adjusting_radius';
return _CancellationContext(mode: mode, atStep: atStep);
}
void confirmGeofencePlacement(LatLng center) {
unawaited(_tracking.legacyLocationPointConfirmed('geofence'));
state = state.copyWith(
@@ -93,8 +87,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void updatePreviewRadius(double radius) {
// Intentionally NOT tracked: this fires on every slider tick. The final
// radius is captured in [confirmRadius] once the user settles.
state = state.copyWith(previewRadius: radius);
}
@@ -113,8 +105,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(previewPoint: null);
}
// ─── Exploration / selection ──────────────────────────────────────────
void selectGeofence(GeofenceEntity geofence) {
unawaited(_tracking.legacyLocationGeofenceSelected());
state = state.copyWith(
@@ -168,8 +158,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(selectedHistoryPosition: null);
}
// ─── Live tracking ────────────────────────────────────────────────────
void toggleFollowing() {
final newFollowing = !state.isFollowing;
unawaited(_tracking.legacyLocationFollowingToggled(newFollowing));
@@ -177,16 +165,12 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
void stopFollowing() {
// Called automatically when the user pans the map manually. We track
// this as a "following disabled" event for consistency with toggle.
if (state.isFollowing) {
unawaited(_tracking.legacyLocationFollowingToggled(false));
}
state = state.copyWith(isFollowing: false);
}
// ─── UI chrome ────────────────────────────────────────────────────────
void toggleActionsExpanded() {
final newExpanded = !state.actionsExpanded;
unawaited(_tracking.legacyLocationMapActionsExpanded(newExpanded));
@@ -201,22 +185,14 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
state = state.copyWith(frequencyExpanded: false);
}
// ─── Map zoom (debounced) ─────────────────────────────────────────────
void updateMapZoom(double zoom) {
state = state.copyWith(mapZoom: zoom);
// Debounce: cancel any pending fire and schedule a new one. If the user
// keeps zooming, the timer keeps resetting and we only fire once the
// zoom settles for [_zoomDebounceDelay].
_zoomDebounce?.cancel();
_zoomDebounce = Timer(_zoomDebounceDelay, () {
unawaited(_tracking.legacyLocationMapZoomed(zoom));
});
}
// ─── Helpers ──────────────────────────────────────────────────────────
String _modeName(PlacingMode mode) {
switch (mode) {
case PlacingMode.geofence:
@@ -228,3 +204,9 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
}
}
}
class _CancellationContext {
const _CancellationContext({required this.mode, required this.atStep});
final String mode;
final String atStep;
}