From 89fbac63f363802b7d25454f10d8eb10ba245cc3 Mon Sep 17 00:00:00 2001 From: Sameer Kumar Date: Mon, 17 Aug 2026 09:41:11 +0530 Subject: [PATCH] feat(activity-tracker): create a storage module to track activities in multiple application --- example/pubspec.lock | 382 +++++++++++++- lib/fa_flutter_core.dart | 1 + lib/src/activity/README.md | 328 ++++++++++++ lib/src/activity/fa_flutter_activity.dart | 10 + lib/src/activity/src/activity_repository.dart | 159 ++++++ .../src/activity_repository_impl.dart | 476 ++++++++++++++++++ lib/src/activity/src/dao/activity_dao.dart | 14 + .../activity/src/dao/activity_dao_impl.dart | 108 ++++ .../activity/src/db/activity_realm_db.dart | 9 + .../src/db/activity_realm_db_impl.dart | 63 +++ .../activity/src/models/activity_session.dart | 122 +++++ .../src/models/activity_session.freezed.dart | 322 ++++++++++++ .../src/models/activity_session.g.dart | 54 ++ .../src/models/activity_session_entity.dart | 68 +++ .../models/activity_session_entity.realm.dart | 139 +++++ pubspec.lock | 196 ++++++-- pubspec.yaml | 10 +- 17 files changed, 2417 insertions(+), 44 deletions(-) create mode 100644 lib/src/activity/README.md create mode 100644 lib/src/activity/fa_flutter_activity.dart create mode 100644 lib/src/activity/src/activity_repository.dart create mode 100644 lib/src/activity/src/activity_repository_impl.dart create mode 100644 lib/src/activity/src/dao/activity_dao.dart create mode 100644 lib/src/activity/src/dao/activity_dao_impl.dart create mode 100644 lib/src/activity/src/db/activity_realm_db.dart create mode 100644 lib/src/activity/src/db/activity_realm_db_impl.dart create mode 100644 lib/src/activity/src/models/activity_session.dart create mode 100644 lib/src/activity/src/models/activity_session.freezed.dart create mode 100644 lib/src/activity/src/models/activity_session.g.dart create mode 100644 lib/src/activity/src/models/activity_session_entity.dart create mode 100644 lib/src/activity/src/models/activity_session_entity.realm.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index 8dba549..009a32a 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" _flutterfire_internals: dependency: transitive description: @@ -9,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.59" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" ansicolor: dependency: transitive description: @@ -57,6 +73,78 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.3" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" + url: "https://pub.dev" + source: hosted + version: "4.1.5" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + build_runner: + dependency: transitive + description: + name: build_runner + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" + url: "https://pub.dev" + source: hosted + version: "8.12.7" cached_network_image: dependency: transitive description: @@ -121,14 +209,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.5" + cancellation_token: + dependency: transitive + description: + name: cancellation_token + sha256: ad95acf9d4b2f3563e25dc937f63587e46a70ce534e910b65d10e115490f1027 + url: "https://pub.dev" + source: hosted + version: "2.0.1" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -137,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -161,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -193,6 +313,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" dartx: dependency: transitive description: @@ -233,6 +361,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + ejson: + dependency: transitive + description: + name: ejson + sha256: "5856e32943e476d67304731dedcd2a4b2363f2d14442176e2a151039605c4633" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + ejson_annotation: + dependency: transitive + description: + name: ejson_annotation + sha256: "966c38370f21a0b5ed296a33293c2592a7d14e4b320cc1a5de49d6c55606cc2f" + url: "https://pub.dev" + source: hosted + version: "0.4.1" equatable: dependency: transitive description: @@ -577,6 +721,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" geoclue: dependency: transitive description: @@ -681,6 +833,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.3.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" google_maps: dependency: transitive description: @@ -729,6 +889,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.14+3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" gsettings: dependency: transitive description: @@ -753,6 +921,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -857,6 +1033,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -897,6 +1089,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" lottie: dependency: transitive description: @@ -909,26 +1109,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -953,6 +1153,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + objectid: + dependency: transitive + description: + name: objectid + sha256: f6cea8360862c78b1984cc8f497e484aa7f491282f7f4e4bd2f722bc85f93237 + url: "https://pub.dev" + source: hosted + version: "4.0.3" octo_image: dependency: transitive description: @@ -961,6 +1169,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: transitive description: @@ -1113,6 +1329,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" posix: dependency: transitive description: @@ -1129,6 +1353,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" quiver: dependency: transitive description: @@ -1145,6 +1385,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" + realm: + dependency: transitive + description: + name: realm + sha256: "475b9635c781fe10b898447e86cf979e36966caefd1dd16efd53a3f03bbe30a6" + url: "https://pub.dev" + source: hosted + version: "20.2.0" + realm_common: + dependency: transitive + description: + name: realm_common + sha256: efa7ddac1c31c5abfb4d43f74376ffacfc3f6193a83847969955e2dc4d7908b7 + url: "https://pub.dev" + source: hosted + version: "20.2.0" + realm_dart: + dependency: transitive + description: + name: realm_dart + sha256: "6657fda24dbde1ba749730063c9152330d155536866e73056950d481ff0bed5c" + url: "https://pub.dev" + source: hosted + version: "20.2.0" + realm_generator: + dependency: transitive + description: + name: realm_generator + sha256: c2c90ad42e90865ed3becb864966c90d57384cb8d489135747548e1837a0dfc9 + url: "https://pub.dev" + source: hosted + version: "20.2.0" recase: dependency: transitive description: @@ -1161,6 +1433,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + sane_uuid: + dependency: transitive + description: + name: sane_uuid + sha256: "5b3fd581f51d01aef0526344c175e198d04ff701249c1a75186429dbb4367265" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sanitize_html: dependency: transitive description: @@ -1257,6 +1537,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" shimmer: dependency: transitive description: @@ -1278,6 +1574,14 @@ packages: description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" source_span: dependency: transitive description: @@ -1366,6 +1670,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + tar: + dependency: transitive + description: + name: tar + sha256: b338bacfd24dae6cf527acb4242003a71fc88ce183a9002376fabbc4ebda30c9 + url: "https://pub.dev" + source: hosted + version: "2.0.2" term_glyph: dependency: transitive description: @@ -1378,10 +1690,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" time: dependency: transitive description: @@ -1390,6 +1702,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.5" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + type_plus: + dependency: transitive + description: + name: type_plus + sha256: d5d1019471f0d38b91603adb9b5fd4ce7ab903c879d2fbf1a3f80a630a03fcc9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" typed_data: dependency: transitive description: @@ -1510,6 +1838,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -1518,6 +1854,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" webview_flutter: dependency: transitive description: @@ -1582,6 +1934,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.11.0 <4.0.0" flutter: ">=3.35.0" diff --git a/lib/fa_flutter_core.dart b/lib/fa_flutter_core.dart index 71d2b2d..e596224 100644 --- a/lib/fa_flutter_core.dart +++ b/lib/fa_flutter_core.dart @@ -41,6 +41,7 @@ export 'package:url_launcher/url_launcher.dart'; export 'package:uuid/uuid.dart'; export 'package:webview_flutter/webview_flutter.dart'; +export 'src/activity/fa_flutter_activity.dart'; export 'src/device_info/device_info.dart'; export 'src/extension/num_extension.dart'; export 'src/extension/string_extension.dart'; diff --git a/lib/src/activity/README.md b/lib/src/activity/README.md new file mode 100644 index 0000000..367f8b6 --- /dev/null +++ b/lib/src/activity/README.md @@ -0,0 +1,328 @@ +# Activity Tracking + +Tracks how long users spend in each part of the app, across **every FieldAssist +app sharing the process**, buffers it locally, and hands batches to the host app +to upload. + +## Why it lives in core + +FA One is a git dependency of GT and is mounted inside it, so both run in one +process. That means they already share: + +- one `SharedPreferences` store (`SharedPreferences.getInstance()` is a + process-wide singleton), and +- one Realm database — `fa_flutter_activity.realm`, holding the closed sessions + waiting to be uploaded. + +Putting the repository here gives them a **single instance over that shared +state**. That is what makes this work: + +```dart +// in fa_flutter_one +await ActivityRepository.instance.track( + name: 'Social Feed', + moduleEnum: ActivityModule.faOne, + subModuleEnum: ActivitySubModule.faOneSocialSession, + source: ActivitySource.faOne, +); + +// later, in fa_flutter_gt — closes the session FA One opened +await ActivityRepository.instance.endCurrentSession(); +``` + +Neither app needs to know the other exists, and neither imports the other. + +## Setup + +Call once per process, as early as possible. It is **idempotent**, so every app +can call it defensively — whoever runs first creates the instance and the rest +receive it. + +```dart +await ActivityRepository.initialise( + appPrefs: locator(), + // Only the app that owns syncing needs to pass this. A non-null uploader + // always replaces the stored one, so ordering does not matter. + uploader: (activities) async { + if (!await networkInfo.isConnected) { + throw const SocketException('Offline'); // throw => keep buffered + } + await apiHelper.post( + endpoint: ApiEndpoints.addAppActivities(), + // toApiJson(), not toJson() — the latter is the local storage format. + body: jsonEncode(activities.map((a) => a.toApiJson()).toList()), + ); + }, +); +``` + +Core has no `ApiHelper` of its own — each app brings its own auth and base URL — +so uploading is a callback. **Return normally on success, throw on failure.** A +throw keeps the batch buffered for the next attempt. + +## Usage + +```dart +final activity = ActivityRepository.instance; + +// Open or continue a session. Same screen => keeps accumulating. +await activity.track( + name: 'Order Booking', + moduleEnum: ActivityModule.generalTrade, +); + +// Rename the open session without splitting it (e.g. a sub-tab switch) +await activity.updateCurrentSession(name: 'Order Summary'); + +// Force a fresh guid even on the same screen +await activity.startSession( + name: 'Order Booking', + moduleEnum: ActivityModule.generalTrade, +); + +// Close and buffer — works regardless of which app opened it +await activity.endCurrentSession(); + +// App lifecycle: close but remember, then reopen as a new session +await activity.pauseCurrentSession(); // on background +await activity.resumeLastSession(); // on foreground + +// Upload; also closes and reopens the open session so long visits report +await activity.sync(includeCurrentSession: true); +``` + +Tracking is deliberately **manual** — there is no route observer here, and none +is expected. Call `track()` from the screens you care about. + +### Background / foreground + +`pauseCurrentSession()` closes the open session *and stashes what it was*; +`resumeLastSession()` reopens it as a new session (fresh guid, `startTime` of +now) and clears the stash. Time spent backgrounded is therefore not counted, but +the user does not have to re-navigate for tracking to continue. + +`resumeLastSession()` no-ops if a session is already open — whatever opened it +is more current than the stash. + +In fire-and-forget paths (route observers, lifecycle handlers) prefer +`ActivityRepository.instanceOrNull?.…` so tracking silently no-ops before +startup finishes rather than throwing. + +## Flow + +``` +track(name, moduleEnum, subModuleEnum, source) + │ + ├─ same screen? → leave open session running (duration accumulates) + │ + └─ different? → stamp endTime on the open session + └─ move it to Realm + └─ open a new session in SharedPreferences + │ + ▼ +sync() → uploader(batch) → on success remove those guids + → on throw keep for next attempt +``` + +## Files + +| File | Role | +| --- | --- | +| `src/activity_repository.dart` | Contract + `initialise` / `instance` | +| `src/activity_repository_impl.dart` | Prefs (open session) + DAO (closed) | +| `src/dao/activity_dao.dart` / `_impl` | Realm buffer, capped at 2000 | +| `src/db/activity_realm_db.dart` / `_impl` | Opens / closes the Realm file | +| `src/models/activity_session.dart` | Domain model + `ActivitySource` / `ActivityModule` / `ActivitySubModule` + `toApiJson()` | +| `src/models/activity_session_entity.dart` | The Realm row + mappers to and from the domain model | + +## Wire format + +`toApiJson()` produces the backend's `FaOneUserActivityModel`: + +| JSON key | Source | +| --- | --- | +| `guid` | `guid` | +| `activityDescription` | `name` | +| `moduleName` | `moduleEnum.label` | +| `moduleEnum` | `moduleEnum.id` — **never null**, see below | +| `subModuleName` | `subModuleEnum?.label`, or null | +| `subModuleEnum` | `subModuleEnum?.id`, or null (`int?` server side) | +| `startTime` / `endTime` | local ISO-8601, second precision, no offset | + +**The enums are the identity.** There is no free-text module or submodule +anywhere — not on the wire, not in the model, not in the API. Each `*Name` is +the `label` of the same enum value that produced the `*Enum` beside it, so the +pair cannot drift, and `isSameScreenAs` compares the enums rather than strings +two call sites might spell differently. + +`name` (`activityDescription`) is the one field that still carries free text. +Anything the backend needs to see beyond the enum goes there — a deep-link path, +a tapped card, a screen label. + +`ActivityModule` and `ActivitySubModule` each carry the integer that goes on the +wire: + +| `ActivityModule` | `id` | `label` | +| --- | --- | --- | +| `generalTrade` | 1 | `GeneralTrade` | +| `faOne` | 2 | `FaOne` | +| `embeddedGtFaOne` | 3 | `EmbeddedGTFaOne` | + +| `ActivitySubModule` | `id` | `label` | +| --- | --- | --- | +| `gtPinPage` | 10 | `GTPinPage` | +| `faOneSocialTap` | 20 | `FaOneSocialTap` | +| `faOneLearningTap` | 21 | `FaOneLearningTap` | +| `faOneLeaderboardTap` | 22 | `FaOneLeaderboardTap` | +| `faOneSfaTap` | 23 | `FaOneSfaTap` | +| `faOneHomePageLand` | 24 | `FaOneHomePageLand` | +| `faOneSocialSession` | 25 | `FaOneSocialSession` | +| `faOneLearningSession` | 26 | `FaOneLearningSession` | +| `embeddedGtFaOneSession` | 30 | `EmbeddedGTFaOneSession` | + +> `embeddedGtFaOneSession` stayed at 30 rather than shifting to 31 with the +> block above it — the tens read as ranges (10s GT, 20s FA One, 30s embedded) +> and 26 collides with nothing. Say so if it should move. + +`Tap` values pair with `logEvent()` (an instant), `Session` values with +`track()` / `startSession()` (a span). + +`moduleEnum` is **required** on `track()` / `logEvent()` / `startSession()`, +which is what makes the backend's non-nullable `ModuleEnum` structurally +satisfied — there is no path that could send null. Screens pass `generalTrade` +or `faOne`; `embeddedGtFaOne` is reserved for the app-level session below. + +`subModuleEnum` is optional and has no fallback: null is sent as null, and +`subModuleName` goes null with it rather than naming a submodule the enum does +not cover. + +```dart +// a span +await ActivityRepository.instance.track( + name: 'Social Feed', + moduleEnum: ActivityModule.faOne, + subModuleEnum: ActivitySubModule.faOneSocialSession, + source: ActivitySource.faOne, +); + +// an instant +await ActivityRepository.instance.logEvent( + name: 'Social tapped', + moduleEnum: ActivityModule.faOne, + subModuleEnum: ActivitySubModule.faOneSocialTap, + source: ActivitySource.faOne, +); +``` + +Both are stored by **name**, not by `id`, precisely because ids are not unique — +an `id` of 22 could not be read back unambiguously. + +`toJson()` / `fromJson()` remain the *storage* format, with `ModuleEnum` and +`SubModuleEnum` added as nullable keys so sessions buffered before these fields +existed still decode. + +## Total application usage + +`embeddedGtFaOne` / `embeddedGtFaOneSession` do **not** mean "FA One inside GT" +— they mean *the application as a whole*, and answer "how long has the user used +the app". That span is owned by a separate pair of calls: + +```dart +await ActivityRepository.instance.startAppSession(); // on launch and on resume +await ActivityRepository.instance.endAppSession(); // on background +``` + +It runs **in parallel with** `track()` / `currentSession`, not through it. The +screen chain holds one open session at a time, so an app-wide span could not +live there — the next `track()` would close it. It gets its own +SharedPreferences key (`APP_SESSION_ACTIVITY`) and is buffered only by +`endAppSession()`. + +`startAppSession()` is idempotent: calling it while a segment is running returns +that segment rather than restarting the clock, so a re-entered lifecycle +callback cannot silently discard elapsed time. + +Total usage is the **sum of the segments**, one per foreground stretch. +Backgrounded time is excluded, and each segment is buffered the moment the app +backgrounds — which is also when the background sync runs, so a segment is +uploaded rather than lost if the process is then killed. + +GT drives both ends: `_initActivityRepository` opens the first segment (a cold +start never fires `resumed`), and `AppState.didChangeAppLifecycleState` closes +and reopens the rest. Screens keep reporting `generalTrade` / `faOne` as before. + +## Storage + +Closed sessions live in a local Realm, `fa_flutter_activity.realm`, next to the +app's documents on Android and iOS. `ActivitySessionEntity` is keyed by `guid`, +so re-adding the same session updates its row instead of duplicating it. + +The domain model stays a plain freezed class and never leaves the DAO as a +managed Realm object, so nothing upstream has to care whether the database that +produced it is still open. + +Two things are worth knowing: + +- **A Realm cannot cross isolates.** Each isolate opens its own handle against + the same file and Realm coordinates the concurrent access, which is exactly + what GT's background sync task needs. `ActivityRepository.initialise()` is + per-isolate for the same reason, so a background isolate simply calls it + again. +- **The schema is disposable.** The Realm is opened with + `shouldDeleteIfMigrationNeeded: true`. This database is an upload buffer, not + a source of truth — anything in it is at most a few minutes of usage data — + so a schema change drops it rather than requiring a migration, and can never + fail to open and silently stop tracking. + +Realm has no web support, so activity tracking is Android/iOS/desktop only. + +## Debugging + +Every state change prints to console under `fa_activity`, tagged `[Activity]` +(repository) or `[ActivityDao]` (buffer). Filter your console on `[Activity`. + +Each line carries `source:ModuleName/SubModuleName "name" [guid8]` — the same +names the backend receives, since both come off the enums — so one session can +be followed end to end — opened on one screen, closed on another, uploaded from +the background isolate — and matched against what the backend received: + +``` +[Activity] App session started sfa:EmbeddedGTFaOne/EmbeddedGTFaOneSession "Application" [7c11ab02] +[Activity] Started sfa:GeneralTrade "Order Booking" [3f6b1c9e] +[ActivityDao] Buffered, 4 pending +[Activity] Continue sfa:GeneralTrade "Order Booking" [3f6b1c9e] (1523ms so far) +[Activity] Switch sfa:GeneralTrade "Order Booking" [3f6b1c9e] -> FaOne +[Activity] Closed sfa:GeneralTrade "Order Booking" [3f6b1c9e] after 4820ms, buffered for upload +[Activity] Paused faOne:FaOne/FaOneSocialSession "Social Feed" [9a2f01bd], stashed for resume +[Activity] App session closed sfa:EmbeddedGTFaOne/EmbeddedGTFaOneSession "Application" [7c11ab02] after 41320ms, buffered for upload +[Activity] Resuming faOne:FaOne/FaOneSocialSession "Social Feed" [9a2f01bd] as a new session +[Activity] Uploading 5 session(s)... +[Activity] -> sfa:GeneralTrade "Order Booking" [3f6b1c9e] +[Activity] Synced 5 session(s) in 412ms, buffer cleared +``` + +Levels: `d` for normal flow, `w` for things worth noticing (no uploader, buffer +overflow, state cleared), `e` for upload failures. + +> `.v()` is **not** used anywhere here on purpose. In `logger` 2.x +> `Level.verbose` is 999 while the default `Logger.level` is `trace` (1000), so +> verbose lines are silently discarded and would never reach the console. + +Sync frequency is owned by the host app, not core — GT drives it from +`BackgroundTaskHandler.fetchIntervalInMinutes` (Android 5 min, iOS 15 min). + +## Notes + +- **Mutations are serialized** on an internal queue. Callers fire `track()` + without awaiting, and two apps share the instance, so without it two + navigations could interleave their read-modify-write of the current session. + Public methods take the lock; the private `*Unlocked` variants exist so + internal calls don't deadlock. +- **Background isolates get their own instance.** A second isolate has its own + prefs cache, so it should sync with `includeCurrentSession: false` — closing + the open session there would be invisible to the foreground and could + double-report the same span. +- `source` distinguishes which app produced a row. Without it the backend cannot + tell GT usage from FA One usage, since they share everything else. +- The buffer is capped at 2000 sessions; if uploads keep failing the oldest are + dropped rather than growing the database without limit. diff --git a/lib/src/activity/fa_flutter_activity.dart b/lib/src/activity/fa_flutter_activity.dart new file mode 100644 index 0000000..6bab9f0 --- /dev/null +++ b/lib/src/activity/fa_flutter_activity.dart @@ -0,0 +1,10 @@ +library fa_flutter_activity; + +export 'src/activity_repository.dart'; +export 'src/activity_repository_impl.dart'; +export 'src/dao/activity_dao.dart'; +export 'src/dao/activity_dao_impl.dart'; +export 'src/db/activity_realm_db.dart'; +export 'src/db/activity_realm_db_impl.dart'; +export 'src/models/activity_session.dart'; +export 'src/models/activity_session_entity.dart'; diff --git a/lib/src/activity/src/activity_repository.dart b/lib/src/activity/src/activity_repository.dart new file mode 100644 index 0000000..e14683e --- /dev/null +++ b/lib/src/activity/src/activity_repository.dart @@ -0,0 +1,159 @@ +import '../../../fa_flutter_core.dart'; + +typedef ActivityUploader = Future Function( + List activities, +); + +/// Call [initialise] once per process, as early as possible. It is idempotent, +/// so every app in the process can call it defensively — whoever gets there +/// first creates the instance and the rest receive it. +abstract class ActivityRepository { + static const String defaultDatabaseName = 'fa_flutter_activity'; + + static const String prefsCurrentActivity = 'APP_CURRENT_ACTIVITY'; + + /// SharedPreferences key holding the session stashed by [pauseCurrentSession], + /// so [resumeLastSession] can reopen it when the app returns to foreground. + static const String prefsLastActivity = 'APP_LAST_ACTIVITY'; + + /// SharedPreferences key holding the open application-level session. + /// + /// Separate from [prefsCurrentActivity] on purpose: the app session runs + /// *alongside* whatever screen session is open, so the two cannot share one + /// slot. See [startAppSession]. + static const String prefsAppSession = 'APP_SESSION_ACTIVITY'; + + static ActivityRepository? _instance; + + /// Whether [initialise] has run in this isolate. + /// + /// Check this before tracking from code that may run before startup finishes. + static bool get isInitialised => _instance != null; + + /// The process-wide instance, or null when [initialise] has not run. + /// + /// Prefer this over [instance] in fire-and-forget paths so tracking can + /// silently no-op rather than throw. + static ActivityRepository? get instanceOrNull => _instance; + + /// The process-wide instance. + /// + /// Throws a [StateError] if [initialise] has not been awaited yet. + static ActivityRepository get instance { + final repository = _instance; + if (repository == null) { + throw StateError( + 'ActivityRepository.initialise() must be awaited before use. ' + 'Call it during app startup, before any track() call.', + ); + } + return repository; + } + + static Future initialise({ + required AppPrefs appPrefs, + ActivityUploader? uploader, + String databaseName = defaultDatabaseName, + ActivityRealmDb? activityRealmDb, + }) async { + final existing = _instance; + if (existing != null) { + if (uploader != null) { + existing.uploader = uploader; + } + return existing; + } + + final realmDb = activityRealmDb ?? ActivityRealmDbImpl(); + if (activityRealmDb == null) { + await realmDb.initialise(databaseName); + } + + return _instance = ActivityRepositoryImpl( + appPrefs: appPrefs, + activityDao: ActivityDaoImpl(realmDb), + uploader: uploader, + ); + } + + /// Drops the shared instance. For tests and for teardown after logout. + /// + /// Does not touch stored data — use [clear] for that. + static void resetInstance() => _instance = null; + + /// Uploads buffered sessions. Null disables syncing. + abstract ActivityUploader? uploader; + + /// Read straight from SharedPreferences, so it reflects writes made by any + /// app in the process. + ActivitySession? get currentSession; + + /// [ActivityModule.embeddedGtFaOne] is reserved for the app-level session — + /// see [startAppSession] — so screens pass `generalTrade` or `faOne`. + Future track({ + required String name, + required ActivityModule moduleEnum, + ActivitySubModule? subModuleEnum, + ActivitySource source, + }); + + /// Goes straight into the buffer and **leaves the open session alone**. Use + /// this rather than [track] for anything the user does *while staying put*: + /// [track] would close the session for the screen they are on and open one + /// for the tap, which then stays open until they navigate, attributing all + /// the intervening time to a button press. + Future logEvent({ + required String name, + required ActivityModule moduleEnum, + ActivitySubModule? subModuleEnum, + ActivitySource source, + }); + + /// Closes any open session and unconditionally opens a new one, even if it + /// matches the current screen. Prefer [track] unless you specifically need a + /// fresh guid. + Future startSession({ + required String name, + required ActivityModule moduleEnum, + ActivitySubModule? subModuleEnum, + ActivitySource source, + }); + + /// Use when the user stays on the same logical screen but what it reports + /// changes (e.g. a sub-tab switch that should not split the session). Null + /// leaves a field unchanged; pass [clearSubModuleEnum] to null the submodule + /// out. Returns null when no session is open. + Future updateCurrentSession({ + String? name, + ActivityModule? moduleEnum, + ActivitySubModule? subModuleEnum, + bool clearSubModuleEnum, + }); + + Future endCurrentSession(); + + /// The open application-level session, or null if none is running. + ActivitySession? get appSession; + + /// Pair it with the app lifecycle — start on resume, [endAppSession] on + /// background — so each segment covers foreground time only and the total is + /// their sum. Backgrounded time is then excluded rather than inflating usage. + Future startAppSession({ + String name, + ActivityModule moduleEnum, + ActivitySubModule subModuleEnum, + ActivitySource source, + }); + + Future endAppSession(); + + Future pauseCurrentSession(); + + Future resumeLastSession(); + + Future> getPendingActivities(); + + Future sync({bool includeCurrentSession}); + + Future clear(); +} diff --git a/lib/src/activity/src/activity_repository_impl.dart b/lib/src/activity/src/activity_repository_impl.dart new file mode 100644 index 0000000..f2e4da1 --- /dev/null +++ b/lib/src/activity/src/activity_repository_impl.dart @@ -0,0 +1,476 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../../../fa_flutter_core.dart'; + +final AppLog _logger = AppLogImpl(packageName: 'fa_activity'); + +class ActivityRepositoryImpl implements ActivityRepository { + ActivityRepositoryImpl({ + required this.appPrefs, + required this.activityDao, + this.uploader, + }); + + final AppPrefs appPrefs; + final ActivityDao activityDao; + + @override + ActivityUploader? uploader; + + /// Tail of the serialized mutation queue. See [_serialized]. + Future _lock = Future.value(); + + int get _nowInMillis => DateTime.now().millisecondsSinceEpoch; + + /// Consistent label for log lines: + /// `sfa:GeneralTrade/GTPinPage "App Opened" [3f6b1c9e]`. + /// + /// Names come from the enums, so a log line and the uploaded row read the + /// same. The guid prefix is what lets one session be followed across the log + /// — opened here, closed on a later screen, uploaded from the background + /// isolate — and matched against a row the backend received. + String _tag(ActivitySession session) { + final guid = session.guid; + final shortGuid = guid.length > 8 ? guid.substring(0, 8) : guid; + final sub = + session.subModuleName == null ? '' : '/${session.subModuleName}'; + return '${session.source.name}:${session.moduleName}$sub ' + '"${session.name}" [$shortGuid]'; + } + + @override + ActivitySession? get currentSession => + _readSession(ActivityRepository.prefsCurrentActivity, 'current'); + + @override + ActivitySession? get appSession => + _readSession(ActivityRepository.prefsAppSession, 'application'); + + /// Reads a session stored under [key], dropping it if it cannot be parsed. + /// + /// [label] only names the session in the error line. + ActivitySession? _readSession(String key, String label) { + final raw = appPrefs.getString(key); + if (raw == null || raw.isEmpty) { + return null; + } + try { + return ActivitySession.fromJson( + jsonDecode(raw) as Map, + ); + } catch (e) { + // A malformed value would otherwise wedge tracking permanently. + _logger.e('[Activity] Dropping unreadable $label session: $e', + StackTrace.current); + unawaited(appPrefs.remove(key)); + return null; + } + } + + @override + Future track({ + required String name, + required ActivityModule moduleEnum, + ActivitySubModule? subModuleEnum, + ActivitySource source = ActivitySource.sfa, + }) => + _serialized( + () => _trackUnlocked( + name: name, + moduleEnum: moduleEnum, + subModuleEnum: subModuleEnum, + source: source, + ), + ); + + Future _trackUnlocked({ + required String name, + required ActivityModule moduleEnum, + ActivitySubModule? subModuleEnum, + ActivitySource source = ActivitySource.sfa, + }) async { + final current = currentSession; + final isSameScreen = current?.isSameScreenAs( + name: name, + moduleEnum: moduleEnum, + subModuleEnum: subModuleEnum, + source: source, + ) ?? + false; + + if (current != null && isSameScreen) { + // Already on this screen — let the open session keep accumulating. + _logger.d( + '[Activity] Continue ${_tag(current)} ' + '(${current.durationInMillis}ms so far)', + ); + return current; + } + + if (current != null) { + _logger.d('[Activity] Switch ${_tag(current)} -> ${moduleEnum.label}' + '${subModuleEnum == null ? '' : '/${subModuleEnum.label}'}'); + } + + return _startSessionUnlocked( + name: name, + moduleEnum: moduleEnum, + subModuleEnum: subModuleEnum, + source: source, + ); + } + + /// Deliberately not [_serialized]: the lock guards read-modify-write of the + /// current session in SharedPreferences, and this touches neither — it only + /// appends one already-closed row. Queuing it behind a slow track() would + /// delay a tap for no benefit. + @override + Future logEvent({ + required String name, + required ActivityModule moduleEnum, + ActivitySubModule? subModuleEnum, + ActivitySource source = ActivitySource.sfa, + }) async { + final now = _nowInMillis; + final event = ActivitySession( + guid: const Uuid().v7(), + name: name, + moduleEnum: moduleEnum, + startTime: now, + endTime: now, + subModuleEnum: subModuleEnum, + source: source, + ); + await activityDao.addPendingActivity(event); + _logger.d('[Activity] Event ${_tag(event)} buffered'); + return event; + } + + @override + Future startSession({ + required String name, + required ActivityModule moduleEnum, + ActivitySubModule? subModuleEnum, + ActivitySource source = ActivitySource.sfa, + }) => + _serialized( + () => _startSessionUnlocked( + name: name, + moduleEnum: moduleEnum, + subModuleEnum: subModuleEnum, + source: source, + ), + ); + + Future _startSessionUnlocked({ + required String name, + required ActivityModule moduleEnum, + ActivitySubModule? subModuleEnum, + ActivitySource source = ActivitySource.sfa, + }) async { + await _endCurrentSessionUnlocked(); + + final session = ActivitySession( + guid: const Uuid().v7(), + name: name, + moduleEnum: moduleEnum, + startTime: _nowInMillis, + subModuleEnum: subModuleEnum, + source: source, + ); + await _saveCurrentSession(session); + _logger.d('[Activity] Started ${_tag(session)}'); + return session; + } + + @override + Future updateCurrentSession({ + String? name, + ActivityModule? moduleEnum, + ActivitySubModule? subModuleEnum, + bool clearSubModuleEnum = false, + }) => + _serialized( + () => _updateCurrentSessionUnlocked( + name: name, + moduleEnum: moduleEnum, + subModuleEnum: subModuleEnum, + clearSubModuleEnum: clearSubModuleEnum, + ), + ); + + Future _updateCurrentSessionUnlocked({ + String? name, + ActivityModule? moduleEnum, + ActivitySubModule? subModuleEnum, + bool clearSubModuleEnum = false, + }) async { + final current = currentSession; + if (current == null) { + _logger.w('[Activity] updateCurrentSession ignored, nothing open'); + return null; + } + + final updated = current.copyWith( + name: name ?? current.name, + moduleEnum: moduleEnum ?? current.moduleEnum, + subModuleEnum: + clearSubModuleEnum ? null : (subModuleEnum ?? current.subModuleEnum), + ); + await _saveCurrentSession(updated); + // Same guid and startTime: the session was amended, not split. + _logger.d('[Activity] Updated ${_tag(current)} -> ${_tag(updated)}'); + return updated; + } + + @override + Future startAppSession({ + String name = 'Application', + ActivityModule moduleEnum = ActivityModule.embeddedGtFaOne, + ActivitySubModule subModuleEnum = ActivitySubModule.embeddedGtFaOneSession, + ActivitySource source = ActivitySource.sfa, + }) => + _serialized(() async { + final open = appSession; + if (open != null) { + // Already running — a second call must not restart the clock, or a + // re-entered lifecycle callback would silently discard the elapsed + // time. + _logger.d( + '[Activity] App session already open ${_tag(open)} ' + '(${open.durationInMillis}ms so far)', + ); + return open; + } + + final session = ActivitySession( + guid: const Uuid().v7(), + name: name, + moduleEnum: moduleEnum, + startTime: _nowInMillis, + source: source, + subModuleEnum: subModuleEnum, + ); + await appPrefs.setString( + ActivityRepository.prefsAppSession, + jsonEncode(session.toJson()), + ); + _logger.d('[Activity] App session started ${_tag(session)}'); + return session; + }); + + @override + Future endAppSession() => _serialized(() async { + final open = appSession; + if (open == null) { + _logger.d('[Activity] endAppSession: no app session open'); + return null; + } + + final closed = open.copyWith(endTime: _nowInMillis); + await activityDao.addPendingActivity(closed); + await appPrefs.remove(ActivityRepository.prefsAppSession); + _logger.d( + '[Activity] App session closed ${_tag(closed)} after ' + '${closed.durationInMillis}ms, buffered for upload', + ); + return closed; + }); + + @override + Future endCurrentSession() => + _serialized(_endCurrentSessionUnlocked); + + Future _endCurrentSessionUnlocked() async { + final current = currentSession; + if (current == null) { + _logger.d('[Activity] endCurrentSession: nothing open'); + return null; + } + + final closed = current.copyWith(endTime: _nowInMillis); + await activityDao.addPendingActivity(closed); + await appPrefs.remove(ActivityRepository.prefsCurrentActivity); + _logger.d( + '[Activity] Closed ${_tag(closed)} after ${closed.durationInMillis}ms, ' + 'buffered for upload', + ); + return closed; + } + + @override + Future pauseCurrentSession() => + _serialized(_pauseCurrentSessionUnlocked); + + Future _pauseCurrentSessionUnlocked() async { + final closed = await _endCurrentSessionUnlocked(); + if (closed == null) { + _logger.d('[Activity] Pause: nothing open, nothing stashed'); + return null; + } + + await appPrefs.setString( + ActivityRepository.prefsLastActivity, + jsonEncode(closed.toJson()), + ); + _logger.d('[Activity] Paused ${_tag(closed)}, stashed for resume'); + return closed; + } + + @override + Future resumeLastSession() => + _serialized(_resumeLastSessionUnlocked); + + Future _resumeLastSessionUnlocked() async { + // Something already opened a session since the pause (a screen tracked + // during startup, say) — that is more current than the stash. + final current = currentSession; + if (current != null) { + await appPrefs.remove(ActivityRepository.prefsLastActivity); + _logger.d( + '[Activity] Resume skipped, ${_tag(current)} already open; ' + 'stash discarded', + ); + return current; + } + + final raw = appPrefs.getString(ActivityRepository.prefsLastActivity); + if (raw == null || raw.isEmpty) { + _logger.d('[Activity] Resume: nothing stashed'); + return null; + } + + ActivitySession last; + try { + last = ActivitySession.fromJson(jsonDecode(raw) as Map); + } catch (e) { + _logger.e('[Activity] Dropping unreadable stashed session: $e', + StackTrace.current); + await appPrefs.remove(ActivityRepository.prefsLastActivity); + return null; + } + + await appPrefs.remove(ActivityRepository.prefsLastActivity); + _logger.d('[Activity] Resuming ${_tag(last)} as a new session'); + return _startSessionUnlocked( + name: last.name, + moduleEnum: last.moduleEnum, + subModuleEnum: last.subModuleEnum, + source: last.source, + ); + } + + @override + Future> getPendingActivities() => + activityDao.getPendingActivities(); + + @override + Future sync({bool includeCurrentSession = false}) async { + final upload = uploader; + if (upload == null) { + // Expected in FA One standalone, and in GT's foreground: only the + // headless background task registers an uploader. + _logger.w('[Activity] Sync skipped, no uploader registered'); + return const VoidResult.failure(reason: 'No uploader registered.'); + } + + if (includeCurrentSession) { + await _serialized(_rollOverCurrentSession); + } + + // Deliberately outside the lock: uploading can be slow, and it only + // touches pending rows, which it removes by explicit guid. + final pending = await activityDao.getPendingActivities(); + if (pending.isEmpty) { + _logger.d('[Activity] Sync: buffer empty, nothing to upload'); + return const VoidResult.success(); + } + + _logger.i('[Activity] Uploading ${pending.length} session(s)...'); + for (final activity in pending) { + _logger.d('[Activity] -> ${_tag(activity)}'); + } + + final startedAt = DateTime.now(); + try { + await upload(pending); + await activityDao.removePendingActivities( + pending.map((activity) => activity.guid).toList(), + ); + final elapsed = DateTime.now().difference(startedAt).inMilliseconds; + _logger.i( + '[Activity] Synced ${pending.length} session(s) in ${elapsed}ms, ' + 'buffer cleared', + ); + return const VoidResult.success(); + } catch (e) { + // Left buffered for the next attempt. + _logger.e( + '[Activity] Upload FAILED, kept ${pending.length} session(s) ' + 'buffered for the next run: $e', + StackTrace.current, + ); + return VoidResult.failure(reason: e.toString()); + } + } + + @override + Future clear() => _serialized(() async { + final dropped = (await activityDao.getPendingActivities()).length; + await appPrefs.remove(ActivityRepository.prefsCurrentActivity); + await appPrefs.remove(ActivityRepository.prefsLastActivity); + await appPrefs.remove(ActivityRepository.prefsAppSession); + await activityDao.clearPendingActivities(); + _logger.w( + '[Activity] Cleared all activity state, ' + 'discarding $dropped unsynced session(s)', + ); + }); + + Future _saveCurrentSession(ActivitySession session) => + appPrefs.setString( + ActivityRepository.prefsCurrentActivity, + jsonEncode(session.toJson()), + ); + + /// Closes the open session so it can be shipped, then reopens an identical + /// one. Without this a user parked on a single screen for hours would report + /// nothing until they finally navigated away. + Future _rollOverCurrentSession() async { + final current = currentSession; + if (current == null) { + return; + } + + _logger.d('[Activity] Rolling over ${_tag(current)} so it can be shipped'); + await _endCurrentSessionUnlocked(); + await _startSessionUnlocked( + name: current.name, + moduleEnum: current.moduleEnum, + subModuleEnum: current.subModuleEnum, + source: current.source, + ); + } + + /// Runs [action] after every previously queued action has finished. + /// + /// Callers fire tracking without awaiting it (route observers do), and two + /// apps share this instance, so without a queue two navigations could + /// interleave their read-modify-write of the current session and lose or + /// duplicate one. + /// + /// Public methods take the lock; the `*Unlocked` variants are what they call + /// internally, since re-entering would deadlock. + Future _serialized(Future Function() action) { + final completer = Completer(); + _lock = _lock.then((_) async { + try { + completer.complete(await action()); + } catch (e, s) { + completer.completeError(e, s); + } + }); + return completer.future; + } +} diff --git a/lib/src/activity/src/dao/activity_dao.dart b/lib/src/activity/src/dao/activity_dao.dart new file mode 100644 index 0000000..6bd8860 --- /dev/null +++ b/lib/src/activity/src/dao/activity_dao.dart @@ -0,0 +1,14 @@ +import '../models/activity_session.dart'; + +/// Local store for *closed* activity sessions that are waiting to be uploaded. +abstract class ActivityDao { + Future> getPendingActivities(); + + Future savePendingActivities(List activities); + + Future addPendingActivity(ActivitySession activity); + + Future removePendingActivities(List guids); + + Future clearPendingActivities(); +} diff --git a/lib/src/activity/src/dao/activity_dao_impl.dart b/lib/src/activity/src/dao/activity_dao_impl.dart new file mode 100644 index 0000000..a4a4bda --- /dev/null +++ b/lib/src/activity/src/dao/activity_dao_impl.dart @@ -0,0 +1,108 @@ +import 'package:realm/realm.dart'; + +import '../../../../fa_flutter_core.dart'; + +final AppLog _logger = AppLogImpl(packageName: 'fa_activity'); + +/// Realm-backed [ActivityDao]. +/// +/// The database handle comes from [ActivityRealmDb] rather than being opened +/// here, because a Realm is isolate-confined and its lifetime belongs to +/// whoever set the isolate up. +class ActivityDaoImpl implements ActivityDao { + ActivityDaoImpl(this.activityRealmDb); + + final ActivityRealmDb activityRealmDb; + + /// Upper bound on locally buffered sessions. If uploads keep failing the + /// oldest records are dropped rather than growing the database without limit. + static const maxPendingActivities = 2000; + + Realm get _realm => activityRealmDb.realm; + + /// Oldest first. Realm results have no inherent order, so the sort is part of + /// the query — [ActivityDao.getPendingActivities] promises this ordering and + /// [_trimTo] relies on it to know which rows are the oldest. + RealmResults get _oldestFirst => + _realm.query(r'TRUEPREDICATE SORT(startTime ASC)'); + + @override + Future> getPendingActivities() async => + _oldestFirst.map((entity) => entity.toModel()).toList(); + + @override + Future savePendingActivities(List activities) async { + final trimmed = activities.length > maxPendingActivities + ? activities.sublist(activities.length - maxPendingActivities) + : activities; + + if (trimmed.length < activities.length) { + _logOverflow(activities.length - trimmed.length); + } + + await _realm.writeAsync(() { + _realm.deleteAll(); + _realm.addAll(trimmed.map(ActivitySessionEntityX.fromModel)); + }); + } + + @override + Future addPendingActivity(ActivitySession activity) async { + final pending = await _realm.writeAsync(() { + // `update: true` keys on the primary key, so re-adding the same session + // overwrites it rather than creating a duplicate. + _realm.add(ActivitySessionEntityX.fromModel(activity), update: true); + return _trimTo(maxPendingActivities); + }); + + _logger.d('[ActivityDao] Buffered, $pending pending'); + } + + @override + Future removePendingActivities(List guids) async { + if (guids.isEmpty) { + return; + } + + // A guid can already be gone — `clear()` or an overflow trim may have run + // while an upload was in flight — so missing rows are skipped, not an error. + final rows = guids + .map((guid) => _realm.find(guid)) + .nonNulls + .toList(); + if (rows.isEmpty) { + return; + } + + await _realm.writeAsync(() => _realm.deleteMany(rows)); + _logger.d('[ActivityDao] Removed ${rows.length} uploaded session(s)'); + } + + @override + Future clearPendingActivities() => + _realm.writeAsync(() => _realm.deleteAll()); + + /// Drops the oldest rows until at most [limit] remain, and returns how many + /// are left. Must be called inside a write transaction. + int _trimTo(int limit) { + final pending = _oldestFirst; + final excess = pending.length - limit; + if (excess <= 0) { + return pending.length; + } + + // Materialized before deleting: `pending` is a live view that shrinks as + // rows are removed. + _realm.deleteMany(pending.take(excess).toList()); + _logOverflow(excess); + return limit; + } + + void _logOverflow(int dropped) { + // Data loss — the only place it happens, so it must be visible. + _logger.w( + '[ActivityDao] Buffer over $maxPendingActivities, DROPPED $dropped ' + 'oldest session(s). Uploads have been failing for a long time.', + ); + } +} diff --git a/lib/src/activity/src/db/activity_realm_db.dart b/lib/src/activity/src/db/activity_realm_db.dart new file mode 100644 index 0000000..bf78198 --- /dev/null +++ b/lib/src/activity/src/db/activity_realm_db.dart @@ -0,0 +1,9 @@ +import 'package:realm/realm.dart'; + +abstract class ActivityRealmDb { + Future initialise(String databaseName); + + Realm get realm; + + Future close(); +} diff --git a/lib/src/activity/src/db/activity_realm_db_impl.dart b/lib/src/activity/src/db/activity_realm_db_impl.dart new file mode 100644 index 0000000..80ddca6 --- /dev/null +++ b/lib/src/activity/src/db/activity_realm_db_impl.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:realm/realm.dart'; + +import '../../../../fa_flutter_core.dart'; + +final AppLog _logger = AppLogImpl(packageName: 'fa_activity'); + +class ActivityRealmDbImpl implements ActivityRealmDb { + static const schemaVersion = 1; + + Realm? _realm; + + @override + Realm get realm { + final realm = _realm; + if (realm == null || realm.isClosed) { + throw StateError( + 'ActivityRealmDb.initialise() must be awaited before use.', + ); + } + return realm; + } + + @override + Future initialise(String databaseName) async { + final existing = _realm; + if (existing != null && !existing.isClosed) { + return; + } + + final config = Configuration.local( + [ActivitySessionEntity.schema], + path: await _databasePath(databaseName), + schemaVersion: schemaVersion, + shouldDeleteIfMigrationNeeded: true, + ); + + _realm = Realm(config); + _logger.d('[ActivityDao] Realm opened at ${config.path}'); + } + + @override + Future close() async { + final realm = _realm; + if (realm == null || realm.isClosed) { + return; + } + realm.close(); + _realm = null; + _logger.d('[ActivityDao] Realm closed'); + } + + Future _databasePath(String databaseName) async { + final fileName = '$databaseName.realm'; + if (!Platform.isAndroid && !Platform.isIOS) { + return fileName; + } + final directory = await getApplicationDocumentsDirectory(); + await directory.create(recursive: true); + return '${directory.path}${Platform.pathSeparator}$fileName'; + } +} diff --git a/lib/src/activity/src/models/activity_session.dart b/lib/src/activity/src/models/activity_session.dart new file mode 100644 index 0000000..c3bafe7 --- /dev/null +++ b/lib/src/activity/src/models/activity_session.dart @@ -0,0 +1,122 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'activity_session.freezed.dart'; +part 'activity_session.g.dart'; + +enum ActivitySource { + @JsonValue('SFA') + sfa, + @JsonValue('FA_ONE') + faOne, +} + +enum ActivityModule { + @JsonValue('generalTrade') + generalTrade(1, 'GeneralTrade'), + @JsonValue('faOne') + faOne(2, 'FaOne'), + + @JsonValue('embeddedGtFaOne') + embeddedGtFaOne(3, 'EmbeddedGTFaOne'); + + const ActivityModule(this.id, this.label); + + final int id; + + final String label; +} + +enum ActivitySubModule { + @JsonValue('gtPinPage') + gtPinPage(10, 'GTPinPage'), + @JsonValue('faOneSocialTap') + faOneSocialTap(20, 'FaOneSocialTap'), + @JsonValue('faOneLearningTap') + faOneLearningTap(21, 'FaOneLearningTap'), + @JsonValue('faOneLeaderboardTap') + faOneLeaderboardTap(22, 'FaOneLeaderboardTap'), + @JsonValue('faOneSfaTap') + faOneSfaTap(23, 'FaOneSfaTap'), + @JsonValue('faOneHomePageLand') + faOneHomePageLand(24, 'FaOneHomePageLand'), + @JsonValue('faOneSocialSession') + faOneSocialSession(25, 'FaOneSocialSession'), + @JsonValue('faOneLearningSession') + faOneLearningSession(26, 'FaOneLearningSession'), + + @JsonValue('embeddedGtFaOneSession') + embeddedGtFaOneSession(30, 'EmbeddedGTFaOneSession'); + + const ActivitySubModule(this.id, this.label); + + final int id; + + final String label; +} + +@freezed +abstract class ActivitySession with _$ActivitySession { + factory ActivitySession({ + @JsonKey(name: 'Guid') required String guid, + @JsonKey(name: 'Name') required String name, + + /// Which part of the product this is. Required — it is the session's + /// identity, and `ModuleEnum` is non-nullable on the wire. + @JsonKey(name: 'ModuleEnum') required ActivityModule moduleEnum, + + /// Epoch milliseconds. + @JsonKey(name: 'StartTime') required int startTime, + + /// The backend's `SubModuleEnum`, or null when no value names this screen. + /// Null is sent as null — there is no fallback, since nothing else in the + /// session identifies which one would apply. + @JsonKey(name: 'SubModuleEnum') ActivitySubModule? subModuleEnum, + + /// Epoch milliseconds. Null while the session is still open. + @JsonKey(name: 'EndTime') int? endTime, + @JsonKey(name: 'Source') @Default(ActivitySource.sfa) ActivitySource source, + }) = _ActivitySession; + + const ActivitySession._(); + + factory ActivitySession.fromJson(Map json) => + _$ActivitySessionFromJson(json); + + Map toApiJson() => { + 'guid': guid, + 'activityDescription': name, + 'moduleName': moduleName, + 'moduleEnum': moduleEnum.id, + 'subModuleName': subModuleName, + 'subModuleEnum': subModuleEnum?.id, + 'startTime': _toApiTimestamp(startTime), + 'endTime': _toApiTimestamp(endTime ?? startTime), + }; + + String get moduleName => moduleEnum.label; + + String? get subModuleName => subModuleEnum?.label; + + static String _toApiTimestamp(int epochMillis) { + final dateTime = DateTime.fromMillisecondsSinceEpoch(epochMillis); + String pad(int value) => value.toString().padLeft(2, '0'); + return '${dateTime.year}-${pad(dateTime.month)}-${pad(dateTime.day)}' + 'T${pad(dateTime.hour)}:${pad(dateTime.minute)}:${pad(dateTime.second)}'; + } + + bool get isOpen => endTime == null; + + int get durationInMillis => + (endTime ?? DateTime.now().millisecondsSinceEpoch) - startTime; + + bool isSameScreenAs({ + required String name, + required ActivityModule moduleEnum, + required ActivitySubModule? subModuleEnum, + required ActivitySource source, + }) => + this.name == name && + this.moduleEnum == moduleEnum && + this.subModuleEnum == subModuleEnum && + this.source == source; +} diff --git a/lib/src/activity/src/models/activity_session.freezed.dart b/lib/src/activity/src/models/activity_session.freezed.dart new file mode 100644 index 0000000..bb05a71 --- /dev/null +++ b/lib/src/activity/src/models/activity_session.freezed.dart @@ -0,0 +1,322 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'activity_session.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ActivitySession { + @JsonKey(name: 'Guid') + String get guid; + @JsonKey(name: 'Name') + String get name; + + /// Which part of the product this is. Required — it is the session's + /// identity, and `ModuleEnum` is non-nullable on the wire. + @JsonKey(name: 'ModuleEnum') + ActivityModule get moduleEnum; + + /// Epoch milliseconds. + @JsonKey(name: 'StartTime') + int get startTime; + + /// The backend's `SubModuleEnum`, or null when no value names this screen. + /// Null is sent as null — there is no fallback, since nothing else in the + /// session identifies which one would apply. + @JsonKey(name: 'SubModuleEnum') + ActivitySubModule? get subModuleEnum; + + /// Epoch milliseconds. Null while the session is still open. + @JsonKey(name: 'EndTime') + int? get endTime; + + /// Which app wrote the row. Local only — [moduleEnum] is what the backend + /// reads, and the two say much the same thing. + @JsonKey(name: 'Source') + ActivitySource get source; + + /// Create a copy of ActivitySession + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ActivitySessionCopyWith get copyWith => + _$ActivitySessionCopyWithImpl( + this as ActivitySession, _$identity); + + /// Serializes this ActivitySession to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ActivitySession && + (identical(other.guid, guid) || other.guid == guid) && + (identical(other.name, name) || other.name == name) && + (identical(other.moduleEnum, moduleEnum) || + other.moduleEnum == moduleEnum) && + (identical(other.startTime, startTime) || + other.startTime == startTime) && + (identical(other.subModuleEnum, subModuleEnum) || + other.subModuleEnum == subModuleEnum) && + (identical(other.endTime, endTime) || other.endTime == endTime) && + (identical(other.source, source) || other.source == source)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, guid, name, moduleEnum, + startTime, subModuleEnum, endTime, source); + + @override + String toString() { + return 'ActivitySession(guid: $guid, name: $name, moduleEnum: $moduleEnum, startTime: $startTime, subModuleEnum: $subModuleEnum, endTime: $endTime, source: $source)'; + } +} + +/// @nodoc +abstract mixin class $ActivitySessionCopyWith<$Res> { + factory $ActivitySessionCopyWith( + ActivitySession value, $Res Function(ActivitySession) _then) = + _$ActivitySessionCopyWithImpl; + @useResult + $Res call( + {@JsonKey(name: 'Guid') String guid, + @JsonKey(name: 'Name') String name, + @JsonKey(name: 'ModuleEnum') ActivityModule moduleEnum, + @JsonKey(name: 'StartTime') int startTime, + @JsonKey(name: 'SubModuleEnum') ActivitySubModule? subModuleEnum, + @JsonKey(name: 'EndTime') int? endTime, + @JsonKey(name: 'Source') ActivitySource source}); +} + +/// @nodoc +class _$ActivitySessionCopyWithImpl<$Res> + implements $ActivitySessionCopyWith<$Res> { + _$ActivitySessionCopyWithImpl(this._self, this._then); + + final ActivitySession _self; + final $Res Function(ActivitySession) _then; + + /// Create a copy of ActivitySession + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? guid = null, + Object? name = null, + Object? moduleEnum = null, + Object? startTime = null, + Object? subModuleEnum = freezed, + Object? endTime = freezed, + Object? source = null, + }) { + return _then(_self.copyWith( + guid: null == guid + ? _self.guid + : guid // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + moduleEnum: null == moduleEnum + ? _self.moduleEnum + : moduleEnum // ignore: cast_nullable_to_non_nullable + as ActivityModule, + startTime: null == startTime + ? _self.startTime + : startTime // ignore: cast_nullable_to_non_nullable + as int, + subModuleEnum: freezed == subModuleEnum + ? _self.subModuleEnum + : subModuleEnum // ignore: cast_nullable_to_non_nullable + as ActivitySubModule?, + endTime: freezed == endTime + ? _self.endTime + : endTime // ignore: cast_nullable_to_non_nullable + as int?, + source: null == source + ? _self.source + : source // ignore: cast_nullable_to_non_nullable + as ActivitySource, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _ActivitySession extends ActivitySession { + _ActivitySession( + {@JsonKey(name: 'Guid') required this.guid, + @JsonKey(name: 'Name') required this.name, + @JsonKey(name: 'ModuleEnum') required this.moduleEnum, + @JsonKey(name: 'StartTime') required this.startTime, + @JsonKey(name: 'SubModuleEnum') this.subModuleEnum, + @JsonKey(name: 'EndTime') this.endTime, + @JsonKey(name: 'Source') this.source = ActivitySource.sfa}) + : super._(); + factory _ActivitySession.fromJson(Map json) => + _$ActivitySessionFromJson(json); + + @override + @JsonKey(name: 'Guid') + final String guid; + @override + @JsonKey(name: 'Name') + final String name; + + /// Which part of the product this is. Required — it is the session's + /// identity, and `ModuleEnum` is non-nullable on the wire. + @override + @JsonKey(name: 'ModuleEnum') + final ActivityModule moduleEnum; + + /// Epoch milliseconds. + @override + @JsonKey(name: 'StartTime') + final int startTime; + + /// The backend's `SubModuleEnum`, or null when no value names this screen. + /// Null is sent as null — there is no fallback, since nothing else in the + /// session identifies which one would apply. + @override + @JsonKey(name: 'SubModuleEnum') + final ActivitySubModule? subModuleEnum; + + /// Epoch milliseconds. Null while the session is still open. + @override + @JsonKey(name: 'EndTime') + final int? endTime; + + /// Which app wrote the row. Local only — [moduleEnum] is what the backend + /// reads, and the two say much the same thing. + @override + @JsonKey(name: 'Source') + final ActivitySource source; + + /// Create a copy of ActivitySession + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$ActivitySessionCopyWith<_ActivitySession> get copyWith => + __$ActivitySessionCopyWithImpl<_ActivitySession>(this, _$identity); + + @override + Map toJson() { + return _$ActivitySessionToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _ActivitySession && + (identical(other.guid, guid) || other.guid == guid) && + (identical(other.name, name) || other.name == name) && + (identical(other.moduleEnum, moduleEnum) || + other.moduleEnum == moduleEnum) && + (identical(other.startTime, startTime) || + other.startTime == startTime) && + (identical(other.subModuleEnum, subModuleEnum) || + other.subModuleEnum == subModuleEnum) && + (identical(other.endTime, endTime) || other.endTime == endTime) && + (identical(other.source, source) || other.source == source)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, guid, name, moduleEnum, + startTime, subModuleEnum, endTime, source); + + @override + String toString() { + return 'ActivitySession(guid: $guid, name: $name, moduleEnum: $moduleEnum, startTime: $startTime, subModuleEnum: $subModuleEnum, endTime: $endTime, source: $source)'; + } +} + +/// @nodoc +abstract mixin class _$ActivitySessionCopyWith<$Res> + implements $ActivitySessionCopyWith<$Res> { + factory _$ActivitySessionCopyWith( + _ActivitySession value, $Res Function(_ActivitySession) _then) = + __$ActivitySessionCopyWithImpl; + @override + @useResult + $Res call( + {@JsonKey(name: 'Guid') String guid, + @JsonKey(name: 'Name') String name, + @JsonKey(name: 'ModuleEnum') ActivityModule moduleEnum, + @JsonKey(name: 'StartTime') int startTime, + @JsonKey(name: 'SubModuleEnum') ActivitySubModule? subModuleEnum, + @JsonKey(name: 'EndTime') int? endTime, + @JsonKey(name: 'Source') ActivitySource source}); +} + +/// @nodoc +class __$ActivitySessionCopyWithImpl<$Res> + implements _$ActivitySessionCopyWith<$Res> { + __$ActivitySessionCopyWithImpl(this._self, this._then); + + final _ActivitySession _self; + final $Res Function(_ActivitySession) _then; + + /// Create a copy of ActivitySession + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? guid = null, + Object? name = null, + Object? moduleEnum = null, + Object? startTime = null, + Object? subModuleEnum = freezed, + Object? endTime = freezed, + Object? source = null, + }) { + return _then(_ActivitySession( + guid: null == guid + ? _self.guid + : guid // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + moduleEnum: null == moduleEnum + ? _self.moduleEnum + : moduleEnum // ignore: cast_nullable_to_non_nullable + as ActivityModule, + startTime: null == startTime + ? _self.startTime + : startTime // ignore: cast_nullable_to_non_nullable + as int, + subModuleEnum: freezed == subModuleEnum + ? _self.subModuleEnum + : subModuleEnum // ignore: cast_nullable_to_non_nullable + as ActivitySubModule?, + endTime: freezed == endTime + ? _self.endTime + : endTime // ignore: cast_nullable_to_non_nullable + as int?, + source: null == source + ? _self.source + : source // ignore: cast_nullable_to_non_nullable + as ActivitySource, + )); + } +} + +// dart format on diff --git a/lib/src/activity/src/models/activity_session.g.dart b/lib/src/activity/src/models/activity_session.g.dart new file mode 100644 index 0000000..d51874b --- /dev/null +++ b/lib/src/activity/src/models/activity_session.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ActivitySession _$ActivitySessionFromJson(Map json) => + _ActivitySession( + guid: json['Guid'] as String, + name: json['Name'] as String, + moduleEnum: $enumDecode(_$ActivityModuleEnumMap, json['ModuleEnum']), + startTime: (json['StartTime'] as num).toInt(), + subModuleEnum: $enumDecodeNullable( + _$ActivitySubModuleEnumMap, json['SubModuleEnum']), + endTime: (json['EndTime'] as num?)?.toInt(), + source: $enumDecodeNullable(_$ActivitySourceEnumMap, json['Source']) ?? + ActivitySource.sfa, + ); + +Map _$ActivitySessionToJson(_ActivitySession instance) => + { + 'Guid': instance.guid, + 'Name': instance.name, + 'ModuleEnum': _$ActivityModuleEnumMap[instance.moduleEnum]!, + 'StartTime': instance.startTime, + 'SubModuleEnum': _$ActivitySubModuleEnumMap[instance.subModuleEnum], + 'EndTime': instance.endTime, + 'Source': _$ActivitySourceEnumMap[instance.source]!, + }; + +const _$ActivityModuleEnumMap = { + ActivityModule.generalTrade: 'generalTrade', + ActivityModule.faOne: 'faOne', + ActivityModule.embeddedGtFaOne: 'embeddedGtFaOne', +}; + +const _$ActivitySubModuleEnumMap = { + ActivitySubModule.gtPinPage: 'gtPinPage', + ActivitySubModule.faOneSocialTap: 'faOneSocialTap', + ActivitySubModule.faOneLearningTap: 'faOneLearningTap', + ActivitySubModule.faOneLeaderboardTap: 'faOneLeaderboardTap', + ActivitySubModule.faOneSfaTap: 'faOneSfaTap', + ActivitySubModule.faOneHomePageLand: 'faOneHomePageLand', + ActivitySubModule.faOneSocialSession: 'faOneSocialSession', + ActivitySubModule.faOneLearningSession: 'faOneLearningSession', + ActivitySubModule.embeddedGtFaOneSession: 'embeddedGtFaOneSession', +}; + +const _$ActivitySourceEnumMap = { + ActivitySource.sfa: 'SFA', + ActivitySource.faOne: 'FA_ONE', +}; diff --git a/lib/src/activity/src/models/activity_session_entity.dart b/lib/src/activity/src/models/activity_session_entity.dart new file mode 100644 index 0000000..b0243bd --- /dev/null +++ b/lib/src/activity/src/models/activity_session_entity.dart @@ -0,0 +1,68 @@ +import 'package:realm/realm.dart'; + +import 'activity_session.dart'; + +part 'activity_session_entity.realm.dart'; + +@RealmModel() +class _ActivitySessionEntity { + @PrimaryKey() + late String guid; + late String name; + + late int startTime; + + late String source; + + late String moduleEnum; + + late String? subModuleEnum; + + late int? endTime; +} + +extension ActivitySessionEntityX on ActivitySessionEntity { + ActivitySession toModel() => ActivitySession( + guid: guid, + name: name, + moduleEnum: _byName(ActivityModule.values, moduleEnum) ?? + ActivityModule.generalTrade, + startTime: startTime, + subModuleEnum: _byName(ActivitySubModule.values, subModuleEnum), + endTime: endTime, + source: _sourceFromStorage(source), + ); + + static ActivitySessionEntity fromModel(ActivitySession session) => + ActivitySessionEntity( + session.guid, + session.name, + session.startTime, + _sourceToStorage(session.source), + session.moduleEnum.name, + subModuleEnum: session.subModuleEnum?.name, + endTime: session.endTime, + ); + + static String _sourceToStorage(ActivitySource source) => switch (source) { + ActivitySource.sfa => 'SFA', + ActivitySource.faOne => 'FA_ONE', + }; + + static ActivitySource _sourceFromStorage(String value) => switch (value) { + 'FA_ONE' => ActivitySource.faOne, + _ => ActivitySource.sfa, + }; + + static T? _byName(List values, String? name) { + if (name == null) { + return null; + } + for (final value in values) { + if (value.name == name) { + return value; + } + } + return null; + } +} diff --git a/lib/src/activity/src/models/activity_session_entity.realm.dart b/lib/src/activity/src/models/activity_session_entity.realm.dart new file mode 100644 index 0000000..566bc45 --- /dev/null +++ b/lib/src/activity/src/models/activity_session_entity.realm.dart @@ -0,0 +1,139 @@ +// dart format width=80 +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity_session_entity.dart'; + +// ************************************************************************** +// RealmObjectGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +class ActivitySessionEntity extends _ActivitySessionEntity + with RealmEntity, RealmObjectBase, RealmObject { + ActivitySessionEntity( + String guid, + String name, + int startTime, + String source, + String moduleEnum, { + String? subModuleEnum, + int? endTime, + }) { + RealmObjectBase.set(this, 'guid', guid); + RealmObjectBase.set(this, 'name', name); + RealmObjectBase.set(this, 'startTime', startTime); + RealmObjectBase.set(this, 'source', source); + RealmObjectBase.set(this, 'moduleEnum', moduleEnum); + RealmObjectBase.set(this, 'subModuleEnum', subModuleEnum); + RealmObjectBase.set(this, 'endTime', endTime); + } + + ActivitySessionEntity._(); + + @override + String get guid => RealmObjectBase.get(this, 'guid') as String; + @override + set guid(String value) => RealmObjectBase.set(this, 'guid', value); + + @override + String get name => RealmObjectBase.get(this, 'name') as String; + @override + set name(String value) => RealmObjectBase.set(this, 'name', value); + + @override + int get startTime => RealmObjectBase.get(this, 'startTime') as int; + @override + set startTime(int value) => RealmObjectBase.set(this, 'startTime', value); + + @override + String get source => RealmObjectBase.get(this, 'source') as String; + @override + set source(String value) => RealmObjectBase.set(this, 'source', value); + + @override + String get moduleEnum => + RealmObjectBase.get(this, 'moduleEnum') as String; + @override + set moduleEnum(String value) => + RealmObjectBase.set(this, 'moduleEnum', value); + + @override + String? get subModuleEnum => + RealmObjectBase.get(this, 'subModuleEnum') as String?; + @override + set subModuleEnum(String? value) => + RealmObjectBase.set(this, 'subModuleEnum', value); + + @override + int? get endTime => RealmObjectBase.get(this, 'endTime') as int?; + @override + set endTime(int? value) => RealmObjectBase.set(this, 'endTime', value); + + @override + Stream> get changes => + RealmObjectBase.getChanges(this); + + @override + Stream> changesFor( + [List? keyPaths]) => + RealmObjectBase.getChangesFor(this, keyPaths); + + @override + ActivitySessionEntity freeze() => + RealmObjectBase.freezeObject(this); + + EJsonValue toEJson() { + return { + 'guid': guid.toEJson(), + 'name': name.toEJson(), + 'startTime': startTime.toEJson(), + 'source': source.toEJson(), + 'moduleEnum': moduleEnum.toEJson(), + 'subModuleEnum': subModuleEnum.toEJson(), + 'endTime': endTime.toEJson(), + }; + } + + static EJsonValue _toEJson(ActivitySessionEntity value) => value.toEJson(); + static ActivitySessionEntity _fromEJson(EJsonValue ejson) { + if (ejson is! Map) return raiseInvalidEJson(ejson); + return switch (ejson) { + { + 'guid': EJsonValue guid, + 'name': EJsonValue name, + 'startTime': EJsonValue startTime, + 'source': EJsonValue source, + 'moduleEnum': EJsonValue moduleEnum, + } => + ActivitySessionEntity( + fromEJson(guid), + fromEJson(name), + fromEJson(startTime), + fromEJson(source), + fromEJson(moduleEnum), + subModuleEnum: fromEJson(ejson['subModuleEnum']), + endTime: fromEJson(ejson['endTime']), + ), + _ => raiseInvalidEJson(ejson), + }; + } + + static final schema = () { + RealmObjectBase.registerFactory(ActivitySessionEntity._); + register(_toEJson, _fromEJson); + return const SchemaObject(ObjectType.realmObject, ActivitySessionEntity, + 'ActivitySessionEntity', [ + SchemaProperty('guid', RealmPropertyType.string, primaryKey: true), + SchemaProperty('name', RealmPropertyType.string), + SchemaProperty('startTime', RealmPropertyType.int), + SchemaProperty('source', RealmPropertyType.string), + SchemaProperty('moduleEnum', RealmPropertyType.string), + SchemaProperty('subModuleEnum', RealmPropertyType.string, optional: true), + SchemaProperty('endTime', RealmPropertyType.int, optional: true), + ]); + }(); + + @override + SchemaObject get objectSchema => RealmObjectBase.getSchema(this) ?? schema; +} diff --git a/pubspec.lock b/pubspec.lock index a8b06b1..a7d97b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "85.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "7.7.1" ansicolor: dependency: transitive description: @@ -77,18 +77,26 @@ packages: dependency: transitive description: name: build - sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "2.4.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.1.2" build_daemon: dependency: transitive description: @@ -97,14 +105,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + url: "https://pub.dev" + source: hosted + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "04f69b1502f66e22ae7990bbd01eb552b7f12793c4d3ea6e715d0ac5e98bcdac" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "2.10.2" + version: "8.0.0" built_collection: dependency: transitive description: @@ -185,14 +209,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.5" + cancellation_token: + dependency: transitive + description: + name: cancellation_token + sha256: ad95acf9d4b2f3563e25dc937f63587e46a70ce534e910b65d10e115490f1027 + url: "https://pub.dev" + source: hosted + version: "2.0.1" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -277,10 +309,10 @@ packages: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.1" dartx: dependency: "direct main" description: @@ -321,6 +353,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + ejson: + dependency: transitive + description: + name: ejson + sha256: "5856e32943e476d67304731dedcd2a4b2363f2d14442176e2a151039605c4633" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + ejson_annotation: + dependency: transitive + description: + name: ejson_annotation + sha256: "966c38370f21a0b5ed296a33293c2592a7d14e4b320cc1a5de49d6c55606cc2f" + url: "https://pub.dev" + source: hosted + version: "0.4.1" equatable: dependency: "direct main" description: @@ -654,10 +702,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77" + sha256: "6022db4c7bfa626841b2a10f34dd1e1b68e8f8f9650db6112dcdeeca45ca793c" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.0.6" freezed_annotation: dependency: "direct main" description: @@ -666,6 +714,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" geoclue: dependency: transitive description: @@ -978,6 +1034,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -990,10 +1054,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.11.1" + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -1046,26 +1110,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1090,6 +1154,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + objectid: + dependency: transitive + description: + name: objectid + sha256: f6cea8360862c78b1984cc8f497e484aa7f491282f7f4e4bd2f722bc85f93237 + url: "https://pub.dev" + source: hosted + version: "4.0.3" octo_image: dependency: transitive description: @@ -1314,6 +1386,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" + realm: + dependency: "direct main" + description: + name: realm + sha256: "475b9635c781fe10b898447e86cf979e36966caefd1dd16efd53a3f03bbe30a6" + url: "https://pub.dev" + source: hosted + version: "20.2.0" + realm_common: + dependency: transitive + description: + name: realm_common + sha256: efa7ddac1c31c5abfb4d43f74376ffacfc3f6193a83847969955e2dc4d7908b7 + url: "https://pub.dev" + source: hosted + version: "20.2.0" + realm_dart: + dependency: transitive + description: + name: realm_dart + sha256: "6657fda24dbde1ba749730063c9152330d155536866e73056950d481ff0bed5c" + url: "https://pub.dev" + source: hosted + version: "20.2.0" + realm_generator: + dependency: transitive + description: + name: realm_generator + sha256: c2c90ad42e90865ed3becb864966c90d57384cb8d489135747548e1837a0dfc9 + url: "https://pub.dev" + source: hosted + version: "20.2.0" recase: dependency: "direct main" description: @@ -1330,6 +1434,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + sane_uuid: + dependency: transitive + description: + name: sane_uuid + sha256: "5b3fd581f51d01aef0526344c175e198d04ff701249c1a75186429dbb4367265" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sanitize_html: dependency: transitive description: @@ -1467,18 +1579,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243" + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "2.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.7" source_span: dependency: transitive description: @@ -1567,6 +1679,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + tar: + dependency: transitive + description: + name: tar + sha256: b338bacfd24dae6cf527acb4242003a71fc88ce183a9002376fabbc4ebda30c9 + url: "https://pub.dev" + source: hosted + version: "2.0.2" term_glyph: dependency: transitive description: @@ -1579,10 +1699,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" time: dependency: transitive description: @@ -1591,6 +1711,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.5" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + type_plus: + dependency: transitive + description: + name: type_plus + sha256: d5d1019471f0d38b91603adb9b5fd4ce7ab903c879d2fbf1a3f80a630a03fcc9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3a1392b..78cd9f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,15 +69,19 @@ dependencies: provider: ^6.1.5 flutter_image_compress: ^2.4.0 permission_handler: ^12.0.1 + realm: ^20.2.0 dev_dependencies: flutter_test: sdk: flutter # Model Utilities - build_runner: ^2.10.2 - freezed: ^3.2.3 - json_serializable: ^6.9.5 + # Upper bounds are left to pub on purpose: realm_generator pins + # `source_gen ^2.0.0`, so freezed/json_serializable have to resolve to + # versions that agree with it. Raising these floors will break `pub get`. + build_runner: ^2.4.15 + freezed: ^3.0.6 + json_serializable: ^6.9.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec