From 8a97d3851a4f56abf72394fc4bc94b994b50211d Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Tue, 26 May 2026 22:36:55 +0900 Subject: [PATCH] feat: add analytics preference tracking --- CONTEXT.md | 85 ++++++++++ docs/Analytics-Event-Catalog.md | 52 ++++++ docs/Analytics-Preference-API.md | 105 ++++++++++++ docs/Google-Play-Data-Safety.md | 41 ++++- docs/Home.md | 2 + docs/Privacy-Policy-Draft.md | 40 ++++- docs/Release-Checklist.md | 5 + ...base-analytics-for-product-usage-events.md | 3 + ...0002-track-analytics-from-feature-blocs.md | 3 + ...orical-analytics-after-account-deletion.md | 3 + ...analytics-outside-production-by-default.md | 3 + ...matic-screen-tracking-for-first-release.md | 3 + ...ics-preference-across-signed-in-devices.md | 3 + ...mote-config-until-a-concrete-experiment.md | 3 + lib/core/constants/endpoint.dart | 2 + .../fallback_alarm_notification_service.dart | 2 +- .../services/product_analytics_service.dart | 96 +++++++++++ ...nalytics_preference_local_data_source.dart | 27 ++++ ...alytics_preference_remote_data_source.dart | 52 ++++++ .../analytics_preference_repository_impl.dart | 36 +++++ lib/domain/entities/analytics_preference.dart | 28 ++++ lib/domain/entities/product_usage_event.dart | 27 ++++ .../analytics_preference_repository.dart | 11 ++ .../load_analytics_preference_use_case.dart | 25 +++ .../track_product_usage_event_use_case.dart | 27 ++++ .../update_analytics_preference_use_case.dart | 26 +++ lib/l10n/app_en.arb | 4 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_ko.dart | 3 + .../app/cubit/analytics_preference_cubit.dart | 76 +++++++++ .../app/cubit/analytics_preference_state.dart | 64 ++++++++ lib/presentation/my_page/my_page_screen.dart | 72 ++++++++- .../bloc/schedule_form_bloc.dart | 23 +++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + plans/analytics-implementation-plan.md | 106 +++++++++++++ pubspec.lock | 52 ++++-- pubspec.yaml | 1 + .../product_analytics_service_test.dart | 49 ++++++ ...cs_preference_remote_data_source_test.dart | 72 +++++++++ ...ytics_preference_repository_impl_test.dart | 60 +++++++ .../analytics_preference_use_cases_test.dart | 68 ++++++++ .../analytics_preference_cubit_test.dart | 98 ++++++++++++ .../my_page/my_page_screen_test.dart | 150 ++++++++++++++++-- .../bloc/schedule_form_bloc_test.dart | 56 +++++++ .../schedule_multi_page_form_test.dart | 8 + 47 files changed, 1642 insertions(+), 42 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/Analytics-Event-Catalog.md create mode 100644 docs/Analytics-Preference-API.md create mode 100644 docs/adr/0001-use-firebase-analytics-for-product-usage-events.md create mode 100644 docs/adr/0002-track-analytics-from-feature-blocs.md create mode 100644 docs/adr/0003-retain-deidentified-historical-analytics-after-account-deletion.md create mode 100644 docs/adr/0004-disable-analytics-outside-production-by-default.md create mode 100644 docs/adr/0005-avoid-automatic-screen-tracking-for-first-release.md create mode 100644 docs/adr/0006-sync-analytics-preference-across-signed-in-devices.md create mode 100644 docs/adr/0007-defer-remote-config-until-a-concrete-experiment.md create mode 100644 lib/core/services/product_analytics_service.dart create mode 100644 lib/data/data_sources/analytics_preference_local_data_source.dart create mode 100644 lib/data/data_sources/analytics_preference_remote_data_source.dart create mode 100644 lib/data/repositories/analytics_preference_repository_impl.dart create mode 100644 lib/domain/entities/analytics_preference.dart create mode 100644 lib/domain/entities/product_usage_event.dart create mode 100644 lib/domain/repositories/analytics_preference_repository.dart create mode 100644 lib/domain/use-cases/load_analytics_preference_use_case.dart create mode 100644 lib/domain/use-cases/track_product_usage_event_use_case.dart create mode 100644 lib/domain/use-cases/update_analytics_preference_use_case.dart create mode 100644 lib/presentation/app/cubit/analytics_preference_cubit.dart create mode 100644 lib/presentation/app/cubit/analytics_preference_state.dart create mode 100644 plans/analytics-implementation-plan.md create mode 100644 test/core/services/product_analytics_service_test.dart create mode 100644 test/data/data_sources/analytics_preference_remote_data_source_test.dart create mode 100644 test/data/repositories/analytics_preference_repository_impl_test.dart create mode 100644 test/domain/use-cases/analytics_preference_use_cases_test.dart create mode 100644 test/presentation/app/cubit/analytics_preference_cubit_test.dart diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..63f53aae --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,85 @@ +# OnTime Front + +This context defines product language for the OnTime Flutter app so analytics, +release, and feature discussions use the same terms. + +## Language + +**Product Usage Event**: +A named record that a user performed a product-relevant action, excluding raw personal content. +_Avoid_: User activity, tracking event, raw interaction log + +**Analytics Purpose**: +The approved reason a Product Usage Event may be collected or used. +_Avoid_: Use case, tracking reason + +**Experiment**: +A pseudonymous feature or configuration comparison used for product improvement. +_Avoid_: Personalization, marketing campaign, user targeting + +**Deferred Analytics Purpose**: +An Analytics Purpose that is recognized but not active until a later privacy and consent review approves it. +_Avoid_: Future tracking, inactive use case + +**Pseudonymous Analytics Subject**: +The non-directly-identifying actor associated with a Product Usage Event. +_Avoid_: User identity, personal identity, email identity + +**Analytics Preference**: +The user's current choice about whether optional Product Usage Events may be collected. +_Avoid_: Tracking consent, privacy switch + +**Help Improve OnTime**: +The user-facing name for the Analytics Preference. +_Avoid_: Tracking, marketing analytics, personalization + +**Analytics Provider**: +An external service approved to receive Product Usage Events. +_Avoid_: Tracking vendor, analytics SDK + +**Workflow Milestone Event**: +A Product Usage Event that marks completion or failure of a meaningful user workflow step. +_Avoid_: Tap event, raw navigation log, interaction trace + +**Analytics Event Parameter**: +An allowlisted non-content value attached to a Product Usage Event. +_Avoid_: Event payload, arbitrary metadata, raw detail + +## Relationships + +- A **Product Usage Event** may describe a schedule, preparation, notification, alarm, onboarding, or account action without storing the user's raw schedule names, notes, place names, credentials, tokens, or free text. +- First-release **Product Usage Events** are **Workflow Milestone Events**, not every tap or raw navigation step. +- First-release **Workflow Milestone Events** cover analytics preference, onboarding, authentication, schedule, notification permission, alarm, and schedule-finish outcomes. +- A **Product Usage Event** may include **Analytics Event Parameters** such as workflow, result, stable error category, coarse count, coarse duration, platform, or app version. +- An **Analytics Event Parameter** must not contain user-authored text, direct identifiers, tokens, raw exception strings, request bodies, or response bodies. +- A **Product Usage Event** uses a stable snake_case name and includes a schema version. +- A changed **Product Usage Event** meaning requires a new event name or schema version. +- Active first-release **Analytics Purposes** are product improvement, debugging and operations, and experimentation. +- A first-release **Experiment** must not be used for marketing targeting, sensitive segmentation, or personalized treatment. +- Marketing and personalization are **Deferred Analytics Purposes**. +- A **Product Usage Event** belongs to a **Pseudonymous Analytics Subject**, using an internal user or analytics identifier for signed-in use and an installation identifier before sign-in. +- A **Pseudonymous Analytics Subject** must not be an email address, display name, OAuth identifier, FCM token, or raw personal content value. +- The first-release **Analytics Preference** is opt-out for active Analytics Purposes. +- A disabled **Analytics Preference** stops future optional Product Usage Events. +- Before sign-in, the **Analytics Preference** is installation-scoped. +- After sign-in, the **Analytics Preference** is account-scoped and should apply across the user's signed-in devices. +- Before sign-in, a **Product Usage Event** may be associated only with an installation-scoped **Pseudonymous Analytics Subject**. +- After sign-in, future **Product Usage Events** may be associated with a signed-in **Pseudonymous Analytics Subject**. +- After sign-out, future **Product Usage Events** return to an installation-scoped **Pseudonymous Analytics Subject**. +- Account deletion stops future user-linked **Product Usage Events** and may retain historical analytics only in aggregate or de-identified form. +- A first-release **Analytics Provider** may receive Product Usage Events only after privacy, Data Safety, retention, and deletion responsibilities are approved. + +## Example dialogue + +> **Dev:** "Should the analytics event include the schedule note so we can understand why users are late?" +> **Domain expert:** "No. A **Product Usage Event** can say a schedule was finished late, but it must not include the user's raw note." + +## Flagged ambiguities + +- "User activities" was used broadly; resolved: the canonical term is **Product Usage Event**, and raw personal content is out of scope. +- "Analytics" was used to include all possible purposes; resolved: marketing and personalization are deferred, not first-release purposes. +- "User identity" for analytics was ambiguous; resolved: analytics uses a **Pseudonymous Analytics Subject**, not directly identifying user data. +- "Consent" was ambiguous for analytics; resolved: first-release analytics is opt-out with a user-visible **Analytics Preference**. +- "Third party" was ambiguous for analytics; resolved: the canonical term is **Analytics Provider**. +- "Event taxonomy" was broad; resolved: first-release analytics tracks **Workflow Milestone Events** only. +- "Event payload" was too open-ended; resolved: events use allowlisted **Analytics Event Parameters** only. diff --git a/docs/Analytics-Event-Catalog.md b/docs/Analytics-Event-Catalog.md new file mode 100644 index 00000000..7f0acfb3 --- /dev/null +++ b/docs/Analytics-Event-Catalog.md @@ -0,0 +1,52 @@ +# Analytics Event Catalog + +This catalog defines the first-release Product Usage Events for OnTime. Events are Workflow Milestone Events only; they must not capture raw user-authored content, direct identifiers, tokens, request bodies, response bodies, raw exception strings, schedule names, notes, place names, or preparation step names. + +## Common Parameters + +All events include: + +| Parameter | Meaning | +| --- | --- | +| `schema_version` | Event schema version. First release uses `1`. | +| `workflow` | Product workflow that produced the event. | +| `result` | Stable result category such as `success`, `failure`, `allowed`, `denied`, or `disabled`. | +| `platform` | Coarse app platform. | +| `app_version` | App version string. | + +Optional parameters must be allowlisted per event and limited to stable categories, coarse counts, or coarse durations. + +## First-Release Events + +| Event | Owner Question | Trigger Point | Allowed Parameters | +| --- | --- | --- | --- | +| `analytics_preference_changed` | How many users keep optional analytics enabled? | Analytics Preference update succeeds. | `enabled`, `source` | +| `onboarding_completed` | How many users complete first setup? | Onboarding use case succeeds. | `preparation_step_count`, `spare_time_minutes` | +| `sign_up_completed` | How many users create accounts successfully? | Sign-up succeeds. | `auth_provider` | +| `login_completed` | How many users return successfully? | Sign-in succeeds. | `auth_provider` | +| `schedule_create_started` | Where does schedule creation begin? | Schedule create form initializes. | `source` | +| `schedule_created` | How often do users create schedules? | Schedule creation succeeds. | `preparation_mode`, `preparation_step_count`, `minutes_until_schedule` | +| `schedule_updated` | How often do users revise schedules? | Schedule update succeeds. | `preparation_changed`, `minutes_until_schedule` | +| `schedule_deleted` | How often are schedules removed? | Schedule deletion succeeds at a BLoC or use-case workflow boundary. | `minutes_until_schedule` | +| `notification_permission_result` | How often do users grant notification access? | Notification permission flow resolves. | `permission_result`, `source` | +| `alarm_opened` | How often do alarms bring users into the preparation flow? | Alarm launch payload opens a schedule route. | `launch_action`, `provider` | +| `alarm_failed` | Which alarm failures need attention? | Alarm status/reporting detects a stable failure category. | `error_code`, `provider` | +| `schedule_finished` | How often do users finish preparation on time? | Schedule finish succeeds. | `lateness_bucket`, `preparation_step_count`, `started_early` | + +## Forbidden Fields + +Never include: + +- Email, display name, OAuth identifier, FCM token, access token, or refresh token. +- Schedule name, schedule note, place name, or preparation step name. +- User-authored free text. +- Raw exception message, stack trace, request body, response body, or arbitrary map. +- Exact location data or any permission-sensitive data not already approved for analytics. + +## Change Control + +- Event names use stable snake_case. +- Breaking meaning changes require a new event name or `schema_version` increment. +- New events require an owner question and explicit allowed parameters before implementation. +- First-release failure tracking is limited to `alarm_failed`; other failure events require stable error categories before implementation. +- Marketing and personalization events are out of scope until deferred purposes are approved. diff --git a/docs/Analytics-Preference-API.md b/docs/Analytics-Preference-API.md new file mode 100644 index 00000000..6217c922 --- /dev/null +++ b/docs/Analytics-Preference-API.md @@ -0,0 +1,105 @@ +# Analytics Preference API + +This document defines the frontend contract needed before implementing OnTime analytics preference sync. It covers only the account-scoped preference for signed-in users; pre-login installation-scoped preference remains local to the app. + +## Scope + +- The preference controls optional Product Usage Events for active Analytics Purposes. +- The first release uses opt-out analytics and exposes the setting as Help Improve OnTime. +- Disabled preference stops future optional Product Usage Events. +- Marketing and personalization remain deferred and are not enabled by this API. + +## Endpoints + +### Get Analytics Preference + +```http +GET /users/me/analytics-preference +Authorization: Bearer +``` + +Successful response: + +```json +{ + "data": { + "enabled": true, + "updatedAt": "2026-05-26T12:00:00Z" + } +} +``` + +### Update Analytics Preference + +```http +PUT /users/me/analytics-preference +Authorization: Bearer +Content-Type: application/json + +{ + "enabled": false +} +``` + +Successful response: + +```json +{ + "data": { + "enabled": false, + "updatedAt": "2026-05-26T12:00:05Z" + } +} +``` + +## Field Semantics + +| Field | Type | Required | Meaning | +| --- | --- | --- | --- | +| `enabled` | boolean | Yes | Whether optional Product Usage Events may be collected for the signed-in account. | +| `updatedAt` | ISO-8601 UTC string | Yes | Server time when the account-scoped preference was last changed. | + +## Default Value + +- The backend default for existing and newly created signed-in accounts is config-gated. +- The initial config default is `enabled: false` until privacy policy, hosted policy page, Google Play Data Safety, and release approval are complete. +- After approval, the backend may flip the config default to `enabled: true` without changing the API contract. +- An explicit user-saved `enabled` value always wins over the config default. +- The frontend must still treat unknown or load-failed preference state as disabled for optional Product Usage Events. + +## Frontend Behavior + +1. Before sign-in, the app stores the Analytics Preference locally for the installation. +2. After sign-in, the app loads `GET /users/me/analytics-preference`. +3. After the user changes Help Improve OnTime, the app calls `PUT /users/me/analytics-preference`. +4. If `enabled` is `false`, the app disables Firebase Analytics collection and does not emit future optional Product Usage Events. +5. On sign-out, the app clears the Firebase Analytics user association and returns to the local installation-scoped preference. +6. On account deletion, the app stops future user-linked Product Usage Events and clears the Firebase Analytics user association. + +## Failure Behavior + +- If loading the signed-in account preference fails, provider collection remains disabled until the preference is loaded successfully. +- If updating the signed-in account preference fails, the app keeps the previous confirmed value and does not emit `analytics_preference_changed`. +- If local installation preference and signed-in account preference conflict, the app uses the stricter value until the user explicitly changes the account preference. +- Unknown preference state is treated as disabled for optional Product Usage Events. + +## Backend Handoff Scope + +The backend task should be limited to account-scoped Analytics Preference sync: + +- Backend issue: DevKor-github/OnTime-back#318. +- Add `GET /users/me/analytics-preference`. +- Add `PUT /users/me/analytics-preference`. +- Persist `enabled` and `updatedAt` for the signed-in account. +- Define the default account value for existing and newly created users. +- Confirm account deletion behavior for historical analytics as aggregate or de-identified retention. +- Confirm privacy policy and Google Play Data Safety updates before release. + +The backend task does not need to define Firebase event names, Flutter BLoC instrumentation, local pre-login preference storage, or UI copy. + +## Privacy And Release Requirements + +- Do not include email, name, OAuth identifiers, FCM token, schedule names, schedule notes, place names, preparation step names, request bodies, response bodies, raw exception strings, or free text in analytics events or preference API payloads. +- Update the privacy policy and Google Play Data Safety worksheet before releasing Firebase Analytics. +- Historical analytics after account deletion may be retained only in aggregate or de-identified form. +- Production analytics is enabled by default only for production builds; debug, local development, tests, and Widgetbook collection require an explicit override. diff --git a/docs/Google-Play-Data-Safety.md b/docs/Google-Play-Data-Safety.md index 31c59f11..a02ca60a 100644 --- a/docs/Google-Play-Data-Safety.md +++ b/docs/Google-Play-Data-Safety.md @@ -50,6 +50,8 @@ Repo evidence reviewed for this worksheet: - `lib/domain/entities/alarm_entities.dart` - `docs/Android-Manifest-Permissions.md` - `docs/Release-Checklist.md` +- `docs/Analytics-Preference-API.md` +- `docs/Analytics-Event-Catalog.md` ## Current App Data Flow Inventory @@ -72,12 +74,18 @@ deletion support must be approved by the release owner. | Local alarm registry | Scheduled alarm records are stored locally with schedule id, alarm time, preparation start time, fingerprint, notification ids, provider, schedule title, and payload. | On-device storage; disclose only if transmitted or shared elsewhere | Source-backed | | Android permissions | Release manifest includes notification, exact alarm, full-screen intent, boot restore, vibration, and dependency-owned network/Firebase/sign-in permissions. It does not include location, contacts, camera, microphone, phone, SMS, storage, calendar, nearby-device, or Bluetooth permissions. | Permission/API evidence for form consistency | Source-backed by #442 | | Firebase Cloud Messaging SDK | The app uses `firebase_core` and `firebase_messaging`. Firebase documentation says Cloud Messaging collects app version automatically and depends on Firebase Installations; FID and Firebase user agent handling must be considered. | SDK-collected data, device or other identifiers, app info and performance | Source-backed dependency, final SDK review pending | +| Firebase Analytics Product Usage Events | Planned analytics release uses Firebase Analytics for workflow milestone events only, with Help Improve OnTime opt-out, pseudonymous analytics identifiers, schema-versioned event names, app version, platform, workflow result, stable error categories, and coarse counts or durations. The event catalog forbids raw schedule names, notes, place names, preparation step names, email, OAuth identifiers, FCM token, raw exceptions, request bodies, response bodies, and free text. | App activity, app info and performance, device or other identifiers; purposes include Analytics, app functionality, debugging and operations, and non-personalized experiments | Planned; not present in current `pubspec.yaml` until `firebase_analytics` is added | | Google Play services core SDKs | Google Play services base/basement/tasks may be present through dependencies. Google's disclosure page says the listed core SDKs do not collect end-user data, but app owners remain responsible for the overall disclosure. | SDK review | Dependency review pending | ## Saved Play Console Answers Entered in Play Console by `jjoonleo@gmail.com` on 2026-05-10. +Important: these saved answers predate the planned Firebase Analytics release. +Do not submit a build that includes Firebase Analytics until the privacy policy, +Firebase console settings, SDK data handling, and Play Data safety answers below +are reviewed and updated. + Security and deletion: - Required user data types collected or shared: Yes. @@ -111,6 +119,22 @@ Play Console preview showed: - Remaining blocker shown by Play Console before final app-content submission: target audience/content. +## Firebase Analytics Release Delta + +Before releasing a build with Firebase Analytics, the release owner must revise +the saved Play Console answers to cover Help Improve OnTime and Product Usage +Events: + +| Area | Required review | +| --- | --- | +| SDK/provider set | Add `firebase_analytics` to the reviewed SDK set and confirm Firebase console settings, optional exports, linked Google services, and whether service-provider sharing remains accurate. | +| App activity | Confirm `App interactions` covers workflow milestone Product Usage Events and mark the analytics collection as optional if the user can disable Help Improve OnTime and still use the app. | +| App info and performance | Confirm diagnostics or crash/error categories used for `alarm_failed` and debugging/operations are declared with the correct purpose. | +| Device or other IDs | Confirm Firebase installation or analytics identifiers and pseudonymous analytics subject handling are declared correctly. | +| Purposes | Add or confirm Analytics as a purpose for Product Usage Events, with app functionality/debugging where appropriate. Marketing and personalization remain out of scope. | +| Data deletion | Confirm account deletion stops future user-linked events and that historical analytics is retained only in aggregate or de-identified form. | +| Required vs optional | Confirm whether each analytics-related data type is optional because Help Improve OnTime can be disabled. | + ## Answers That Still Need Owner Confirmation The Play Console draft is saved, but the owners below should still confirm these @@ -125,23 +149,26 @@ facts before final release submission. | Final privacy policy text approval | Product/legal owner and #434. Hosted URL is `https://ontime-back.duckdns.org/privacy-policy`. | | External account deletion request URL and page content | Closed in #440: `https://ontime-back.duckdns.org/account-deletion`. | | Final active auth providers for Android release | Release owner. Current source supports normal, Google, and Apple paths; Kakao dependencies are present but no active release flow was found in the checked auth path. | -| Firebase optional exports such as FCM delivery metrics to BigQuery or Analytics-linked notification interaction events | Firebase project owner. No Analytics dependency was found in `pubspec.yaml`, but console settings must still be checked. | +| Firebase Analytics release readiness | Firebase project owner, privacy owner, and release owner. Confirm `firebase_analytics` SDK behavior, console settings, optional exports, linked Google services, Help Improve OnTime opt-out behavior, and Play Data safety changes before release. | +| Firebase optional exports such as FCM delivery metrics to BigQuery or Analytics-linked notification interaction events | Firebase project owner. Confirm whether optional exports or Analytics-linked notification interaction events are enabled. | | Play Console app-content submission | Play Console owner after target audience/content is complete. | ## Pre-Submission Checklist 1. Confirm the release build's exact dependency set and SDK configuration. 2. Re-run the source audit after final release branch changes. -3. Confirm backend deletion and retention behavior for normal, Google, and Apple +3. If Firebase Analytics is included, update the privacy policy and Play Data + safety answers using the Firebase Analytics Release Delta above. +4. Confirm backend deletion and retention behavior for normal, Google, and Apple account paths. -4. Approve the privacy policy and verify every declared data type appears in it. -5. Verify the public privacy policy URL works without login and is the same URL +5. Approve the privacy policy and verify every declared data type appears in it. +6. Verify the public privacy policy URL works without login and is the same URL used in app and Play Console. -6. Verify the public account deletion URL works without installing or opening +7. Verify the public account deletion URL works without installing or opening the app and explains deleted and retained data. -7. Confirm the saved Data safety answers above still match the approved policy +8. Confirm the saved Data safety answers above still match the approved policy and final release build. -8. Send the saved changes for review from Publishing overview after the +9. Send the saved changes for review from Publishing overview after the remaining app-content blockers are resolved. ## Suggested Final Documentation Template diff --git a/docs/Home.md b/docs/Home.md index cf88d498..52c7e994 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -18,6 +18,8 @@ Welcome to the OnTime-front project documentation! This wiki contains everything - [Privacy Policy Hosting](./Privacy-Policy-Hosting.md) - Public HTTPS privacy policy hosting checklist and evidence form - [Google Play Data Safety Worksheet](./Google-Play-Data-Safety.md) - Source-backed Data safety evidence and pending owner inputs - [Backend Account Deletion Retention Report](./Backend-Account-Deletion-Retention-Report.md) - Backend retention targets and confirmation checklist for account deletion +- [Analytics Preference API](./Analytics-Preference-API.md) - Frontend contract for account-scoped analytics opt-out sync +- [Analytics Event Catalog](./Analytics-Event-Catalog.md) - First-release workflow milestone events and parameter allowlist - [Play Pre-Launch Report](./Play-Pre-Launch-Report.md) - Google Play report runbook, triage gate, and evidence template - [Release Rollout Monitoring](./Release-Rollout-Monitoring.md) - Release ownership, staged rollout gates, and post-launch monitoring - [Play Review Rejection Playbook](./Play-Review-Rejection-Playbook.md) - How to triage, fix, resubmit, or appeal Google Play review rejections diff --git a/docs/Privacy-Policy-Draft.md b/docs/Privacy-Policy-Draft.md index 1c9d610c..aba1d613 100644 --- a/docs/Privacy-Policy-Draft.md +++ b/docs/Privacy-Policy-Draft.md @@ -13,6 +13,10 @@ confirmation. - TODO: Backend/environment owner must confirm the service can enforce the retention periods listed in this draft. +- TODO: Product/legal owner must approve Firebase Analytics wording before any + release that enables Help Improve OnTime. +- TODO: Backend-hosted privacy policy update is tracked in + DevKor-github/OnTime-back#319 for the Firebase Analytics release handoff. - TODO: Product/legal owner must approve the final text before #434 can close. ## Draft Policy Text @@ -43,6 +47,7 @@ preparation reminders, alarms, and support features: | Feedback data | Optional account deletion feedback or other feedback message | Process user feedback and account deletion requests | | Local app data | Cached user, schedule, place, preparation, alarm, and token data stored on the device | Keep app state available locally and support app operation | | Technical data | Network request metadata, server logs, error metadata, and security-related operational records | Operate, secure, debug, and maintain the service | +| Product usage analytics data | Privacy-safe event names, app version, platform, workflow result, stable error category, coarse counts or durations, Analytics Preference, and pseudonymous analytics identifiers | Improve OnTime, debug and operate the service, and run non-personalized experiments when Help Improve OnTime is enabled | OnTime does not request app-owned access to location, contacts, camera, microphone, phone, SMS, storage, calendar, nearby-device, or Bluetooth @@ -64,23 +69,30 @@ OnTime uses collected data to: delivery. - Process optional feedback and account deletion feedback. - Maintain security, prevent abuse, debug failures, and operate the service. +- Collect privacy-safe Product Usage Events when Help Improve OnTime is enabled + to improve OnTime, debug and operate the service, and run non-personalized + experiments. ### Third-Party Services And Processors OnTime uses third-party services and SDKs where needed for core app behavior: -| Service or SDK | Purpose | Data involved | -| ------------------------------------------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| Google Sign-In | Google account authentication | Google account authentication data, including ID token and profile scopes for email/profile | -| Apple Sign-In | Apple account authentication | Apple identity token, authorization code, and Apple-provided name or email when available | -| Firebase Core and Firebase Cloud Messaging | App initialization and push notification delivery | Firebase installation or messaging identifiers, FCM token, notification delivery data, and device-related messaging metadata | -| OnTime backend/API infrastructure | Account, schedule, preparation, alarm, notification, feedback, and deletion request processing | The account, schedule, preparation, alarm, notification, feedback, and technical data listed above | +| Service or SDK | Purpose | Data involved | +| ------------------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Google Sign-In | Google account authentication | Google account authentication data, including ID token and profile scopes for email/profile | +| Apple Sign-In | Apple account authentication | Apple identity token, authorization code, and Apple-provided name or email when available | +| Firebase Core and Firebase Cloud Messaging | App initialization and push notification delivery | Firebase installation or messaging identifiers, FCM token, notification delivery data, and device-related messaging metadata | +| Firebase Analytics | Privacy-safe Product Usage Events for product improvement, debugging and operations, and non-personalized experiments when Help Improve OnTime is enabled | Product usage analytics data, app/device metadata handled by Firebase, and pseudonymous analytics identifiers | +| OnTime backend/API infrastructure | Account, schedule, preparation, alarm, notification, feedback, analytics preference, and deletion request processing | The account, schedule, preparation, alarm, notification, feedback, analytics preference, and technical data listed above | TODO: Confirm whether Kakao SDK is present only as an unused dependency for this release. If Kakao sign-in or Kakao SDK data processing is active in the release build, add Kakao to this table and update the Data safety form accordingly. -TODO: Confirm the backend hosting, database, logging, monitoring, analytics, and +TODO: Confirm Firebase Analytics console settings, including whether any exports +or Google integrations are enabled, before release. + +TODO: Confirm the backend hosting, database, logging, monitoring, and crash-reporting providers used outside this frontend repository, then add each approved provider to this table if it processes personal or sensitive user data. @@ -88,7 +100,9 @@ approved provider to this table if it processes personal or sensitive user data. OnTime shares data with service providers only as needed to provide app functionality, authentication, notifications, hosting, security, operations, and -support. OnTime does not use in-app advertising in the current release build. +support. When Help Improve OnTime is enabled, OnTime uses Firebase Analytics as +an analytics service provider for privacy-safe Product Usage Events. OnTime does +not use in-app advertising in the current release build. TODO: Product/legal owner must confirm whether any backend, analytics, monitoring, support, or legal/compliance sharing occurs outside the app code @@ -126,6 +140,11 @@ hash of the normalized email address, feedback message, and creation timestamp. OnTime retains optional account deletion feedback for up to 1 year to review service quality and deletion-related support issues. +When an account is deleted, OnTime stops future user-linked Product Usage Events +and clears the app's Firebase Analytics user association. Historical analytics +may be retained only in aggregate or de-identified form for product improvement, +debugging and operations, and non-personalized experiments. + Operational logs, monitoring records, and security records may be retained for up to 90 days for service operation, debugging, security, and abuse-prevention purposes, unless a longer period is required for legal compliance or an active @@ -175,8 +194,11 @@ when the policy changes. backend code-review and automated-test evidence. - [x] Retained account deletion feedback duration and reason are set in this draft. +- [x] Firebase Analytics wording is drafted for Help Improve OnTime, product + improvement, debugging and operations, and non-personalized experiments. - [x] Log, monitoring, security record, and backup retention periods are set in this draft. +- [ ] Firebase Analytics console settings and optional exports are confirmed. - [ ] Backend/environment owner confirms production retention settings, backup rotation, and cleanup jobs can enforce the draft periods. - [x] #440 external account deletion request URL exists or the policy links to @@ -188,6 +210,8 @@ when the policy changes. - [ ] Product/legal owner approves the final text. - [x] Policy text is handed to #435 for public HTTPS hosting. - [x] Hosted policy URL is entered in Play Console in #437. +- [ ] Firebase Analytics privacy policy update is handed to + DevKor-github/OnTime-back#319 before releasing Help Improve OnTime. ## References diff --git a/docs/Release-Checklist.md b/docs/Release-Checklist.md index c3b84b4d..f66264cc 100644 --- a/docs/Release-Checklist.md +++ b/docs/Release-Checklist.md @@ -210,6 +210,11 @@ If any trigger applies, block release approval until the release owner records which privacy policy section, Play Data safety answer, Play declaration, or store listing field was reviewed or changed. +For any release that enables Firebase Analytics or Help Improve OnTime, also +confirm the Firebase console settings, optional exports, analytics provider +handling, account deletion retention behavior, and opt-out behavior documented +in `docs/Google-Play-Data-Safety.md` and `docs/Privacy-Policy-Draft.md`. + ## Content Category And UGC - Current release audit result (2026-05-09): no UGC is exposed to other users. diff --git a/docs/adr/0001-use-firebase-analytics-for-product-usage-events.md b/docs/adr/0001-use-firebase-analytics-for-product-usage-events.md new file mode 100644 index 00000000..91377ada --- /dev/null +++ b/docs/adr/0001-use-firebase-analytics-for-product-usage-events.md @@ -0,0 +1,3 @@ +# Use Firebase Analytics for Product Usage Events + +The first third-party Analytics Provider for Product Usage Events will be Firebase Analytics because OnTime already depends on Firebase for core app and messaging behavior, making the provider review smaller than introducing a separate analytics vendor. This choice still requires privacy policy, Google Play Data Safety, retention, deletion, and opt-out behavior to be reviewed before release because Product Usage Events will be sent to an external analytics provider. diff --git a/docs/adr/0002-track-analytics-from-feature-blocs.md b/docs/adr/0002-track-analytics-from-feature-blocs.md new file mode 100644 index 00000000..20a726a5 --- /dev/null +++ b/docs/adr/0002-track-analytics-from-feature-blocs.md @@ -0,0 +1,3 @@ +# Track Analytics from Feature BLoCs + +Workflow Milestone Events will be emitted from the feature BLoCs or Cubits that own the completed workflow, using an injected tracking use case. This keeps analytics tied to domain outcomes instead of raw UI interactions, avoids a global BlocObserver that could accidentally observe sensitive form state, and keeps event emission testable without depending on widget navigation. diff --git a/docs/adr/0003-retain-deidentified-historical-analytics-after-account-deletion.md b/docs/adr/0003-retain-deidentified-historical-analytics-after-account-deletion.md new file mode 100644 index 00000000..a79f7780 --- /dev/null +++ b/docs/adr/0003-retain-deidentified-historical-analytics-after-account-deletion.md @@ -0,0 +1,3 @@ +# Retain De-Identified Historical Analytics After Account Deletion + +When an account is deleted, OnTime will stop future user-linked Product Usage Events and clear the analytics user association, but historical Firebase Analytics data may be retained only in aggregate or de-identified form. This avoids promising database-style cascade deletion for provider-managed analytics exports while preserving product improvement, debugging and operations, and experimentation value. diff --git a/docs/adr/0004-disable-analytics-outside-production-by-default.md b/docs/adr/0004-disable-analytics-outside-production-by-default.md new file mode 100644 index 00000000..7f2b94c8 --- /dev/null +++ b/docs/adr/0004-disable-analytics-outside-production-by-default.md @@ -0,0 +1,3 @@ +# Disable Analytics Outside Production By Default + +Product Usage Events will be sent to the Analytics Provider only for production builds by default, with any development or staging collection requiring an explicit override. This prevents local development, tests, Widgetbook, and manual QA from polluting production funnels, debugging signals, and experiment data. diff --git a/docs/adr/0005-avoid-automatic-screen-tracking-for-first-release.md b/docs/adr/0005-avoid-automatic-screen-tracking-for-first-release.md new file mode 100644 index 00000000..f44ab4b6 --- /dev/null +++ b/docs/adr/0005-avoid-automatic-screen-tracking-for-first-release.md @@ -0,0 +1,3 @@ +# Avoid Automatic Screen Tracking for First Release + +The first analytics release will not enable automatic screen-view tracking. OnTime will track explicit Workflow Milestone Events from feature BLoCs and Cubits instead, keeping the event stream focused and reducing the chance that route parameters or alarm navigation context create noisy or sensitive analytics. diff --git a/docs/adr/0006-sync-analytics-preference-across-signed-in-devices.md b/docs/adr/0006-sync-analytics-preference-across-signed-in-devices.md new file mode 100644 index 00000000..6141b0e9 --- /dev/null +++ b/docs/adr/0006-sync-analytics-preference-across-signed-in-devices.md @@ -0,0 +1,3 @@ +# Sync Analytics Preference Across Signed-In Devices + +The Analytics Preference is installation-scoped before sign-in and account-scoped after sign-in, so a signed-in user's opt-out should apply across their devices. This requires a backend-supported account preference rather than relying only on local app storage, because optional analytics must stop consistently once the user disables Help Improve OnTime. diff --git a/docs/adr/0007-defer-remote-config-until-a-concrete-experiment.md b/docs/adr/0007-defer-remote-config-until-a-concrete-experiment.md new file mode 100644 index 00000000..38a79b9b --- /dev/null +++ b/docs/adr/0007-defer-remote-config-until-a-concrete-experiment.md @@ -0,0 +1,3 @@ +# Defer Remote Config Until a Concrete Experiment + +The first analytics release will add Firebase Analytics, Analytics Preference controls, and Workflow Milestone Events, but it will not add Firebase Remote Config. Remote Config should be introduced only when there is a concrete Experiment with defined variants, success metrics, rollout rules, and rollback behavior. diff --git a/lib/core/constants/endpoint.dart b/lib/core/constants/endpoint.dart index 04ec5cad..94cee016 100644 --- a/lib/core/constants/endpoint.dart +++ b/lib/core/constants/endpoint.dart @@ -11,6 +11,7 @@ class Endpoint { static const _deleteAppleMe = '/oauth2/apple/me'; static const _feedback = '/feedback'; static const _deleteUser = '/users/me/delete'; + static const _analyticsPreference = '/users/me/analytics-preference'; static String get signIn => _signIn; static String get signUp => _signUp; @@ -21,6 +22,7 @@ class Endpoint { static String get deleteAppleMe => _deleteAppleMe; static String get feedback => _feedback; static String get deleteUser => _deleteUser; + static String get analyticsPreference => _analyticsPreference; // schedule static const _schedules = '/schedules'; diff --git a/lib/core/services/fallback_alarm_notification_service.dart b/lib/core/services/fallback_alarm_notification_service.dart index 8e7a3d4f..1a691a9a 100644 --- a/lib/core/services/fallback_alarm_notification_service.dart +++ b/lib/core/services/fallback_alarm_notification_service.dart @@ -17,7 +17,7 @@ abstract interface class FallbackAlarmNotificationService { class FallbackAlarmNotificationServiceImpl implements FallbackAlarmNotificationService { FallbackAlarmNotificationServiceImpl({ - NotificationService? notificationService, + @ignoreParam NotificationService? notificationService, }) : _notificationService = notificationService ?? NotificationService.instance; diff --git a/lib/core/services/product_analytics_service.dart b/lib/core/services/product_analytics_service.dart new file mode 100644 index 00000000..857a5028 --- /dev/null +++ b/lib/core/services/product_analytics_service.dart @@ -0,0 +1,96 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/services/device_info_service/shared.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:on_time_front/domain/entities/product_usage_event.dart'; + +abstract interface class AnalyticsProviderClient { + Future setAnalyticsCollectionEnabled(bool enabled); + + Future logEvent({ + required String name, + required Map parameters, + }); + + Future setUserId(String? userId); +} + +@Singleton(as: AnalyticsProviderClient) +class FirebaseAnalyticsProviderClient implements AnalyticsProviderClient { + FirebaseAnalyticsProviderClient({@ignoreParam FirebaseAnalytics? analytics}) + : _analytics = analytics ?? FirebaseAnalytics.instance; + + final FirebaseAnalytics _analytics; + + @override + Future setAnalyticsCollectionEnabled(bool enabled) { + return _analytics.setAnalyticsCollectionEnabled(enabled); + } + + @override + Future logEvent({ + required String name, + required Map parameters, + }) { + return _analytics.logEvent(name: name, parameters: parameters); + } + + @override + Future setUserId(String? userId) { + return _analytics.setUserId(id: userId); + } +} + +@Singleton() +class ProductAnalyticsService { + ProductAnalyticsService({ + required AnalyticsProviderClient client, + @ignoreParam + bool collectionAllowedInBuild = const bool.fromEnvironment( + 'ONTIME_ANALYTICS_ENABLED', + ), + }) : _client = client, + _collectionAllowedInBuild = collectionAllowedInBuild; + + final AnalyticsProviderClient _client; + final bool _collectionAllowedInBuild; + bool _collectionEnabled = false; + + Future applyPreference(AnalyticsPreference preference) async { + _collectionEnabled = + _collectionAllowedInBuild && + preference.isConfirmed && + preference.enabled; + await _client.setAnalyticsCollectionEnabled(_collectionEnabled); + } + + Future track(ProductUsageEvent event) async { + if (!_collectionEnabled) return; + await _client.logEvent( + name: event.name, + parameters: event.toAnalyticsParameters( + platform: _platformWireValue(), + appVersion: '1.0.0', + ), + ); + } + + Future setUserAssociation(String? userId) { + return _client.setUserId(userId); + } + + String _platformWireValue() { + try { + switch (DeviceInfoService.platformType) { + case PlatformType.android: + return 'android'; + case PlatformType.ios: + return 'ios'; + case PlatformType.web: + return 'web'; + } + } catch (_) { + return 'unknown'; + } + } +} diff --git a/lib/data/data_sources/analytics_preference_local_data_source.dart b/lib/data/data_sources/analytics_preference_local_data_source.dart new file mode 100644 index 00000000..df4686af --- /dev/null +++ b/lib/data/data_sources/analytics_preference_local_data_source.dart @@ -0,0 +1,27 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract interface class AnalyticsPreferenceLocalDataSource { + Future loadPreference(); + + Future savePreference(bool enabled); +} + +@Injectable(as: AnalyticsPreferenceLocalDataSource) +class AnalyticsPreferenceLocalDataSourceImpl + implements AnalyticsPreferenceLocalDataSource { + static const _enabledKey = 'analytics_preference_enabled'; + + @override + Future loadPreference() async { + final prefs = await SharedPreferences.getInstance(); + return AnalyticsPreference(enabled: prefs.getBool(_enabledKey) ?? false); + } + + @override + Future savePreference(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_enabledKey, enabled); + } +} diff --git a/lib/data/data_sources/analytics_preference_remote_data_source.dart b/lib/data/data_sources/analytics_preference_remote_data_source.dart new file mode 100644 index 00000000..e5d1b261 --- /dev/null +++ b/lib/data/data_sources/analytics_preference_remote_data_source.dart @@ -0,0 +1,52 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/constants/endpoint.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; + +abstract interface class AnalyticsPreferenceRemoteDataSource { + Future getAnalyticsPreference(); + + Future updateAnalyticsPreference({ + required bool enabled, + }); +} + +@Injectable(as: AnalyticsPreferenceRemoteDataSource) +class AnalyticsPreferenceRemoteDataSourceImpl + implements AnalyticsPreferenceRemoteDataSource { + AnalyticsPreferenceRemoteDataSourceImpl(this.dio); + + final Dio dio; + + @override + Future getAnalyticsPreference() async { + final result = await dio.get(Endpoint.analyticsPreference); + if (result.statusCode == 200) { + return _preferenceFromResponse(result.data); + } + throw Exception('Error getting analytics preference'); + } + + @override + Future updateAnalyticsPreference({ + required bool enabled, + }) async { + final result = await dio.put( + Endpoint.analyticsPreference, + data: {'enabled': enabled}, + ); + if (result.statusCode == 200) { + return _preferenceFromResponse(result.data); + } + throw Exception('Error updating analytics preference'); + } + + AnalyticsPreference _preferenceFromResponse(Object? data) { + final envelope = data as Map; + final payload = envelope['data'] as Map; + return AnalyticsPreference( + enabled: payload['enabled'] as bool, + updatedAt: DateTime.parse(payload['updatedAt'] as String), + ); + } +} diff --git a/lib/data/repositories/analytics_preference_repository_impl.dart b/lib/data/repositories/analytics_preference_repository_impl.dart new file mode 100644 index 00000000..fd6b586d --- /dev/null +++ b/lib/data/repositories/analytics_preference_repository_impl.dart @@ -0,0 +1,36 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/data/data_sources/analytics_preference_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/analytics_preference_remote_data_source.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; + +@Singleton(as: AnalyticsPreferenceRepository) +class AnalyticsPreferenceRepositoryImpl implements AnalyticsPreferenceRepository { + AnalyticsPreferenceRepositoryImpl({ + required this.localDataSource, + required this.remoteDataSource, + }); + + final AnalyticsPreferenceLocalDataSource localDataSource; + final AnalyticsPreferenceRemoteDataSource remoteDataSource; + + @override + Future loadLocalPreference() { + return localDataSource.loadPreference(); + } + + @override + Future saveLocalPreference(bool enabled) { + return localDataSource.savePreference(enabled); + } + + @override + Future loadAccountPreference() { + return remoteDataSource.getAnalyticsPreference(); + } + + @override + Future updateAccountPreference(bool enabled) { + return remoteDataSource.updateAnalyticsPreference(enabled: enabled); + } +} diff --git a/lib/domain/entities/analytics_preference.dart b/lib/domain/entities/analytics_preference.dart new file mode 100644 index 00000000..c8abf1f1 --- /dev/null +++ b/lib/domain/entities/analytics_preference.dart @@ -0,0 +1,28 @@ +class AnalyticsPreference { + const AnalyticsPreference({ + required this.enabled, + this.updatedAt, + this.isConfirmed = true, + }); + + const AnalyticsPreference.disabledUnconfirmed() + : enabled = false, + updatedAt = null, + isConfirmed = false; + + final bool enabled; + final DateTime? updatedAt; + final bool isConfirmed; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is AnalyticsPreference && + other.enabled == enabled && + other.updatedAt == updatedAt && + other.isConfirmed == isConfirmed; + } + + @override + int get hashCode => Object.hash(enabled, updatedAt, isConfirmed); +} diff --git a/lib/domain/entities/product_usage_event.dart b/lib/domain/entities/product_usage_event.dart new file mode 100644 index 00000000..6a243e99 --- /dev/null +++ b/lib/domain/entities/product_usage_event.dart @@ -0,0 +1,27 @@ +class ProductUsageEvent { + const ProductUsageEvent({ + required this.name, + required this.workflow, + required this.result, + this.parameters = const {}, + }); + + final String name; + final String workflow; + final String result; + final Map parameters; + + Map toAnalyticsParameters({ + required String platform, + required String appVersion, + }) { + return { + 'schema_version': 1, + 'workflow': workflow, + 'result': result, + 'platform': platform, + 'app_version': appVersion, + ...parameters, + }; + } +} diff --git a/lib/domain/repositories/analytics_preference_repository.dart b/lib/domain/repositories/analytics_preference_repository.dart new file mode 100644 index 00000000..a5807cc4 --- /dev/null +++ b/lib/domain/repositories/analytics_preference_repository.dart @@ -0,0 +1,11 @@ +import 'package:on_time_front/domain/entities/analytics_preference.dart'; + +abstract interface class AnalyticsPreferenceRepository { + Future loadLocalPreference(); + + Future saveLocalPreference(bool enabled); + + Future loadAccountPreference(); + + Future updateAccountPreference(bool enabled); +} diff --git a/lib/domain/use-cases/load_analytics_preference_use_case.dart b/lib/domain/use-cases/load_analytics_preference_use_case.dart new file mode 100644 index 00000000..e97dea96 --- /dev/null +++ b/lib/domain/use-cases/load_analytics_preference_use_case.dart @@ -0,0 +1,25 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; + +@Injectable() +class LoadAnalyticsPreferenceUseCase { + LoadAnalyticsPreferenceUseCase(this._repository); + + final AnalyticsPreferenceRepository _repository; + + Future call({required bool signedIn}) async { + final localPreference = await _repository.loadLocalPreference(); + if (!signedIn) return localPreference; + + try { + final accountPreference = await _repository.loadAccountPreference(); + return AnalyticsPreference( + enabled: localPreference.enabled && accountPreference.enabled, + updatedAt: accountPreference.updatedAt, + ); + } catch (_) { + return const AnalyticsPreference.disabledUnconfirmed(); + } + } +} diff --git a/lib/domain/use-cases/track_product_usage_event_use_case.dart b/lib/domain/use-cases/track_product_usage_event_use_case.dart new file mode 100644 index 00000000..4a1b74d0 --- /dev/null +++ b/lib/domain/use-cases/track_product_usage_event_use_case.dart @@ -0,0 +1,27 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/logging/app_logger.dart'; +import 'package:on_time_front/core/services/product_analytics_service.dart'; +import 'package:on_time_front/domain/entities/product_usage_event.dart'; + +abstract interface class ProductUsageEventTracker { + Future track(ProductUsageEvent event); +} + +@Injectable(as: ProductUsageEventTracker) +class TrackProductUsageEventUseCase implements ProductUsageEventTracker { + TrackProductUsageEventUseCase(this._analyticsService); + + final ProductAnalyticsService _analyticsService; + + @override + Future track(ProductUsageEvent event) async { + try { + await _analyticsService.track(event); + } catch (error) { + AppLogger.debug( + '[Analytics] track failed event=${event.name} ' + 'errorType=${error.runtimeType}', + ); + } + } +} diff --git a/lib/domain/use-cases/update_analytics_preference_use_case.dart b/lib/domain/use-cases/update_analytics_preference_use_case.dart new file mode 100644 index 00000000..a42e8c1c --- /dev/null +++ b/lib/domain/use-cases/update_analytics_preference_use_case.dart @@ -0,0 +1,26 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; + +@Injectable() +class UpdateAnalyticsPreferenceUseCase { + UpdateAnalyticsPreferenceUseCase(this._repository); + + final AnalyticsPreferenceRepository _repository; + + Future call({ + required bool enabled, + required bool signedIn, + }) async { + if (!signedIn) { + await _repository.saveLocalPreference(enabled); + return AnalyticsPreference(enabled: enabled); + } + + final accountPreference = await _repository.updateAccountPreference( + enabled, + ); + await _repository.saveLocalPreference(accountPreference.enabled); + return accountPreference; + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 90b1f7e8..7b5c7b6a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -356,6 +356,10 @@ "@allowAppNotifications": { "description": "Setting tile for allowing app notifications" }, + "helpImproveOnTime": "Help improve OnTime", + "@helpImproveOnTime": { + "description": "Setting switch label for optional privacy-safe analytics" + }, "privacyPolicy": "Privacy Policy", "@privacyPolicy": { "description": "Setting tile for opening the privacy policy" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 3be04c0e..708ac095 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -90,6 +90,7 @@ "accountSettings": "계정 설정", "editDefaultPreparation": "기본 준비과정 / 여유시간 수정", "allowAppNotifications": "앱 알림 허용", + "helpImproveOnTime": "OnTime 개선에 참여", "privacyPolicy": "개인정보 처리방침", "privacyPolicyOpenError": "개인정보 처리방침을 열 수 없습니다. 잠시 후 다시 시도해주세요.", "logOut": "로그아웃", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a9510a64..290b9d58 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -596,6 +596,12 @@ abstract class AppLocalizations { /// **'Allow App Notifications'** String get allowAppNotifications; + /// Setting switch label for optional privacy-safe analytics + /// + /// In en, this message translates to: + /// **'Help improve OnTime'** + String get helpImproveOnTime; + /// Setting tile for opening the privacy policy /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1d894098..1b2d7426 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -292,6 +292,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allowAppNotifications => 'Allow App Notifications'; + @override + String get helpImproveOnTime => 'Help improve OnTime'; + @override String get privacyPolicy => 'Privacy Policy'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index c5058f89..e11709f7 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -272,6 +272,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get allowAppNotifications => '앱 알림 허용'; + @override + String get helpImproveOnTime => 'OnTime 개선에 참여'; + @override String get privacyPolicy => '개인정보 처리방침'; diff --git a/lib/presentation/app/cubit/analytics_preference_cubit.dart b/lib/presentation/app/cubit/analytics_preference_cubit.dart new file mode 100644 index 00000000..f332b91d --- /dev/null +++ b/lib/presentation/app/cubit/analytics_preference_cubit.dart @@ -0,0 +1,76 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/services/product_analytics_service.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:on_time_front/domain/use-cases/load_analytics_preference_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_analytics_preference_use_case.dart'; + +part 'analytics_preference_state.dart'; + +@Injectable() +class AnalyticsPreferenceCubit extends Cubit { + AnalyticsPreferenceCubit({ + required LoadAnalyticsPreferenceUseCase loadPreferenceUseCase, + required UpdateAnalyticsPreferenceUseCase updatePreferenceUseCase, + required ProductAnalyticsService analyticsService, + }) : _loadPreferenceUseCase = loadPreferenceUseCase, + _updatePreferenceUseCase = updatePreferenceUseCase, + _analyticsService = analyticsService, + super(const AnalyticsPreferenceState.initial()); + + final LoadAnalyticsPreferenceUseCase _loadPreferenceUseCase; + final UpdateAnalyticsPreferenceUseCase _updatePreferenceUseCase; + final ProductAnalyticsService _analyticsService; + + Future load({required bool signedIn}) async { + emit(state.copyWith(status: AnalyticsPreferenceStatus.loading)); + final preference = await _loadPreferenceUseCase(signedIn: signedIn); + if (!preference.isConfirmed) { + await _analyticsService.applyPreference(preference); + emit( + AnalyticsPreferenceState.failure( + enabled: preference.enabled, + isConfirmed: false, + ), + ); + return; + } + await _analyticsService.applyPreference(preference); + emit( + AnalyticsPreferenceState.loaded( + enabled: preference.enabled, + isConfirmed: true, + ), + ); + } + + Future update({ + required bool enabled, + required bool signedIn, + }) async { + final previous = state; + emit(state.copyWith(status: AnalyticsPreferenceStatus.updating)); + try { + final preference = await _updatePreferenceUseCase( + enabled: enabled, + signedIn: signedIn, + ); + await _analyticsService.applyPreference(preference); + emit( + AnalyticsPreferenceState.loaded( + enabled: preference.enabled, + isConfirmed: true, + ), + ); + } catch (_) { + await _analyticsService.applyPreference( + AnalyticsPreference( + enabled: previous.enabled, + isConfirmed: previous.isConfirmed, + ), + ); + emit(previous.copyWith(status: AnalyticsPreferenceStatus.failure)); + } + } +} diff --git a/lib/presentation/app/cubit/analytics_preference_state.dart b/lib/presentation/app/cubit/analytics_preference_state.dart new file mode 100644 index 00000000..b61553dc --- /dev/null +++ b/lib/presentation/app/cubit/analytics_preference_state.dart @@ -0,0 +1,64 @@ +part of 'analytics_preference_cubit.dart'; + +enum AnalyticsPreferenceStatus { + initial, + loading, + loaded, + updating, + failure, +} + +class AnalyticsPreferenceState extends Equatable { + const AnalyticsPreferenceState._({ + required this.status, + required this.enabled, + required this.isConfirmed, + }); + + const AnalyticsPreferenceState.initial() + : this._( + status: AnalyticsPreferenceStatus.initial, + enabled: false, + isConfirmed: false, + ); + + const AnalyticsPreferenceState.loaded({ + required bool enabled, + required bool isConfirmed, + }) : this._( + status: AnalyticsPreferenceStatus.loaded, + enabled: enabled, + isConfirmed: isConfirmed, + ); + + const AnalyticsPreferenceState.failure({ + required bool enabled, + required bool isConfirmed, + }) : this._( + status: AnalyticsPreferenceStatus.failure, + enabled: enabled, + isConfirmed: isConfirmed, + ); + + final AnalyticsPreferenceStatus status; + final bool enabled; + final bool isConfirmed; + + bool get canEmitEvents => + status == AnalyticsPreferenceStatus.loaded && isConfirmed && enabled; + + AnalyticsPreferenceState copyWith({ + AnalyticsPreferenceStatus? status, + bool? enabled, + bool? isConfirmed, + }) { + return AnalyticsPreferenceState._( + status: status ?? this.status, + enabled: enabled ?? this.enabled, + isConfirmed: isConfirmed ?? this.isConfirmed, + ); + } + + @override + List get props => [status, enabled, isConfirmed]; +} diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index daac0360..47fb50fc 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -16,6 +16,7 @@ import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/app/cubit/analytics_preference_cubit.dart'; import 'package:on_time_front/presentation/my_page/my_page_modal/delete_user_modal.dart'; import 'package:on_time_front/presentation/my_page/my_page_modal/logout_modal.dart'; import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; @@ -28,15 +29,20 @@ class MyPageScreen extends StatelessWidget { super.key, PrivacyPolicyLauncher? openPrivacyPolicy, NotificationService? notificationService, + AnalyticsPreferenceCubit? analyticsPreferenceCubit, }) : _openPrivacyPolicy = openPrivacyPolicy, - _notificationService = notificationService; + _notificationService = notificationService, + _analyticsPreferenceCubit = analyticsPreferenceCubit; final PrivacyPolicyLauncher? _openPrivacyPolicy; final NotificationService? _notificationService; + final AnalyticsPreferenceCubit? _analyticsPreferenceCubit; @override Widget build(BuildContext context) { - return Scaffold( + final signedIn = + context.read().state.status == AuthStatus.authenticated; + final content = Scaffold( backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow, appBar: AppBar( title: Text( @@ -106,6 +112,7 @@ class MyPageScreen extends StatelessWidget { ); }, ), + const _AnalyticsPreferenceTile(), _SettingTile( title: AppLocalizations.of(context)!.privacyPolicy, onTap: () async { @@ -122,6 +129,61 @@ class MyPageScreen extends StatelessWidget { ), ), ); + if (_analyticsPreferenceCubit != null) { + final analyticsPreferenceCubit = _analyticsPreferenceCubit; + return BlocProvider.value( + value: analyticsPreferenceCubit..load(signedIn: signedIn), + child: content, + ); + } + return BlocProvider( + create: (_) => + getIt.get()..load(signedIn: signedIn), + child: content, + ); + } +} + +class _AnalyticsPreferenceTile extends StatelessWidget { + const _AnalyticsPreferenceTile(); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final signedIn = + context.read().state.status == AuthStatus.authenticated; + return BlocBuilder( + builder: (context, state) { + final isUpdating = + state.status == AnalyticsPreferenceStatus.loading || + state.status == AnalyticsPreferenceStatus.updating; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)!.helpImproveOnTime, + style: textTheme.bodyLarge, + ), + ), + Switch( + key: const Key('analyticsPreferenceSwitch'), + value: state.enabled, + activeColor: colorScheme.primary, + onChanged: isUpdating + ? null + : (value) { + context.read().update( + enabled: value, + signedIn: signedIn, + ); + }, + ), + ], + ); + }, + ); } } @@ -310,7 +372,11 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { ), ], ), - Switch(value: _alarmsEnabled, onChanged: _isUpdating ? null : _toggle), + Switch( + key: const Key('alarmSettingsSwitch'), + value: _alarmsEnabled, + onChanged: _isUpdating ? null : _toggle, + ), ], ); } diff --git a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart index e51a3ef7..a0932ed8 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart @@ -11,6 +11,8 @@ import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case. import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart'; +import 'package:on_time_front/domain/entities/product_usage_event.dart'; +import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; import 'package:on_time_front/domain/use-cases/update_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; @@ -30,6 +32,7 @@ class ScheduleFormBloc extends Bloc { this._createCustomPreparationUseCase, this._updateScheduleUseCase, this._updatePreparationByScheduleIdUseCase, + this._productUsageEventTracker, @factoryParam this._authBloc, ) : super(ScheduleFormState()) { on(_onEditRequested); @@ -54,6 +57,7 @@ class ScheduleFormBloc extends Bloc { final UpdateScheduleUseCase _updateScheduleUseCase; final UpdatePreparationByScheduleIdUseCase _updatePreparationByScheduleIdUseCase; + final ProductUsageEventTracker _productUsageEventTracker; final AuthBloc _authBloc; Future _onEditRequested( @@ -264,6 +268,7 @@ class ScheduleFormBloc extends Bloc { scheduleEntity.id, ); } + await _trackScheduleCreated(scheduleEntity); emit( state.copyWith( submissionStatus: ScheduleFormSubmissionStatus.success, @@ -305,4 +310,22 @@ class ScheduleFormBloc extends Bloc { initialTime.minute, ); } + + Future _trackScheduleCreated(ScheduleEntity scheduleEntity) async { + await _productUsageEventTracker.track( + ProductUsageEvent( + name: 'schedule_created', + workflow: 'schedule', + result: 'success', + parameters: { + 'preparation_mode': scheduleEntity.preparationMode?.name ?? 'default', + 'preparation_step_count': + state.preparation?.preparationStepList.length ?? 0, + 'minutes_until_schedule': scheduleEntity.scheduleTime + .difference(DateTime.now()) + .inMinutes, + }, + ), + ); + } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5d92a99e..b3c853f5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import firebase_analytics import firebase_core import firebase_messaging import flutter_appauth @@ -19,6 +20,7 @@ import url_launcher_macos import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) diff --git a/plans/analytics-implementation-plan.md b/plans/analytics-implementation-plan.md new file mode 100644 index 00000000..f6fce5c9 --- /dev/null +++ b/plans/analytics-implementation-plan.md @@ -0,0 +1,106 @@ +# Analytics Implementation Plan + +## Goal + +Add privacy-safe Firebase Analytics collection for first-release Product Usage Events, with account-synced opt-out, fail-closed preference handling, and BLoC/Cubit workflow milestone tracking. + +## Context + +- Canonical analytics language is defined in `CONTEXT.md`. +- Account preference contract is defined in `docs/Analytics-Preference-API.md`. +- First-release event names and parameter allowlists are defined in `docs/Analytics-Event-Catalog.md`. +- ADRs in `docs/adr/` record the provider, BLoC tracking boundary, account deletion retention, production-only default, no automatic screen tracking, account preference sync, and Remote Config deferral. +- Existing user-scoped API endpoints use `/users/me/...`; the analytics preference API follows that pattern. +- Existing app architecture uses clean layers under `lib/core`, `lib/data`, `lib/domain`, and `lib/presentation`, with BLoCs/Cubits owning workflow outcomes. + +## Decisions + +- Use Firebase Analytics as the first third-party Analytics Provider. +- Track only Workflow Milestone Events, not every tap, automatic screen view, or raw navigation step. +- Emit analytics from feature BLoCs/Cubits via an injected tracking use case, not from a global `BlocObserver`. +- Use strict allowlisted Analytics Event Parameters and include `schema_version: 1`. +- Keep marketing and personalization deferred until a separate privacy and consent review. +- Disable provider collection outside production by default unless explicitly overridden. +- Fail closed when the signed-in Analytics Preference is unknown or cannot be loaded. +- Keep historical analytics after account deletion only in aggregate or de-identified form. +- Do not add Firebase Remote Config until a concrete Experiment exists. + +## Steps + +1. Backend/API preparation: + - Track backend work in DevKor-github/OnTime-back#318 using `docs/Analytics-Preference-API.md`. + - Confirm `GET /users/me/analytics-preference` and `PUT /users/me/analytics-preference`. + - Use a config-gated backend default that starts as `enabled: false`; flip it to `enabled: true` only after privacy policy, hosted policy page, Play Data Safety, and release approval are complete. + - Ensure explicit user-saved preference values always win over the config default. + - Confirm account deletion and historical analytics retention wording. + +2. Release/privacy documentation: + - Update `docs/Privacy-Policy-Draft.md` for Firebase Analytics and Help Improve OnTime. + - Update `docs/Google-Play-Data-Safety.md` for Firebase Analytics data collection, purposes, provider handling, opt-out, and retention. + - Hand off the backend-hosted privacy policy page update through DevKor-github/OnTime-back#319 before the Firebase Analytics release. + - Update release checklist evidence if provider or SDK review requirements change. + +3. Preference domain and data layer: + - Add an `AnalyticsPreference` domain entity or value type. + - Add repository/use cases for loading and updating the preference. + - Add remote data source/model support for `/users/me/analytics-preference`. + - Add local installation-scoped preference storage for pre-login state. + - Implement stricter-value behavior when local and account preference conflict. + +4. Firebase Analytics wrapper: + - Add `firebase_analytics` only after the release/provider docs are updated. + - Create a small analytics service wrapper under `lib/core` or `lib/data` that owns Firebase calls. + - Gate collection by production environment, explicit developer override, and confirmed Analytics Preference. + - Ensure sign-out and account deletion clear Firebase user association. + - Do not enable automatic screen tracking. + +5. Presentation preference UI: + - Add an Analytics Preference Cubit or BLoC for loading/updating preference. + - Add the Help Improve OnTime switch to My Page app settings near Privacy Policy. + - Use English label `Help improve OnTime` and Korean label `OnTime 개선에 참여`. + - Show loading/error behavior for preference fetch/update without silently assuming enabled. + +6. Event tracking use case: + - Add `TrackProductUsageEventUseCase` with validation against the event catalog. + - Reject or strip forbidden fields, arbitrary maps, raw exceptions, and user-authored content. + - Add common parameters including `schema_version`, `workflow`, `result`, `platform`, and `app_version`. + +7. BLoC/Cubit instrumentation: + - `OnboardingCubit`: emit `onboarding_completed` after onboarding succeeds. + - Auth/sign-in flows: emit `sign_up_completed` and `login_completed` after successful auth by provider. + - `ScheduleFormBloc`: emit `schedule_create_started`, `schedule_created`, and `schedule_updated` at confirmed workflow boundaries. + - Schedule deletion: emit `schedule_deleted` only after there is a BLoC or use-case success boundary. + - Notification permission flow: emit `notification_permission_result` after permission flow resolves. + - Alarm launch/status flow: emit `alarm_opened` and `alarm_failed` from stable alarm workflow boundaries. + - `ScheduleBloc`: emit `schedule_finished` after finish succeeds. + +8. Tests: + - Unit-test preference repository/use cases for local-only, account-loaded, conflict, update failure, and load failure behavior. + - Unit-test analytics wrapper gating for production, debug, disabled preference, unknown preference, sign-out, and account deletion. + - Unit-test `TrackProductUsageEventUseCase` to enforce allowed parameters and forbidden-field rejection. + - Add BLoC/Cubit tests for each implemented milestone emission. + - Add My Page widget tests for the Help Improve OnTime switch, loading state, update success, and update failure. + +9. Rollout: + - Keep provider collection disabled until backend API, privacy docs, Play Data Safety docs, and tests are complete. + - Run `flutter analyze`. + - Run focused analytics tests, then `flutter test`. + - Re-run Google Play release checklist items that mention analytics, diagnostics, SDK providers, privacy policy, and Data Safety. + +## Validation + +- `flutter pub get` +- `dart run build_runner build --delete-conflicting-outputs` if generated models or Injectable wiring change. +- `flutter analyze` +- Focused tests for analytics preference, analytics tracking, My Page toggle, and instrumented BLoCs/Cubits. +- `flutter test` +- Manual My Page smoke test for preference load, toggle on/off, failed update, sign-out, and account deletion behavior. +- Documentation review confirms `docs/Privacy-Policy-Draft.md`, `docs/Google-Play-Data-Safety.md`, and release checklist evidence match the shipped Firebase Analytics behavior. + +## Open Questions + +- Backend delivery branch is not assigned yet; backend issue is DevKor-github/OnTime-back#318. +- Backend-hosted privacy policy update is tracked by DevKor-github/OnTime-back#319 and should be handed over during the Firebase Analytics release. +- Final Firebase console settings owner must confirm whether any Analytics-linked exports or Google integrations are enabled. +- Production gating mechanism needs a concrete implementation choice, such as existing flavor/build mode checks or a Dart define. +- Stable error categories beyond `alarm_failed` are intentionally deferred. diff --git a/pubspec.lock b/pubspec.lock index d5a25275..a385e596 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: bda3b7b55958bfd867addc40d067b4b11f7b8846d57671f5b5a6e7f9a56fe3ad + sha256: "8f89e371e2883de35cdc78f648e725fa4da5f3b6c927269f00fa68f1ea92b598" url: "https://pub.dev" source: hosted - version: "1.3.69" + version: "1.3.71" accessibility_tools: dependency: transitive description: @@ -352,54 +352,78 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + sha256: "56641bc6dd7b6a8c63ad5f5d46664af5b66db452f1c5ad052b1eec0188cf3183" + url: "https://pub.dev" + source: hosted + version: "12.4.1" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: "432492ba57024a35dfb67ebcf0c64bac31e19d2c46b3dd222bccbc05f8839efe" + url: "https://pub.dev" + source: hosted + version: "6.0.1" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: e613fca6511ab8f13adb355f7bc486c49fa6aea72fb6703cfb34df29b3b568a6 + url: "https://pub.dev" + source: hosted + version: "0.6.1+7" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: d5a94b884dcb1e6d3430298e94bfe002238094cdfd5e29202d536ee2120f9158 + sha256: "93a5bde9775fd5adcc937f39dfa04ae0bc89c4d79bea6abc49de3f7b049d9ff6" url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "4.9.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "7.0.1" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: dc5096257cd67292d34d78ceeb90836f02a4be921b5f3934311a02bb2376118c + sha256: "7c98f10b8c8e5adedc0b810b66a877120696675e2c22d9ca9caca092da0d9e57" url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "3.7.0" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: e5c93e8e7a9b0513f94bb684d2cf100e32e7dcdf2949574386b1955fc9a9b96a + sha256: "8d0dc81a31cd030170508dc3e89bfd14355b20a1b991340af5f018e37daab5d7" url: "https://pub.dev" source: hosted - version: "16.2.0" + version: "16.2.2" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "8cbb7d842e5071bba836452aff262f7db4b14bb3a0d00c1896cf176df886d65a" + sha256: "37abb0b0535c5497605ee94c12470e1ebbbe47e71a22d0c20bffcc912311f8cb" url: "https://pub.dev" source: hosted - version: "4.7.9" + version: "4.7.11" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "8750bacf50573c0383535fc3f9c58c6a2f9dff5320a16a82c30631b9dad894f1" + sha256: "54e22b43e2c26a2728a3f68c188de0f9011993ae19ae959a06d476dad935c776" url: "https://pub.dev" source: hosted - version: "4.1.5" + version: "4.1.7" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f9347611..c3ad439d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,7 @@ dependencies: sign_in_with_apple: ^7.0.1 permission_handler: ^12.0.1 + firebase_analytics: ^12.4.1 diff --git a/test/core/services/product_analytics_service_test.dart b/test/core/services/product_analytics_service_test.dart new file mode 100644 index 00000000..83c63c33 --- /dev/null +++ b/test/core/services/product_analytics_service_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/product_analytics_service.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:on_time_front/domain/entities/product_usage_event.dart'; + +void main() { + test('unconfirmed analytics preference does not log product usage events', () async { + final client = _FakeAnalyticsProviderClient(); + final service = ProductAnalyticsService( + client: client, + collectionAllowedInBuild: true, + ); + + await service.applyPreference( + const AnalyticsPreference(enabled: true, isConfirmed: false), + ); + await service.track( + const ProductUsageEvent( + name: 'schedule_created', + workflow: 'schedule', + result: 'success', + ), + ); + + expect(client.collectionEnabledValues, [false]); + expect(client.loggedEvents, isEmpty); + }); +} + +class _FakeAnalyticsProviderClient implements AnalyticsProviderClient { + final collectionEnabledValues = []; + final loggedEvents = <({String name, Map parameters})>[]; + + @override + Future setAnalyticsCollectionEnabled(bool enabled) async { + collectionEnabledValues.add(enabled); + } + + @override + Future logEvent({ + required String name, + required Map parameters, + }) async { + loggedEvents.add((name: name, parameters: parameters)); + } + + @override + Future setUserId(String? userId) async {} +} diff --git a/test/data/data_sources/analytics_preference_remote_data_source_test.dart b/test/data/data_sources/analytics_preference_remote_data_source_test.dart new file mode 100644 index 00000000..d31b50b3 --- /dev/null +++ b/test/data/data_sources/analytics_preference_remote_data_source_test.dart @@ -0,0 +1,72 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:on_time_front/core/constants/endpoint.dart'; +import 'package:on_time_front/data/data_sources/analytics_preference_remote_data_source.dart'; + +import '../../helpers/mock.mocks.dart'; + +void main() { + late Dio dio; + late AnalyticsPreferenceRemoteDataSourceImpl dataSource; + + setUp(() { + dio = MockAppDio(); + dataSource = AnalyticsPreferenceRemoteDataSourceImpl(dio); + }); + + test('loads analytics preference from the account endpoint', () async { + when(dio.get(Endpoint.analyticsPreference)).thenAnswer( + (_) async => Response( + statusCode: 200, + data: { + 'data': { + 'enabled': true, + 'updatedAt': '2026-05-26T12:00:00Z', + }, + }, + requestOptions: RequestOptions(path: Endpoint.analyticsPreference), + ), + ); + + final preference = await dataSource.getAnalyticsPreference(); + + expect(preference.enabled, isTrue); + expect(preference.updatedAt, DateTime.parse('2026-05-26T12:00:00Z')); + }); + + test('updates analytics preference with the enabled flag only', () async { + when( + dio.put( + Endpoint.analyticsPreference, + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( + statusCode: 200, + data: { + 'data': { + 'enabled': false, + 'updatedAt': '2026-05-26T12:00:05Z', + }, + }, + requestOptions: RequestOptions(path: Endpoint.analyticsPreference), + ), + ); + + final preference = await dataSource.updateAnalyticsPreference( + enabled: false, + ); + + final data = + verify( + dio.put( + Endpoint.analyticsPreference, + data: captureAnyNamed('data'), + ), + ).captured.single + as Map; + expect(data, {'enabled': false}); + expect(preference.enabled, isFalse); + }); +} diff --git a/test/data/repositories/analytics_preference_repository_impl_test.dart b/test/data/repositories/analytics_preference_repository_impl_test.dart new file mode 100644 index 00000000..7581f811 --- /dev/null +++ b/test/data/repositories/analytics_preference_repository_impl_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/data_sources/analytics_preference_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/analytics_preference_remote_data_source.dart'; +import 'package:on_time_front/data/repositories/analytics_preference_repository_impl.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late _FakeAnalyticsPreferenceRemoteDataSource remoteDataSource; + late AnalyticsPreferenceRepositoryImpl repository; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + remoteDataSource = _FakeAnalyticsPreferenceRemoteDataSource(); + repository = AnalyticsPreferenceRepositoryImpl( + localDataSource: AnalyticsPreferenceLocalDataSourceImpl(), + remoteDataSource: remoteDataSource, + ); + }); + + test('local analytics preference defaults disabled until explicitly changed', () async { + expect((await repository.loadLocalPreference()).enabled, isFalse); + + await repository.saveLocalPreference(true); + + expect((await repository.loadLocalPreference()).enabled, isTrue); + }); + + test('account analytics preference calls delegate to the remote data source', () async { + remoteDataSource.preference = AnalyticsPreference( + enabled: true, + updatedAt: DateTime.utc(2026, 5, 26, 12), + ); + + expect(await repository.loadAccountPreference(), remoteDataSource.preference); + expect( + await repository.updateAccountPreference(false), + const AnalyticsPreference(enabled: false), + ); + expect(remoteDataSource.updatedValues, [false]); + }); +} + +class _FakeAnalyticsPreferenceRemoteDataSource + implements AnalyticsPreferenceRemoteDataSource { + AnalyticsPreference preference = const AnalyticsPreference(enabled: false); + final updatedValues = []; + + @override + Future getAnalyticsPreference() async => preference; + + @override + Future updateAnalyticsPreference({ + required bool enabled, + }) async { + updatedValues.add(enabled); + preference = AnalyticsPreference(enabled: enabled); + return preference; + } +} diff --git a/test/domain/use-cases/analytics_preference_use_cases_test.dart b/test/domain/use-cases/analytics_preference_use_cases_test.dart new file mode 100644 index 00000000..896d2e2f --- /dev/null +++ b/test/domain/use-cases/analytics_preference_use_cases_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; +import 'package:on_time_front/domain/use-cases/load_analytics_preference_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_analytics_preference_use_case.dart'; + +void main() { + test('signed-in analytics preference fails closed when account load fails', () async { + final repository = _FakeAnalyticsPreferenceRepository() + ..localPreference = const AnalyticsPreference(enabled: true) + ..loadAccountError = Exception('backend unavailable'); + final useCase = LoadAnalyticsPreferenceUseCase(repository); + + final preference = await useCase(signedIn: true); + + expect(preference.enabled, isFalse); + expect(preference.isConfirmed, isFalse); + }); + + test( + 'signed-in analytics preference update keeps local value when account update fails', + () async { + final repository = _FakeAnalyticsPreferenceRepository() + ..localPreference = const AnalyticsPreference(enabled: true) + ..updateAccountError = Exception('backend unavailable'); + final useCase = UpdateAnalyticsPreferenceUseCase(repository); + + await expectLater( + useCase(enabled: false, signedIn: true), + throwsException, + ); + + expect(repository.localPreference.enabled, isTrue); + }, + ); +} + +class _FakeAnalyticsPreferenceRepository + implements AnalyticsPreferenceRepository { + AnalyticsPreference localPreference = const AnalyticsPreference(enabled: false); + AnalyticsPreference accountPreference = + const AnalyticsPreference(enabled: false); + Object? loadAccountError; + Object? updateAccountError; + + @override + Future loadLocalPreference() async => localPreference; + + @override + Future saveLocalPreference(bool enabled) async { + localPreference = AnalyticsPreference(enabled: enabled); + } + + @override + Future loadAccountPreference() async { + final error = loadAccountError; + if (error != null) throw error; + return accountPreference; + } + + @override + Future updateAccountPreference(bool enabled) async { + final error = updateAccountError; + if (error != null) throw error; + accountPreference = AnalyticsPreference(enabled: enabled); + return accountPreference; + } +} diff --git a/test/presentation/app/cubit/analytics_preference_cubit_test.dart b/test/presentation/app/cubit/analytics_preference_cubit_test.dart new file mode 100644 index 00000000..6ff0ecd4 --- /dev/null +++ b/test/presentation/app/cubit/analytics_preference_cubit_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/product_analytics_service.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; +import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; +import 'package:on_time_front/domain/use-cases/load_analytics_preference_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_analytics_preference_use_case.dart'; +import 'package:on_time_front/presentation/app/cubit/analytics_preference_cubit.dart'; + +void main() { + test('load fails closed when signed-in account preference cannot be loaded', () async { + final repository = _FakeAnalyticsPreferenceRepository() + ..localPreference = const AnalyticsPreference(enabled: true) + ..loadAccountError = Exception('backend unavailable'); + final cubit = AnalyticsPreferenceCubit( + loadPreferenceUseCase: LoadAnalyticsPreferenceUseCase(repository), + updatePreferenceUseCase: UpdateAnalyticsPreferenceUseCase(repository), + analyticsService: ProductAnalyticsService( + client: _FakeAnalyticsProviderClient(), + collectionAllowedInBuild: true, + ), + ); + addTearDown(cubit.close); + + await cubit.load(signedIn: true); + + expect(cubit.state.status, AnalyticsPreferenceStatus.failure); + expect(cubit.state.enabled, isFalse); + expect(cubit.state.canEmitEvents, isFalse); + }); + + test('load applies confirmed enabled preference to analytics service', () async { + final client = _FakeAnalyticsProviderClient(); + final repository = _FakeAnalyticsPreferenceRepository() + ..localPreference = const AnalyticsPreference(enabled: true) + ..accountPreference = const AnalyticsPreference(enabled: true); + final cubit = AnalyticsPreferenceCubit( + loadPreferenceUseCase: LoadAnalyticsPreferenceUseCase(repository), + updatePreferenceUseCase: UpdateAnalyticsPreferenceUseCase(repository), + analyticsService: ProductAnalyticsService( + client: client, + collectionAllowedInBuild: true, + ), + ); + addTearDown(cubit.close); + + await cubit.load(signedIn: true); + + expect(cubit.state.canEmitEvents, isTrue); + expect(client.collectionEnabledValues, [true]); + }); +} + +class _FakeAnalyticsPreferenceRepository + implements AnalyticsPreferenceRepository { + AnalyticsPreference localPreference = const AnalyticsPreference(enabled: false); + AnalyticsPreference accountPreference = + const AnalyticsPreference(enabled: false); + Object? loadAccountError; + + @override + Future loadLocalPreference() async => localPreference; + + @override + Future saveLocalPreference(bool enabled) async { + localPreference = AnalyticsPreference(enabled: enabled); + } + + @override + Future loadAccountPreference() async { + final error = loadAccountError; + if (error != null) throw error; + return accountPreference; + } + + @override + Future updateAccountPreference(bool enabled) async { + accountPreference = AnalyticsPreference(enabled: enabled); + return accountPreference; + } +} + +class _FakeAnalyticsProviderClient implements AnalyticsProviderClient { + final collectionEnabledValues = []; + + @override + Future setAnalyticsCollectionEnabled(bool enabled) async { + collectionEnabledValues.add(enabled); + } + + @override + Future logEvent({ + required String name, + required Map parameters, + }) async {} + + @override + Future setUserId(String? userId) async {} +} diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index c8fe63e0..aeab6f63 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -8,15 +8,21 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/core/services/product_analytics_service.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/analytics_preference.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/analytics_preference_repository.dart'; import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; import 'package:on_time_front/domain/repositories/alarm_repository.dart'; +import 'package:on_time_front/domain/use-cases/load_analytics_preference_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_analytics_preference_use_case.dart'; import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/app/cubit/analytics_preference_cubit.dart'; import 'package:on_time_front/presentation/my_page/my_page_screen.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; @@ -71,6 +77,39 @@ void main() { expect(find.text('개인정보 처리방침'), findsOneWidget); }); + testWidgets('shows loaded Help improve OnTime preference switch', ( + tester, + ) async { + final analyticsRepository = _FakeAnalyticsPreferenceRepository() + ..localPreference = const AnalyticsPreference(enabled: true) + ..accountPreference = const AnalyticsPreference(enabled: true); + final analyticsCubit = AnalyticsPreferenceCubit( + loadPreferenceUseCase: LoadAnalyticsPreferenceUseCase( + analyticsRepository, + ), + updatePreferenceUseCase: UpdateAnalyticsPreferenceUseCase( + analyticsRepository, + ), + analyticsService: ProductAnalyticsService( + client: _FakeAnalyticsProviderClient(), + collectionAllowedInBuild: true, + ), + ); + + await _pumpMyPage( + tester, + locale: const Locale('en'), + authState: AuthState(user: _authenticatedUser), + analyticsPreferenceCubit: analyticsCubit, + ); + + expect(find.text('Help improve OnTime'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('analyticsPreferenceSwitch'))), + isA().having((switchWidget) => switchWidget.value, 'value', true), + ); + }); + testWidgets('opens hosted privacy policy URL from setting', (tester) async { final openedUris = []; @@ -253,14 +292,17 @@ void main() { await _pumpMyPage(tester, locale: const Locale('en')); - await tester.tap(find.byType(Switch)); + await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); await tester.pumpAndSettle(); await tester.tap(find.text("I'll do it later.")); await tester.pumpAndSettle(); expect(alarmRepository.updatedSettings, [false]); expect(cancelAllUseCase.callCount, 1); - expect(tester.widget(find.byType(Switch)).value, isFalse); + expect( + tester.widget(find.byKey(const Key('alarmSettingsSwitch'))).value, + isFalse, + ); }); testWidgets( @@ -287,7 +329,7 @@ void main() { await _pumpMyPage(tester, locale: const Locale('en')); - await tester.tap(find.byType(Switch)); + await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); await tester.pumpAndSettle(); await tester.tap(find.text('Open Settings')); await tester.pumpAndSettle(); @@ -296,7 +338,12 @@ void main() { expect(alarmRepository.updatedSettings, [true]); expect(fallbackService.requestCount, 1); expect(reconcileUseCase.callCount, 1); - expect(tester.widget(find.byType(Switch)).value, isTrue); + expect( + tester + .widget(find.byKey(const Key('alarmSettingsSwitch'))) + .value, + isTrue, + ); }, ); @@ -355,7 +402,10 @@ void main() { await _pumpMyPage(tester, locale: const Locale('ko')); expect(find.text('네이티브 알람'), findsOneWidget); - expect(tester.widget(find.byType(Switch)).value, isTrue); + expect( + tester.widget(find.byKey(const Key('alarmSettingsSwitch'))).value, + isTrue, + ); }); testWidgets( @@ -460,13 +510,16 @@ void main() { fallbackService.permission = AlarmPermissionState.granted; await _pumpMyPage(tester, locale: const Locale('ko')); - await tester.tap(find.byType(Switch)); + await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); await tester.pumpAndSettle(); expect(alarmRepository.updatedSettings, [true]); expect(fallbackService.requestCount, 1); expect(reconcileUseCase.callCount, 1); - expect(tester.widget(find.byType(Switch)).value, isTrue); + expect( + tester.widget(find.byKey(const Key('alarmSettingsSwitch'))).value, + isTrue, + ); }); testWidgets( @@ -479,12 +532,17 @@ void main() { alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); await _pumpMyPage(tester, locale: const Locale('ko')); - await tester.tap(find.byType(Switch)); + await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); await tester.pumpAndSettle(); expect(alarmRepository.updatedSettings, [false]); expect(cancelAllUseCase.callCount, 1); - expect(tester.widget(find.byType(Switch)).value, isFalse); + expect( + tester + .widget(find.byKey(const Key('alarmSettingsSwitch'))) + .value, + isFalse, + ); }, ); } @@ -494,10 +552,14 @@ Future _pumpMyPage( required Locale locale, PrivacyPolicyLauncher? openPrivacyPolicy, NotificationService? notificationService, + AnalyticsPreferenceCubit? analyticsPreferenceCubit, AuthState authState = const AuthState.loading(), _StubAuthBloc? authBloc, }) async { final bloc = authBloc ?? _StubAuthBloc(authState); + final analyticsCubit = + analyticsPreferenceCubit ?? _buildAnalyticsPreferenceCubit(); + addTearDown(analyticsCubit.close); await tester.pumpWidget( MaterialApp( theme: themeData, @@ -509,6 +571,7 @@ Future _pumpMyPage( child: MyPageScreen( openPrivacyPolicy: openPrivacyPolicy, notificationService: notificationService, + analyticsPreferenceCubit: analyticsCubit, ), ), ), @@ -516,6 +579,23 @@ Future _pumpMyPage( await tester.pumpAndSettle(); } +AnalyticsPreferenceCubit _buildAnalyticsPreferenceCubit({ + _FakeAnalyticsPreferenceRepository? repository, +}) { + final analyticsRepository = + repository ?? _FakeAnalyticsPreferenceRepository(); + return AnalyticsPreferenceCubit( + loadPreferenceUseCase: LoadAnalyticsPreferenceUseCase(analyticsRepository), + updatePreferenceUseCase: UpdateAnalyticsPreferenceUseCase( + analyticsRepository, + ), + analyticsService: ProductAnalyticsService( + client: _FakeAnalyticsProviderClient(), + collectionAllowedInBuild: true, + ), + ); +} + class _StubAuthBloc extends Mock implements AuthBloc { _StubAuthBloc(this._state); @@ -537,6 +617,58 @@ class _StubAuthBloc extends Mock implements AuthBloc { } } +const _authenticatedUser = UserEntity( + id: 'user-1', + email: 'user@example.com', + name: 'User', + spareTime: Duration(minutes: 10), + note: '', + score: 0, + isOnboardingCompleted: true, +); + +class _FakeAnalyticsPreferenceRepository + implements AnalyticsPreferenceRepository { + AnalyticsPreference localPreference = const AnalyticsPreference( + enabled: false, + ); + AnalyticsPreference accountPreference = const AnalyticsPreference( + enabled: false, + ); + + @override + Future loadLocalPreference() async => localPreference; + + @override + Future saveLocalPreference(bool enabled) async { + localPreference = AnalyticsPreference(enabled: enabled); + } + + @override + Future loadAccountPreference() async => + accountPreference; + + @override + Future updateAccountPreference(bool enabled) async { + accountPreference = AnalyticsPreference(enabled: enabled); + return accountPreference; + } +} + +class _FakeAnalyticsProviderClient implements AnalyticsProviderClient { + @override + Future setAnalyticsCollectionEnabled(bool enabled) async {} + + @override + Future logEvent({ + required String name, + required Map parameters, + }) async {} + + @override + Future setUserId(String? userId) async {} +} + class _FakeNotificationService implements NotificationService { _FakeNotificationService({ required this.currentStatus, diff --git a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart index d04d46f3..0771631a 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart @@ -3,8 +3,10 @@ import 'package:mockito/mockito.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/product_usage_event.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; @@ -111,6 +113,15 @@ class StubUpdatePreparationByScheduleIdUseCase } } +class StubProductUsageEventTracker implements ProductUsageEventTracker { + final events = []; + + @override + Future track(ProductUsageEvent event) async { + events.add(event); + } +} + void main() { late StubLoadPreparationByScheduleIdUseCase loadPreparationByScheduleIdUseCase; @@ -122,6 +133,7 @@ void main() { late StubUpdateScheduleUseCase updateScheduleUseCase; late StubUpdatePreparationByScheduleIdUseCase updatePreparationByScheduleIdUseCase; + late StubProductUsageEventTracker productUsageEventTracker; late StubAuthBloc authBloc; final preparation = PreparationEntity( @@ -156,6 +168,7 @@ void main() { createCustomPreparationUseCase, updateScheduleUseCase, updatePreparationByScheduleIdUseCase, + productUsageEventTracker, authBloc, ); } @@ -180,6 +193,7 @@ void main() { updateScheduleUseCase = StubUpdateScheduleUseCase((_) async {}); updatePreparationByScheduleIdUseCase = StubUpdatePreparationByScheduleIdUseCase((_, __) async {}); + productUsageEventTracker = StubProductUsageEventTracker(); authBloc = StubAuthBloc( AuthState( @@ -364,6 +378,48 @@ void main() { ); }); + test('ScheduleFormCreated tracks schedule_created after success', () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + + final createReady = bloc.stream.firstWhere( + (state) => state.status == ScheduleFormStatus.success, + ); + bloc.add(const ScheduleFormCreateRequested()); + await createReady; + + bloc + ..add(const ScheduleFormScheduleNameChanged(scheduleName: 'Meeting')) + ..add( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: DateTime(2027, 3, 20), + scheduleTime: DateTime(2027, 3, 20, 9), + ), + ) + ..add(const ScheduleFormPlaceNameChanged(placeName: 'Office')) + ..add(const ScheduleFormMoveTimeChanged(moveTime: Duration(minutes: 30))) + ..add( + const ScheduleFormScheduleSpareTimeChanged( + scheduleSpareTime: Duration(minutes: 10), + ), + ); + + final submitDone = bloc.stream.firstWhere( + (state) => state.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + bloc.add(const ScheduleFormCreated()); + await submitDone; + + expect(productUsageEventTracker.events, hasLength(1)); + expect(productUsageEventTracker.events.single.name, 'schedule_created'); + expect(productUsageEventTracker.events.single.workflow, 'schedule'); + expect(productUsageEventTracker.events.single.result, 'success'); + expect( + productUsageEventTracker.events.single.parameters, + containsPair('preparation_step_count', 1), + ); + }); + test('ScheduleFormCreateRequested seeds a provided future date', () async { final bloc = buildBloc(); addTearDown(bloc.close); diff --git a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart index 872a1976..f55e1fe9 100644 --- a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart +++ b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart @@ -10,6 +10,7 @@ import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/product_usage_event.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; @@ -21,6 +22,7 @@ import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_us import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; import 'package:on_time_front/domain/use-cases/update_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; @@ -47,6 +49,11 @@ class StubAuthBloc extends Mock implements AuthBloc { bool get isClosed => false; } +class NoopProductUsageEventTracker implements ProductUsageEventTracker { + @override + Future track(ProductUsageEvent event) async {} +} + class StubLoadPreparationByScheduleIdUseCase implements LoadPreparationByScheduleIdUseCase { StubLoadPreparationByScheduleIdUseCase(this.handler); @@ -227,6 +234,7 @@ void main() { createCustomPreparationUseCase, updateScheduleUseCase, updatePreparationByScheduleIdUseCase, + NoopProductUsageEventTracker(), authBloc, ); }