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:
@@ -126,7 +126,7 @@ class ContactViewModel extends Notifier<ContactViewState> {
|
||||
unawaited(
|
||||
_tracking.legacySupportContactInitiated(
|
||||
channel: 'email',
|
||||
country: country,
|
||||
country: country.isEmpty ? 'unknown' : country,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -53,9 +53,6 @@ class VolumeControlViewModel extends Notifier<VolumeControlViewState> {
|
||||
final device = state.device;
|
||||
if (device == null) return;
|
||||
|
||||
// Capture which volume types actually changed compared to the device's
|
||||
// previous settings, so we only emit analytics for the ones the user
|
||||
// actually moved.
|
||||
final previous = device.settings.volume;
|
||||
final mediaChanged = previous.media != state.media;
|
||||
final ringtoneChanged = previous.ringtone != state.ringtone;
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
final legacyDeviceSetupViewModelProvider =
|
||||
NotifierProvider<LegacyDeviceSetupViewModel, LegacyDeviceSetupViewState>(
|
||||
@@ -28,10 +29,7 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
|
||||
late final TextEditingController watchCodeController;
|
||||
late final TextEditingController activationKeyController;
|
||||
|
||||
/// When the user entered the current step. Used to compute how long they
|
||||
/// spent on each step and fire `step_completed` with a `duration_seconds`
|
||||
/// param — super useful for marketing/UX to see where users get stuck.
|
||||
DateTime _currentStepEnteredAt = DateTime.now();
|
||||
DateTime? _currentStepEnteredAt;
|
||||
|
||||
@override
|
||||
LegacyDeviceSetupViewState build() {
|
||||
@@ -48,10 +46,10 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
|
||||
return initial;
|
||||
}
|
||||
|
||||
/// Emits a `step_completed` event for the step the user is LEAVING and
|
||||
/// resets the step timer so the next step starts fresh.
|
||||
void _completeCurrentStep(String stepName) {
|
||||
final duration = DateTime.now().difference(_currentStepEnteredAt).inSeconds;
|
||||
final enteredAt = _currentStepEnteredAt;
|
||||
if (enteredAt == null) return;
|
||||
final duration = DateTime.now().difference(enteredAt).inSeconds;
|
||||
unawaited(
|
||||
_tracking.legacyDeviceSetupStepCompleted(
|
||||
step: stepName,
|
||||
@@ -104,8 +102,6 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
|
||||
state = state.copyWith(errorMessage: I18n.errorScanWatchRequired);
|
||||
return;
|
||||
}
|
||||
// Distinguish QR vs manual code entry: if watchCode is populated and
|
||||
// watchQr is empty, the user typed it manually.
|
||||
if (state.watchCode.isNotEmpty && state.watchQr.isEmpty) {
|
||||
unawaited(_tracking.legacyDeviceSetupManualCodeEntered());
|
||||
}
|
||||
@@ -158,8 +154,6 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
|
||||
|
||||
void onWatchQrScanned(String qr) {
|
||||
unawaited(_tracking.legacyDeviceSetupQrScanned());
|
||||
// The QR scan jumps directly from scan_watch to profile, so complete
|
||||
// the scan_watch step with its timer too.
|
||||
_completeCurrentStep('scan_watch');
|
||||
state = state.copyWith(watchQr: qr, step: LegacyAddKidStep.profile);
|
||||
}
|
||||
@@ -201,20 +195,11 @@ class LegacyDeviceSetupViewModel extends Notifier<LegacyDeviceSetupViewState> {
|
||||
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
final ageYears =
|
||||
now.year -
|
||||
birth.year -
|
||||
((now.month < birth.month ||
|
||||
(now.month == birth.month && now.day < birth.day))
|
||||
? 1
|
||||
: 0);
|
||||
|
||||
unawaited(
|
||||
_tracking.legacyDeviceSetupCompleted(
|
||||
childGender: genrer,
|
||||
relationType: relationType,
|
||||
childAgeYears: ageYears,
|
||||
childAgeYears: yearsBetween(birth, DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -16,9 +16,6 @@ final locationMapViewModelProvider =
|
||||
class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
late final SfTrackingRepository _tracking;
|
||||
|
||||
/// Debounce timer for map zoom tracking. We do NOT want to fire an event
|
||||
/// for every zoom delta during a pinch gesture; we wait until the user
|
||||
/// stops zooming for a brief moment and then log the final zoom level.
|
||||
Timer? _zoomDebounce;
|
||||
static const Duration _zoomDebounceDelay = Duration(seconds: 1);
|
||||
|
||||
@@ -29,8 +26,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
return const LocationMapViewState();
|
||||
}
|
||||
|
||||
// ─── Layer toggles ─────────────────────────────────────────────────────
|
||||
|
||||
void toggleGeofences() {
|
||||
final newVisible = !state.showGeofences;
|
||||
unawaited(_tracking.legacyLocationMapGeofencesToggled(newVisible));
|
||||
@@ -43,29 +38,18 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
state = state.copyWith(showFrequentPlaces: newVisible);
|
||||
}
|
||||
|
||||
// ─── Place creation funnel ────────────────────────────────────────────
|
||||
|
||||
void startPlacing(PlacingMode mode) {
|
||||
unawaited(_tracking.legacyLocationPlaceCreationStarted(_modeName(mode)));
|
||||
state = state.copyWith(placingMode: mode);
|
||||
}
|
||||
|
||||
void cancelPlacing() {
|
||||
// Infer mode + step from current state BEFORE we reset it so we can
|
||||
// send meaningful funnel drop-off data to analytics.
|
||||
final wasPicking = state.placingMode != PlacingMode.none;
|
||||
final wasAdjustingRadius = state.adjustingRadius;
|
||||
|
||||
if (wasPicking || wasAdjustingRadius) {
|
||||
final mode = wasPicking
|
||||
? _modeName(state.placingMode)
|
||||
// Only geofences reach the radius-adjust step.
|
||||
: 'geofence';
|
||||
final atStep = wasPicking ? 'picking_point' : 'adjusting_radius';
|
||||
final cancellation = _inferCancellationContext();
|
||||
if (cancellation != null) {
|
||||
unawaited(
|
||||
_tracking.legacyLocationPlaceCreationCancelled(
|
||||
mode: mode,
|
||||
atStep: atStep,
|
||||
mode: cancellation.mode,
|
||||
atStep: cancellation.atStep,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -77,6 +61,16 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
);
|
||||
}
|
||||
|
||||
_CancellationContext? _inferCancellationContext() {
|
||||
final isPicking = state.placingMode != PlacingMode.none;
|
||||
final isAdjustingRadius = state.adjustingRadius;
|
||||
if (!isPicking && !isAdjustingRadius) return null;
|
||||
|
||||
final mode = isPicking ? _modeName(state.placingMode) : 'geofence';
|
||||
final atStep = isPicking ? 'picking_point' : 'adjusting_radius';
|
||||
return _CancellationContext(mode: mode, atStep: atStep);
|
||||
}
|
||||
|
||||
void confirmGeofencePlacement(LatLng center) {
|
||||
unawaited(_tracking.legacyLocationPointConfirmed('geofence'));
|
||||
state = state.copyWith(
|
||||
@@ -93,8 +87,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
}
|
||||
|
||||
void updatePreviewRadius(double radius) {
|
||||
// Intentionally NOT tracked: this fires on every slider tick. The final
|
||||
// radius is captured in [confirmRadius] once the user settles.
|
||||
state = state.copyWith(previewRadius: radius);
|
||||
}
|
||||
|
||||
@@ -113,8 +105,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
state = state.copyWith(previewPoint: null);
|
||||
}
|
||||
|
||||
// ─── Exploration / selection ──────────────────────────────────────────
|
||||
|
||||
void selectGeofence(GeofenceEntity geofence) {
|
||||
unawaited(_tracking.legacyLocationGeofenceSelected());
|
||||
state = state.copyWith(
|
||||
@@ -168,8 +158,6 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
state = state.copyWith(selectedHistoryPosition: null);
|
||||
}
|
||||
|
||||
// ─── Live tracking ────────────────────────────────────────────────────
|
||||
|
||||
void toggleFollowing() {
|
||||
final newFollowing = !state.isFollowing;
|
||||
unawaited(_tracking.legacyLocationFollowingToggled(newFollowing));
|
||||
@@ -177,16 +165,12 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
}
|
||||
|
||||
void stopFollowing() {
|
||||
// Called automatically when the user pans the map manually. We track
|
||||
// this as a "following disabled" event for consistency with toggle.
|
||||
if (state.isFollowing) {
|
||||
unawaited(_tracking.legacyLocationFollowingToggled(false));
|
||||
}
|
||||
state = state.copyWith(isFollowing: false);
|
||||
}
|
||||
|
||||
// ─── UI chrome ────────────────────────────────────────────────────────
|
||||
|
||||
void toggleActionsExpanded() {
|
||||
final newExpanded = !state.actionsExpanded;
|
||||
unawaited(_tracking.legacyLocationMapActionsExpanded(newExpanded));
|
||||
@@ -201,22 +185,14 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
state = state.copyWith(frequencyExpanded: false);
|
||||
}
|
||||
|
||||
// ─── Map zoom (debounced) ─────────────────────────────────────────────
|
||||
|
||||
void updateMapZoom(double zoom) {
|
||||
state = state.copyWith(mapZoom: zoom);
|
||||
|
||||
// Debounce: cancel any pending fire and schedule a new one. If the user
|
||||
// keeps zooming, the timer keeps resetting and we only fire once the
|
||||
// zoom settles for [_zoomDebounceDelay].
|
||||
_zoomDebounce?.cancel();
|
||||
_zoomDebounce = Timer(_zoomDebounceDelay, () {
|
||||
unawaited(_tracking.legacyLocationMapZoomed(zoom));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
String _modeName(PlacingMode mode) {
|
||||
switch (mode) {
|
||||
case PlacingMode.geofence:
|
||||
@@ -228,3 +204,9 @@ class LocationMapViewModel extends Notifier<LocationMapViewState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CancellationContext {
|
||||
const _CancellationContext({required this.mode, required this.atStep});
|
||||
final String mode;
|
||||
final String atStep;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user