Compare commits

...

6 Commits

Author SHA1 Message Date
b12a04ba30 feat(monetization-261): prepare entitlement infrastructure and quota enforcement for release
- Add EntitlementSnapshot, EntitlementRevalidationUseCase, and BillingUseCases
- Add DriftEntitlementSnapshotRepository for offline entitlement caching
- Add HealthConnect gateway and integration
- Add quota enforcement in share acceptance (server and UI)
- Add entitlement API endpoints in server
- Add privacy policy v1 and release compliance checklist
- Add QA gates and device runbooks for release validation
- Add demo content screen and settings screen

Ticket #261
2026-08-28 23:12:25 +02:00
d4df3822b7 docs(qa-261): ajoute documents conformité et scripts QA pour release Android
- Ajoute privacy-policy-v1.md et release-compliance-checklist.md
- Ajoute documents QA gates #238-#243
- Ajoute scripts de validation repo-only et device runbooks
- Prépare checklists exécutables avant upload Play fermé

Ticket #261
2026-08-28 23:11:16 +02:00
313e755895 feat(release-261): bump version et mise à jour icônes iOS/watch
- Bump version phone: 0.1.0+1 → 0.1.1+2
- Bump version watch: 0.1.0+1 → 0.1.1+301
- Renommage bundle identifier iOS: com.gametime.app → com.idea.gametime
- Mise à jour icônes iOS AppIcon (toutes résolutions)
- Mise à jour icônes watch launcher et ongoing_gt
- Package watch Android: com.gametime.app → com.idea.gametime
- Ajout android.useAndroidX=true pour watch app
2026-08-28 23:09:20 +02:00
442e04bbbb docs(qa-261): document QA pour release Android closed test
Checklist exécutable de validation QA avant premier upload Play fermé
2026-08-28 23:06:54 +02:00
ee1f0e1119 feat(android): mise à jour icônes et logos pour release #261
- Mise à jour icônes launcher Android (toutes densités)
- Mise à jour icônes notification ongoing_gt
- Mise à jour assets source icons
- Pour phone et watch
2026-08-28 23:06:50 +02:00
2eda267346 feat(android): renommage package com.gametime.app → com.idea.gametime
- Mise à jour namespace et applicationId dans build.gradle.kts
- Déplacement des fichiers MainActivity.kt, session/, watch/ vers com/idea/gametime/
- Ajout garde-fou usesCleartextTraffic uniquement en debug/profile
- Nettoyage .gitignore pour exclure .gradle/ au niveau racine

Préparation ticket #261 release Android closed test
2026-08-28 23:06:36 +02:00
89 changed files with 3616 additions and 93 deletions

1
.gitignore vendored
View File

@ -41,6 +41,7 @@ app.*.map.json
/android/app/profile /android/app/profile
/android/app/release /android/app/release
android/.gradle/ android/.gradle/
/.gradle/
android/local.properties android/local.properties
android/**/gradle-wrapper.jar android/**/gradle-wrapper.jar
android/key.properties android/key.properties

View File

@ -28,7 +28,7 @@ check(sharedSigningStoreFile.isFile) {
} }
android { android {
namespace = "com.gametime.app" namespace = "com.idea.gametime"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@ -38,8 +38,8 @@ android {
} }
defaultConfig { defaultConfig {
applicationId = "com.gametime.app" applicationId = "com.idea.gametime"
minSdk = flutter.minSdkVersion minSdk = 26
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
@ -72,5 +72,7 @@ flutter {
} }
dependencies { dependencies {
implementation("androidx.health.connect:connect-client:1.1.0")
implementation("com.google.android.gms:play-services-wearable:19.0.0") implementation("com.google.android.gms:play-services-wearable:19.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
} }

View File

@ -4,4 +4,6 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application android:usesCleartextTraffic="true"/>
</manifest> </manifest>

View File

@ -6,12 +6,15 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
<uses-permission android:name="android.permission.health.WRITE_DISTANCE"/>
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<application <application
android:label="GameTime" android:label="GameTime"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher">
android:usesCleartextTraffic="true">
<meta-data <meta-data
android:name="com.google.android.wearable.capabilities" android:name="com.google.android.wearable.capabilities"
android:resource="@array/android_wear_capabilities" /> android:resource="@array/android_wear_capabilities" />
@ -37,6 +40,23 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".health.HealthConnectRationaleActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".health.HealthConnectRationaleActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
@ -73,6 +93,10 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>

View File

@ -1,14 +0,0 @@
package com.gametime.app
import com.gametime.app.session.SessionNotificationPlugin
import com.gametime.app.watch.WatchBridgePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
WatchBridgePlugin.register(flutterEngine, applicationContext)
SessionNotificationPlugin.register(flutterEngine, this)
}
}

View File

@ -0,0 +1,22 @@
package com.idea.gametime
import android.os.Bundle
import com.idea.gametime.health.HealthConnectPlugin
import com.idea.gametime.session.SessionNotificationPlugin
import com.idea.gametime.watch.WatchBridgePlugin
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
HealthConnectPlugin.prepare(this)
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
WatchBridgePlugin.register(flutterEngine, applicationContext)
SessionNotificationPlugin.register(flutterEngine, this)
HealthConnectPlugin.register(flutterEngine, this)
}
}

View File

@ -0,0 +1,470 @@
package com.idea.gametime.health
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
import androidx.lifecycle.Lifecycle
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Energy
import androidx.health.connect.client.units.Length
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.lang.ref.WeakReference
import java.time.Instant
import java.time.ZoneOffset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
object HealthConnectPlugin {
private const val METHOD_CHANNEL = "gametime.health_connect/methods"
private const val LOG_TAG = "GTHealthConnect"
private const val HEALTH_CONNECT_PROVIDER_PACKAGE = "com.google.android.apps.healthdata"
private const val PLAY_STORE_PACKAGE = "com.android.vending"
private const val FALLBACK_OPEN_DELAY_MS = 1200L
private val requiredPermissions =
setOf(
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.getWritePermission(DistanceRecord::class),
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(HeartRateRecord::class),
)
private var appContext: Context? = null
private var activityRef: WeakReference<Activity>? = null
private var permissionRequestLauncher: ActivityResultLauncher<Set<String>>? = null
private var pendingPermissionResult: MethodChannel.Result? = null
private var fallbackOpenRunnable: Runnable? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val mainScope = CoroutineScope(Dispatchers.Main)
fun prepare(activity: Activity) {
appContext = activity.applicationContext
activityRef = WeakReference(activity)
registerPermissionRequestLauncher(activity)
}
fun register(flutterEngine: FlutterEngine, activity: Activity) {
appContext = activity.applicationContext
activityRef = WeakReference(activity)
if (permissionRequestLauncher == null) {
registerPermissionRequestLauncher(activity)
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(::handleMethodCall)
}
private fun registerPermissionRequestLauncher(activity: Activity) {
Log.d(LOG_TAG, "registerPermissionRequestLauncher(activity=${activity::class.java.name})")
val componentActivity = activity as? ComponentActivity
if (componentActivity == null) {
Log.e(LOG_TAG, "Activity is not a ComponentActivity; permission launcher unavailable.")
return
}
try {
val context = activity.applicationContext
permissionRequestLauncher?.unregister()
permissionRequestLauncher =
componentActivity.activityResultRegistry.register(
"gametime_health_connect_permissions",
PermissionController.createRequestPermissionResultContract(),
) { grantedPermissions ->
Log.d(
LOG_TAG,
"permission request callback(granted=${grantedPermissions.size})",
)
cancelFallbackOpen()
val result = pendingPermissionResult ?: return@register
pendingPermissionResult = null
mainScope.launch {
val state = try {
connectionStateMap(
context,
grantedPermissions,
permissionRequestOutcome =
if (grantedPermissions.containsAll(requiredPermissions)) {
"granted"
} else {
"denied"
},
)
} catch (error: Throwable) {
Log.e(LOG_TAG, "connection state failed after callback.", error)
result.error("health_connect_state_failed", error.message, null)
return@launch
}
result.success(state)
}
}
Log.d(LOG_TAG, "permission launcher registered.")
} catch (error: Throwable) {
Log.e(LOG_TAG, "permission launcher registration failed.", error)
permissionRequestLauncher = null
}
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
val context = appContext
if (context == null) {
result.error("health_connect_unavailable", "Application context unavailable.", null)
return
}
when (call.method) {
"connectionState" -> connectionState(context, result)
"requestPermissions" -> requestPermissions(context, result)
"openSettings" -> openSettings(context, result)
"exportWorkout" -> exportWorkout(context, call.arguments, result)
else -> result.notImplemented()
}
}
private fun connectionState(context: Context, result: MethodChannel.Result) {
mainScope.launch {
try {
result.success(connectionStateMap(context))
} catch (error: Throwable) {
result.error("health_connect_state_failed", error.message, null)
}
}
}
private suspend fun connectionStateMap(
context: Context,
grantedOverride: Set<String>? = null,
permissionRequestOutcome: String? = null,
): Map<String, Any> {
var availability = availability(context)
val grantedPermissions = if (availability == "available") {
try {
grantedOverride ?: HealthConnectClient.getOrCreate(context)
.permissionController
.getGrantedPermissions()
} catch (_: SecurityException) {
availability = "accessBlocked"
emptySet()
} catch (_: IllegalStateException) {
availability = "accessBlocked"
emptySet()
}
} else {
emptySet()
}
val state = mutableMapOf<String, Any>(
"availability" to availability,
"requiredPermissions" to requiredPermissions.toList(),
"grantedPermissions" to grantedPermissions.toList(),
)
if (permissionRequestOutcome != null) {
state["permissionRequestOutcome"] = permissionRequestOutcome
}
return state
}
private fun availability(context: Context): String {
return when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> "available"
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED ->
"providerUpdateRequired"
else -> "unavailable"
}
}
private fun requestPermissions(context: Context, result: MethodChannel.Result) {
val availability = availability(context)
Log.d(LOG_TAG, "requestPermissions availability=$availability")
if (availability != "available") {
mainScope.launch {
result.success(connectionStateMap(context))
}
return
}
val activity = activityRef?.get()
if (activity == null) {
Log.e(LOG_TAG, "requestPermissions failed: activity unavailable.")
result.error("health_connect_unavailable", "Activity unavailable.", null)
return
}
if (pendingPermissionResult != null) {
Log.e(LOG_TAG, "requestPermissions failed: request already active.")
result.error(
"health_connect_request_in_progress",
"Permission request already active.",
null,
)
return
}
pendingPermissionResult = result
val launcher = permissionRequestLauncher
if (launcher == null) {
Log.e(LOG_TAG, "requestPermissions fallback: launcher unavailable.")
openSettingsFallback(context, activity, "settingsFallback")
return
}
try {
Log.d(LOG_TAG, "launching Health Connect permission contract.")
launcher.launch(requiredPermissions)
Log.d(LOG_TAG, "Health Connect permission contract launch returned.")
scheduleFallbackOpen(context, activity)
} catch (error: Throwable) {
Log.e(LOG_TAG, "Health Connect permission contract launch failed.", error)
openSettingsFallback(context, activity, "launchFailed")
}
}
private fun scheduleFallbackOpen(context: Context, activity: Activity) {
cancelFallbackOpen()
val runnable = Runnable {
if (pendingPermissionResult == null) {
return@Runnable
}
val componentActivity = activity as? ComponentActivity
if (componentActivity != null &&
!componentActivity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
) {
Log.d(LOG_TAG, "permission UI appears to have focus; fallback not opened.")
return@Runnable
}
Log.d(LOG_TAG, "permission UI did not take focus; opening Health Connect settings fallback.")
openSettingsFallback(context, activity, "settingsFallback")
}
fallbackOpenRunnable = runnable
mainHandler.postDelayed(runnable, FALLBACK_OPEN_DELAY_MS)
}
private fun cancelFallbackOpen() {
fallbackOpenRunnable?.let(mainHandler::removeCallbacks)
fallbackOpenRunnable = null
}
private fun openSettingsFallback(context: Context, activity: Activity, outcome: String) {
cancelFallbackOpen()
val pendingResult = pendingPermissionResult
pendingPermissionResult = null
try {
activity.startActivity(settingsIntent())
Log.d(LOG_TAG, "Health Connect settings fallback opened(outcome=$outcome).")
mainScope.launch {
try {
pendingResult?.success(
connectionStateMap(
context,
permissionRequestOutcome = outcome,
),
)
} catch (stateError: Throwable) {
Log.e(LOG_TAG, "connection state failed after settings fallback.", stateError)
pendingResult?.error("health_connect_state_failed", stateError.message, null)
}
}
} catch (error: Throwable) {
Log.e(LOG_TAG, "Health Connect settings fallback failed.", error)
pendingResult?.error("health_connect_permission_request_failed", error.message, null)
}
}
private fun openSettings(context: Context, result: MethodChannel.Result) {
val activity = activityRef?.get()
if (activity == null) {
result.success(false)
return
}
val intents =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
listOf(settingsIntent())
} else if (availability(context) == "providerUpdateRequired") {
listOf(providerUpdateIntent(context), settingsIntent())
} else {
listOf(settingsIntent())
}
val opened = intents.any { intent ->
try {
Log.d(LOG_TAG, "openSettings launching ${intent.action}.")
activity.startActivity(intent)
Log.d(LOG_TAG, "openSettings launch ok.")
true
} catch (error: ActivityNotFoundException) {
Log.e(LOG_TAG, "openSettings activity not found.", error)
false
} catch (error: SecurityException) {
Log.e(LOG_TAG, "openSettings security failure.", error)
false
}
}
result.success(opened)
}
private fun providerUpdateIntent(context: Context): Intent {
val uri =
"market://details?id=$HEALTH_CONNECT_PROVIDER_PACKAGE&url=healthconnect%3A%2F%2Fonboarding"
return Intent(Intent.ACTION_VIEW).apply {
setPackage(PLAY_STORE_PACKAGE)
data = Uri.parse(uri)
putExtra("overlay", true)
putExtra("callerId", context.packageName)
}
}
private fun settingsIntent(): Intent =
Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS)
private fun exportWorkout(
context: Context,
arguments: Any?,
result: MethodChannel.Result,
) {
val map = arguments as? Map<*, *>
if (map == null) {
result.error("invalid_health_connect_workout", "Workout payload must be a map.", null)
return
}
mainScope.launch {
try {
if (availability(context) != "available") {
result.error("health_connect_unavailable", "Health Connect is unavailable.", null)
return@launch
}
val grantedPermissions =
HealthConnectClient.getOrCreate(context)
.permissionController
.getGrantedPermissions()
if (!grantedPermissions.containsAll(requiredPermissions)) {
result.error(
"health_connect_permissions_required",
"Health Connect write permissions are not granted.",
null,
)
return@launch
}
val records = recordsFromWorkoutPayload(map)
HealthConnectClient.getOrCreate(context).insertRecords(records)
result.success(null)
} catch (error: Throwable) {
result.error("health_connect_export_failed", error.message, null)
}
}
}
private fun recordsFromWorkoutPayload(map: Map<*, *>): List<Record> {
val historyId = nonBlankString(map["historyId"], "historyId")
val title = map["title"] as? String ?: "GameTime Basketball"
val start = epochMs(map["startedAtEpochMs"], "startedAtEpochMs")
val end = epochMs(map["endedAtEpochMs"], "endedAtEpochMs")
if (!end.isAfter(start)) {
throw IllegalArgumentException("Workout end time must be after start time.")
}
val records = mutableListOf<Record>(
ExerciseSessionRecord(
startTime = start,
startZoneOffset = ZoneOffset.UTC,
endTime = end,
endZoneOffset = ZoneOffset.UTC,
exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL,
title = title,
notes = "Exported from GameTime.",
metadata = Metadata.manualEntry(
clientRecordId = clientRecordId(historyId, "exercise-session"),
),
),
)
val distanceMeters = positiveDouble(map["totalDistanceMeters"])
if (distanceMeters != null) {
records += DistanceRecord(
startTime = start,
startZoneOffset = ZoneOffset.UTC,
endTime = end,
endZoneOffset = ZoneOffset.UTC,
distance = Length.meters(distanceMeters),
metadata = Metadata.manualEntry(
clientRecordId = clientRecordId(historyId, "distance"),
),
)
}
val caloriesKcal = positiveDouble(map["totalCaloriesKcal"])
if (caloriesKcal != null) {
records += TotalCaloriesBurnedRecord(
startTime = start,
startZoneOffset = ZoneOffset.UTC,
endTime = end,
endZoneOffset = ZoneOffset.UTC,
energy = Energy.kilocalories(caloriesKcal),
metadata = Metadata.manualEntry(
clientRecordId = clientRecordId(historyId, "calories"),
),
)
}
val heartRateSamples = heartRateSamples(map["heartRateSamples"], start, end)
if (heartRateSamples.isNotEmpty()) {
records += HeartRateRecord(
startTime = start,
startZoneOffset = ZoneOffset.UTC,
endTime = end,
endZoneOffset = ZoneOffset.UTC,
samples = heartRateSamples,
metadata = Metadata.manualEntry(
clientRecordId = clientRecordId(historyId, "heart-rate"),
),
)
}
return records
}
private fun nonBlankString(value: Any?, fieldName: String): String {
val text = (value as? String)?.trim()
if (text.isNullOrEmpty()) {
throw IllegalArgumentException("$fieldName must be a non-blank string.")
}
return text
}
private fun clientRecordId(historyId: String, kind: String): String =
"gametime:$historyId:$kind"
private fun heartRateSamples(
payload: Any?,
start: Instant,
end: Instant,
): List<HeartRateRecord.Sample> {
val items = payload as? List<*> ?: return emptyList()
return items.mapNotNull { item ->
val map = item as? Map<*, *> ?: return@mapNotNull null
val time =
(map["timeEpochMs"] as? Number)?.toLong()?.let(Instant::ofEpochMilli)
?: return@mapNotNull null
val beatsPerMinute = (map["beatsPerMinute"] as? Number)?.toLong()
?: return@mapNotNull null
if (beatsPerMinute <= 0 || time.isBefore(start) || time.isAfter(end)) {
return@mapNotNull null
}
HeartRateRecord.Sample(time = time, beatsPerMinute = beatsPerMinute)
}
}
private fun epochMs(value: Any?, fieldName: String): Instant {
val epochMs = (value as? Number)?.toLong()
?: throw IllegalArgumentException("$fieldName must be an epoch millisecond number.")
return Instant.ofEpochMilli(epochMs)
}
private fun positiveDouble(value: Any?): Double? {
val number = (value as? Number)?.toDouble() ?: return null
return if (number > 0.0) number else null
}
}

View File

@ -0,0 +1,40 @@
package com.idea.gametime.health
import android.app.Activity
import android.os.Bundle
import android.view.ViewGroup
import android.widget.ScrollView
import android.widget.TextView
class HealthConnectRationaleActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
title = "Health Connect"
val textView =
TextView(this).apply {
text =
"""
GameTime utilise Health Connect pour enregistrer les donnees compatibles de vos seances de basketball.
Les donnees envoyees peuvent inclure la seance d'exercice, la distance, les calories et les mesures de frequence cardiaque disponibles.
GameTime n'utilise ces donnees que pour le suivi sportif affiche dans l'application et dans Health Connect. Vous pouvez modifier ou retirer ces permissions a tout moment depuis Health Connect.
""".trimIndent()
textSize = 16f
setPadding(48, 48, 48, 48)
}
val scrollView =
ScrollView(this).apply {
addView(
textView,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
),
)
}
setContentView(scrollView)
}
}

View File

@ -1,4 +1,4 @@
package com.gametime.app.session package com.idea.gametime.session
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity

View File

@ -1,4 +1,4 @@
package com.gametime.app.session package com.idea.gametime.session
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@ -8,8 +8,8 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import com.gametime.app.MainActivity import com.idea.gametime.MainActivity
import com.gametime.app.R import com.idea.gametime.R
class SessionStatusForegroundService : Service() { class SessionStatusForegroundService : Service() {
override fun onCreate() { override fun onCreate() {
@ -62,7 +62,7 @@ class SessionStatusForegroundService : Service() {
Notification.BigTextStyle().bigText("$primaryLine\n$it") Notification.BigTextStyle().bigText("$primaryLine\n$it")
} }
return builder return builder
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.drawable.ic_ongoing_gt)
.setContentTitle(title) .setContentTitle(title)
.setContentText(primaryLine) .setContentText(primaryLine)
.setSubText(secondaryLine) .setSubText(secondaryLine)
@ -89,7 +89,7 @@ class SessionStatusForegroundService : Service() {
} }
companion object { companion object {
const val ACTION_SHOW = "com.gametime.app.session.SHOW" const val ACTION_SHOW = "com.idea.gametime.session.SHOW"
const val EXTRA_TITLE = "title" const val EXTRA_TITLE = "title"
const val EXTRA_PRIMARY_LINE = "primaryLine" const val EXTRA_PRIMARY_LINE = "primaryLine"
const val EXTRA_SECONDARY_LINE = "secondaryLine" const val EXTRA_SECONDARY_LINE = "secondaryLine"

View File

@ -1,4 +1,4 @@
package com.gametime.app.watch package com.idea.gametime.watch
import com.google.android.gms.wearable.CapabilityInfo import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.MessageEvent

View File

@ -1,4 +1,4 @@
package com.gametime.app.watch package com.idea.gametime.watch
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -214,32 +214,7 @@ object WatchBridgePlugin {
sendMessage(context, targetNode, ACK_PATH, payload, result) sendMessage(context, targetNode, ACK_PATH, payload, result)
return return
} }
Wearable.getCapabilityClient(context)
.getCapability(WATCH_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
val nodes = capability.nodes.toList()
if (nodes.isEmpty()) {
result.success(null) result.success(null)
return@addOnSuccessListener
}
var remaining = nodes.size
var failed = false
for (node in nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, ACK_PATH, payload)
.addOnSuccessListener {
remaining -= 1
if (remaining == 0 && !failed) result.success(null)
}
.addOnFailureListener { error ->
failed = true
result.error("send_ack_failed", error.message, null)
}
}
}
.addOnFailureListener { error ->
result.error("capability_lookup_failed", error.message, null)
}
} }
private fun sendMessage( private fun sendMessage(

View File

@ -1,4 +1,4 @@
package com.gametime.app.watch package com.idea.gametime.watch
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@ -7,7 +7,7 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import com.gametime.app.R import com.idea.gametime.R
class WatchCompanionForegroundService : Service() { class WatchCompanionForegroundService : Service() {
override fun onCreate() { override fun onCreate() {
@ -30,7 +30,7 @@ class WatchCompanionForegroundService : Service() {
Notification.Builder(this) Notification.Builder(this)
} }
return builder return builder
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.drawable.ic_ongoing_gt)
.setContentTitle("GameTime") .setContentTitle("GameTime")
.setContentText("Séance en cours") .setContentText("Séance en cours")
.setOngoing(true) .setOngoing(true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillType="evenOdd"
android:fillColor="#FFFFFFFF"
android:pathData="M12,2a10,10 0,1 0,0.1 0zM12,5a7,7 0,1 1,0 14a7,7 0,0 1,0 -14z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8.8,6.3h1.6l-0.2,5.5l3.4,2.2l-0.8,1.3L9.7,13.4V6.3z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5.8,10.1h4.7v1.6H7.5v1.8h2.7v1.6H5.8zM12.9,7.1h5.4v1.7h-1.8v6.7h-1.8V8.8h-1.8z" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -4,4 +4,6 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application android:usesCleartextTraffic="true"/>
</manifest> </manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 695 KiB

View File

@ -0,0 +1,91 @@
# GameTime Privacy Policy
Version: 1.0
Effective date: 2026-08-22
Status: repo reference draft for release preparation
## Scope
This policy covers the GameTime mobile app, including the Android phone app,
the optional Wear OS companion, account features, sharing, synchronization,
purchase entitlement checks, and optional Health Connect integration.
## Data We Handle
GameTime is designed as an offline-first workout tracker. Depending on the
features used, the app may handle:
- workout library content created by the user, including exercises, programs,
workout templates, timers, scores, repetitions, session history, and local
media selected for exercises
- account identifiers such as email address and authentication tokens when the
user creates an account or signs in
- shared workout resources and recipient email addresses when the sharing
feature is used
- purchase entitlement status needed to determine whether the account is on the
Free or Pro plan
- optional workout telemetry such as heart rate, distance, calories, and
exercise records when the user explicitly enables Health Connect integration
- optional live workout state and commands exchanged with the Wear OS companion
## Free and Pro Features
Sharing remains free. The Free plan limits the editable workout library, not
basic workout execution. Imported or shared resources count toward the
receiving account's quota when accepted. Pro provides unlimited library capacity
and synchronization.
The Wear OS companion and Health Connect integration are free features and are
not paywalled.
## Local Storage and Offline Use
Workout data is stored locally on the device first. The app is intended to keep
working without network access, including before any server synchronization has
been configured.
## Synchronization and Sharing
When synchronization is enabled, workout library and account data may be sent to
the GameTime server so the account can restore or synchronize data. When sharing
is used, the selected resources and recipient identifiers are sent to the server
so recipients can receive, accept, decline, or revoke shares.
## Health Connect
Health Connect access is optional. GameTime requests only the health permissions
needed for the enabled workout features. Health data is used to support workout
tracking and export flows shown in the app. The user can revoke Health Connect
permissions from Android settings.
## Wear OS Companion
The Wear OS companion exchanges live session state, commands, and available
metrics with the phone app. The companion is optional and depends on the Android
Wear Data Layer between paired devices.
## Purchases and Entitlements
Purchase and entitlement data is used to unlock Pro functionality such as
unlimited library capacity and synchronization. GameTime does not use purchase
state to restrict sharing, the Wear OS companion, Health Connect, or basic
workout execution.
## Data Retention and Deletion
Local workout data remains on the device until the user deletes it or removes
the app. Server-side account, synchronization, sharing, and entitlement data
requires an operational account management process before public release.
## Security
Release Android builds must use HTTPS for remote GameTime server traffic.
Cleartext HTTP is permitted only for local debug/profile development builds.
Authentication tokens are treated as sensitive credentials and should be stored
through the platform secure storage adapter.
## Contact
Before public distribution, replace this section with the legal support contact
and the public URL that will be submitted to the Play Console.

View File

@ -0,0 +1,629 @@
# Ticket #238 — Cadrage QA runnable des features principales
## Objectif
Ce document sert de base opérationnelle au nouveau process TDD GameTime.
Il fixe :
- l'ordre recommandé de couverture des features majeures
- les suites attendues par niveau de test
- la stratégie d'implémentation runnable
- les commandes cibles à exécuter
- le backlog initial d'automatisation prioritaire
Le principe directeur est simple : verrouiller d'abord les parcours qui portent la valeur produit et les régressions les plus coûteuses, avant d'étendre la couverture device et infra.
## Preuves réelles ajoutées le 22 août 2026
Preuves exécutées sur `/tmp/gametime-src-1787384228` :
- gate `#239` verte
- gate `#240` verte
- smoke `#241` vert
- gate `#242` verte
- `dart test` serveur vert
Conséquence :
- le socle QA Flutter transverse est désormais prouvé exécutable
- le coeur offline Drift est prouvé exécutable
- la gate monétisation actuellement cadrée est prouvée exécutable
- la validation device reste distincte et passe par `#243`
## Constat repo au 21 août 2026
Le dépôt contient déjà une base exploitable :
- tests Flutter domaine/application/infrastructure dans `test/application`, `test/infrastructure`, `test/domain_import_boundaries_test.dart`
- nombreux tests widget écran par écran dans `test/presentation`
- premier harnais fonctionnel mémoire dans `test/support/functional_harness.dart`
- première suite fonctionnelle transverse dans `test/functional/functional_coverage_test.dart`
- suite montre dédiée dans `watch_app/test/presentation`
- suite serveur Dart dans `server/test`
- checklist d'intégration réelle infra dans `server/docs/integration-checklist.md`
Ce qui manque pour relancer le delivery vers la prod n'est pas une base de tests, mais une hiérarchie claire :
- quels parcours servent de gate de non-régression
- quels niveaux de tests portent chaque feature
- quelles suites doivent tourner en local, en CI rapide, en CI complète et en validation manuelle scriptée
## Convention d'exécution runnable
Dans l'environnement actuel, les commandes Flutter et Dart passent en forçant un `HOME` writable et en coupant l'analytics.
Convention à utiliser pour les runners locaux et CI :
```bash
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
```
Preuve minimale vérifiée dans ce ticket :
- `flutter test test/functional/functional_coverage_test.dart --plain-name 'builders create readable exercise, program and template scenarios'`
- `dart test test/health_test.dart` depuis `server/`
Ces deux commandes passent avec la convention ci-dessus.
## Ordre de couverture recommandé
### Lot 1 — Gate prod minimale téléphone offline-first
But : garantir que l'app principale remplit sa promesse cœur sans compte ni sync.
Features incluses :
- bibliothèque d'exercices
- composition de programmes
- composition de séances
- exécution d'une séance
- enregistrement d'historique
- reprise d'une séance en cours
- progression issue de l'historique
Pourquoi en premier :
- c'est le cœur de la valeur GameTime
- la plupart des invariants sont déjà testables en mémoire
- ce lot permet de relancer le développement produit sans attendre l'infra distante
### Lot 2 — Variantes métier à fort risque de régression
Features incluses :
- score manuel
- score chrono
- timers de série et de repos
- séquences d'étapes
- correction d'une série passée
- import/export local
Pourquoi ensuite :
- ces variantes cassent facilement les écrans et les use cases
- elles demandent des suites de comportement plus transverses que du simple widget isolé
### Lot 3 — Compte, sync et partage
Features incluses :
- profil connecté/déconnecté
- auth
- sync
- boîte de réception des partages
- partage de programme et de séance
- import de partage accepté
Pourquoi après le lot 1 :
- forte dépendance à l'API et à des fixtures serveur
- besoin de distinguer clairement tests clients mockés, tests serveur réels et smoke end-to-end
### Lot 4 — Intégrations gratuites device
Features incluses :
- Health Connect
- compagnon montre Android/Wear
- projection de séance vers la montre
- télémétrie live montre
Pourquoi en quatrième :
- important produit, mais plus coûteux à rendre deterministic
- nécessite émulateurs / devices / bridge natif
### Lot 5 — Validation déploiement et packaging
Features incluses :
- build Android
- build watch
- bootstrap serveur Docker/PostgreSQL
- smoke API réelle
Pourquoi en dernier :
- ce lot doit s'appuyer sur des fondations fonctionnelles déjà stables
## Matrice feature -> suites attendues
### 1. Exercices
Parcours nominaux :
- créer un exercice simple
- créer un exercice avec reps, temps et score
- ajouter tags et médias
- définir une icône média
- configurer des étapes
- modifier puis supprimer un exercice
Cas limites :
- aucune mesure active
- neuvième étape refusée
- sixième image refusée
- étape invalide
- exercice seedé modifié
Suites attendues :
- unitaires : validations métier et normalisation
- intégration : persistance Drift des exercices, tags, médias, étapes
- widget : formulaire réel et liste filtrable
- end-to-end/manuel scripté : ajout complet avec média réel sur device
Base existante :
- `test/presentation/exercise_library_screen_test.dart`
- `test/infrastructure/drift_repositories_test.dart`
### 2. Programmes
Parcours nominaux :
- créer un programme depuis des exercices existants
- configurer séries, cibles, repos
- dupliquer
- filtrer
- partager
Cas limites :
- programme sans exercice
- exercice snapshot incomplet
- partage sans compte
Suites attendues :
- unitaires : construction snapshot et validations de configuration
- intégration : lecture/écriture repository
- widget : formulaire, liste, duplication, partage
- end-to-end/manuel scripté : création puis partage depuis un compte réel
Base existante :
- `test/presentation/program_screen_test.dart`
### 3. Séances / templates
Parcours nominaux :
- créer une séance avec un ou plusieurs programmes
- configurer overrides
- dupliquer
- lancer depuis la liste
Cas limites :
- séance sans programme
- aucune exécution possible
- legacy invalide non bloquante
Suites attendues :
- unitaires : résolution des snapshots et overrides
- intégration : persistance template + programme snapshot
- widget : création/édition/lancement
- end-to-end/manuel scripté : création complète puis lancement
Base existante :
- `test/presentation/workout_template_screen_test.dart`
- `test/functional/functional_coverage_test.dart`
### 4. Exécution de séance
Parcours nominaux :
- démarrer depuis une séance
- exécuter score manuel
- exécuter chrono score
- gérer repos
- pause/reprise
- terminer une série
- quitter et reprendre
- compléter la séance
- enregistrer l'historique
- relancer depuis l'historique
Cas limites :
- séance invalide
- repos resynchronisé
- chrono jamais démarré
- dernière série sans repos
- série passée corrigée
- skip de série
- skip de séquence
Suites attendues :
- unitaires : transitions d'état de session, repos, chrono, score, steps
- intégration : persistance session active -> historique
- widget : écran d'exécution, plan, édition de série passée, reprise
- end-to-end/manuel scripté : parcours complet réel sur téléphone
Base existante :
- `test/application/use_cases_test.dart`
- `test/application/session_notification_use_cases_test.dart`
- `test/presentation/workout_execution_screen_test.dart`
- `test/functional/functional_coverage_test.dart`
### 5. Historique et progression
Parcours nominaux :
- consulter les séances passées
- ouvrir le détail
- supprimer
- relancer
- visualiser la progression globale
- visualiser la progression par exercice
Cas limites :
- historique vide
- historique legacy invalide
- score chrono affiché comme temps
- ouverture de détail depuis point de graphe
Suites attendues :
- unitaires : agrégations progression et mapping métriques
- intégration : repository historique
- widget : écrans Historique et Progression
- end-to-end/manuel scripté : séance jouée puis visible dans progression
Base existante :
- `test/presentation/history_screen_test.dart`
- `test/presentation/progression_screen_test.dart`
### 6. Compte, auth, sync et partage
Parcours nominaux :
- créer un compte
- se connecter
- se déconnecter
- synchroniser
- partager un programme
- partager une séance
- recevoir un partage
- accepter et importer
- décliner
Cas limites :
- erreur login inline
- erreur serveur dédiée
- token invalide
- conflit LWW sync
- partage déjà répondu
- email inconnu
- quota receveur plein
Suites attendues :
- unitaires : use cases auth/sync/share, règles quota/import
- intégration : client HTTP, API serveur, repositories serveur
- widget : Profil, Inbox, formulaires de partage
- end-to-end/manuel scripté : 2 comptes réels, partage puis sync
Base existante :
- client Flutter : `test/presentation/profile_screen_test.dart`, `test/presentation/share_inbox_screen_test.dart`, `test/infrastructure/remote/*.dart`
- serveur Dart : `server/test/auth_*`, `server/test/sync_*`, `server/test/share_*`
Note monétisation :
- le partage reste gratuit
- le quota free limite la bibliothèque éditable, pas l'exécution
- les objets importés/partagés comptent côté receveur à l'acceptation
- le backlog QA devra explicitement ajouter les cas quota multi-comptes et restauration
### 7. Import / export local
Parcours nominaux :
- exporter ses données
- importer en fusion
- importer en remplacement total
Cas limites :
- fichier invalide
- annulation silencieuse
- séance active qui bloque l'import
- double confirmation destructive
Suites attendues :
- unitaires : parsing et règles de fusion/remplacement
- intégration : repositories et frontières d'import
- widget : flux import/export depuis Profil
- end-to-end/manuel scripté : vrai fichier sur device
Base existante :
- `test/presentation/profile_screen_test.dart`
- `test/domain_import_boundaries_test.dart`
### 8. Health Connect
Parcours nominaux :
- afficher l'état d'intégration
- demander les permissions
- rafraîchir le statut
Cas limites :
- permissions partielles
- refus explicite
- fallback vers réglages
- accès bloqué sans action
- échec d'appel natif
Suites attendues :
- unitaires : mapping des statuts natifs
- intégration : gateway Health Connect mockée
- widget : écran Paramètres / Intégrations
- manuel scripté : device Android compatible Health Connect
Base existante :
- `test/application/health_connect_use_cases_test.dart`
- `test/presentation/settings_screen_test.dart`
### 9. Watch companion
Parcours nominaux :
- projeter une séance active vers la montre
- piloter score / timer / pause depuis la montre
- remonter la fréquence cardiaque et la télémétrie
- gérer reconnexion et fraîcheur de projection
Cas limites :
- téléphone absent
- projection stale
- actions bloquées en reconnexion
- timer visible en pause
- TTL dépassé
Suites attendues :
- unitaires : contrat de projection, commandes, policy de retry
- intégration : bridge natif téléphone <-> montre
- widget : UI montre
- manuel scripté : téléphone Android + émulateur/device Wear
Base existante :
- `test/application/watch_companion_projection_test.dart`
- `test/application/watch_companion_command_handler_test.dart`
- `test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart`
- `watch_app/test/presentation/watch_session_screen_test.dart`
- `watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt`
## Stratégie runnable par niveau de suite
### 1. Suites rapides obligatoires sur chaque lot
Objectif : feedback en quelques minutes, sans device réel.
Contenu :
- unitaires domaine/application
- intégration repository mémoire ou Drift locale
- widget ciblés
- fonctionnels mémoire via `FunctionalTestHarness`
Règle :
- toute nouvelle feature/correction doit d'abord produire ou enrichir une suite de ce niveau
### 2. Suites de référence transverses
Objectif : prouver que les briques s'assemblent sur des parcours principaux.
Contenu :
- un petit nombre de scénarios fonctionnels end-to-end en mémoire côté Flutter
- un petit nombre de scénarios API serveur de bout en bout en local
Règle :
- garder ces scénarios courts, lisibles et déterministes
- ne pas y mettre toutes les permutations écran par écran
### 3. Suites device / infra scriptées
Objectif : couvrir les frontières impossibles à prouver en sandbox mémoire.
Contenu :
- import/export réel
- Health Connect réel
- compagnon montre réel
- Docker/PostgreSQL/smoke API réel
Règle :
- documenter chaque parcours sous forme de script de validation réexécutable
- promouvoir en automatisation seulement après stabilisation du protocole et des fixtures
## Commandes cibles
### Flutter app
Installer les dépendances :
```bash
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
./.ideai/flutter-sdk-1785572580/bin/flutter pub get
```
Smoke domaine/widget/functional :
```bash
./.ideai/flutter-sdk-1785572580/bin/flutter test
./.ideai/flutter-sdk-1785572580/bin/flutter test test/functional/functional_coverage_test.dart
./.ideai/flutter-sdk-1785572580/bin/flutter test test/presentation/workout_execution_screen_test.dart
./.ideai/flutter-sdk-1785572580/bin/flutter test test/presentation/profile_screen_test.dart
```
### Watch app
```bash
cd watch_app
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
../.ideai/flutter-sdk-1785572580/bin/flutter pub get
../.ideai/flutter-sdk-1785572580/bin/flutter test
```
### Serveur
```bash
cd server
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export DART_SUPPRESS_ANALYTICS=true
/home/anthony/Documents/Projects/GameTime/.ideai/flutter-sdk-1785572580/bin/dart pub get
/home/anthony/Documents/Projects/GameTime/.ideai/flutter-sdk-1785572580/bin/dart test
```
### Intégration PostgreSQL réelle
```bash
cd server
TEST_DATABASE_URL=postgres://gametime:gametime@localhost:5432/gametime \
/home/anthony/Documents/Projects/GameTime/.ideai/flutter-sdk-1785572580/bin/dart test
```
### Build / smoke Android manuel scripté
```bash
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
./.ideai/flutter-sdk-1785572580/bin/flutter build apk --debug
./.ideai/flutter-sdk-1785572580/bin/flutter run
```
## Backlog initial d'automatisation
### Priorité P0 — à garder verte en permanence
- stabiliser `test/functional/functional_coverage_test.dart` comme gate Flutter transverse
- y conserver au moins 4 scénarios de référence :
- création exercice/programme/séance via formulaires réels
- exécution complète avec score manuel + chrono + repos + historique + replay
- reprise de séance sauvegardée
- séquences d'étapes avec skip et correction de résultat
- définir une commande CI rapide `flutter test test/functional/functional_coverage_test.dart`
- définir une commande CI serveur `dart test` dans `server/`
### Priorité P1 — verrouiller le cœur prod offline
- compléter une suite intégration Drift dédiée au cycle exercice -> programme -> template -> session -> historique
- ajouter une suite application dédiée aux invariants de progression à partir d'historiques réels
- ajouter une suite widget de navigation nominale accueil -> séance -> historique -> progression
- isoler un groupe "smoke critical screens" pour `home`, `exercise_library`, `program`, `workout_template`, `workout_execution`, `history`, `progression`
### Priorité P2 — verrouiller les frontières produit payantes et multi-comptes
- ajouter tests use case et serveur pour quota free/pro
- couvrir import de partage quand quota receveur est plein
- couvrir multi-comptes, restauration et sync Pro
- couvrir offline puis resync après reconnexion
### Priorité P3 — transformer les checklists device en scripts réexécutables
- écrire un script manuel standardisé Health Connect
- écrire un script manuel standardisé watch companion
- écrire un script manuel standardisé partage entre deux comptes réels
- écrire un script manuel standardisé import/export fichier réel
## Première découpe TDD recommandée pour relancer le delivery
### Sprint QA-A — gate rapide commune
- figer la convention d'environnement de test
- garder vert :
- `test/functional/functional_coverage_test.dart`
- `server/test`
- `watch_app/test`
### Sprint QA-B — cœur offline téléphone
- enrichir le functional harness pour couvrir navigation nominale complète
- compléter les invariants manquants exercice -> programme -> template -> exécution -> historique -> progression
### Sprint QA-C — compte/sync/partage
- préparer fixtures API et scénarios 2 comptes
- séparer clairement :
- tests client mockés
- tests serveur réels
- smoke manuels API + client
### Sprint QA-D — device integrations
- formaliser scripts de validation montre et Health Connect
- décider ensuite ce qui mérite une automatisation instrumentée
## Critères de sortie pour qu'une feature soit "finie"
Une feature n'est pas considérée terminée tant que :
- le plan fonctionnel du lot existe
- les tests unitaires / intégration / widget prévus pour le lot existent
- les commandes de suites rapides ont réellement tourné
- les éventuels scripts manuels device/infra ont réellement été exécutés si la feature touche ces frontières
- les régressions critiques correspondantes sont absorbées dans les suites P0/P1
## Recommandation immédiate
Pour redémarrer vers la prod sans disperser l'équipe :
1. utiliser `test/functional/functional_coverage_test.dart` comme colonne vertébrale Flutter
2. considérer `server/test` comme gate du scope compte/sync/partage
3. ne pas lancer de nouvelle feature transverse sans ajouter son scénario dans le harnais fonctionnel ou dans une suite serveur équivalente
4. traiter Health Connect et watch companion comme checklists scriptées tant que le setup device n'est pas stabilisé en CI

View File

@ -0,0 +1,63 @@
# QA Gate #239 — Suite fonctionnelle Flutter transverse
## But
Cette gate sert de socle rapide de non-régression Flutter pour la reprise TDD.
Elle couvre le parcours transverse minimal suivant :
- création via formulaires réels
- exécution complète d'une séance
- reprise de séance
- séquences, skip et correction
## Fichier source
- `test/functional/functional_coverage_test.dart`
## Pré-requis
Utiliser un environnement writable pour Flutter :
```bash
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
```
## Commande gate complète
```bash
./.ideai/flutter-sdk-1785572580/bin/flutter test test/functional/functional_coverage_test.dart
```
## Commandes ciblées utiles
Scénario de référence transverse :
```bash
./.ideai/flutter-sdk-1785572580/bin/flutter test test/functional/functional_coverage_test.dart --plain-name "complete execution covers manual score, stopwatch, rests, skips, history and replay"
```
Scénario reprise :
```bash
./.ideai/flutter-sdk-1785572580/bin/flutter test test/functional/functional_coverage_test.dart --plain-name "saved exit sessions resume from the execution UI without showing completion"
```
## Verdict attendu
- vert si la base Flutter transverse est prête pour une boucle TDD rapide
- rouge si un parcours nominal majeur est cassé
## Preuve réelle du 22 août 2026
Exécution rapportée verte sur `/tmp/gametime-src-1787384228`.
Cette gate devient la référence rapide de non-régression Flutter transverse.
## Usage dans la boucle TDD
- avant implémentation d'une feature transverse touchant exécution/navigation
- après chaque lot modifiant les formulaires, l'exécution, l'historique ou la reprise
- comme gate rapide avant d'élargir vers les suites widget plus détaillées

View File

@ -0,0 +1,57 @@
# QA Gate #240 — Suite coeur offline Drift
## But
Cette gate sécurise le coeur offline local avant reprise intensive du développement.
Le point d'entrée recommandé est le parcours intégré :
- exercice
- programme
- séance-template
- session active
- clôture en historique
- progression minimale
## Fichier source
- `test/infrastructure/drift_repositories_test.dart`
## Pré-requis
Utiliser le runner Flutter/Dart avec un environnement writable :
```bash
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
```
## Commande gate complète
```bash
./.ideai/flutter-sdk-1785572580/bin/flutter test test/infrastructure/drift_repositories_test.dart
```
## Point d'entrée coeur offline recommandé
```bash
./.ideai/flutter-sdk-1785572580/bin/flutter test test/infrastructure/drift_repositories_test.dart --plain-name "QA core offline journey persists exercise to progression through local repositories"
```
## Verdict attendu
- vert si la chaîne locale principale reste cohérente de la création jusqu'à la progression
- rouge si le domaine local ou la persistance Drift régressent sur le flux principal
## Preuve réelle du 22 août 2026
Exécution rapportée verte sur `/tmp/gametime-src-1787384228`.
Cette gate devient la référence coeur offline avant toute validation device ou release.
## Usage dans la boucle TDD
- à lancer avant et après chaque lot touchant les use cases offline
- à utiliser comme première preuve exécutable avant les suites écran par écran
- à compléter ensuite par les tests fonctionnels Flutter et, plus tard, les validations device

View File

@ -0,0 +1,73 @@
# QA Gate #241 — Smoke écrans critiques Flutter
## But
Cette gate sert de détection rapide des régressions visibles majeures pendant la boucle TDD Flutter.
Écrans couverts :
- `home`
- `exercise library`
- `program`
- `workout template`
- `workout execution`
- `history`
- `progression`
- `profile`
## Fichiers sources
- `test/presentation/home_screen_test.dart`
- `test/presentation/exercise_library_screen_test.dart`
- `test/presentation/program_screen_test.dart`
- `test/presentation/workout_template_screen_test.dart`
- `test/presentation/workout_execution_screen_test.dart`
- `test/presentation/history_screen_test.dart`
- `test/presentation/progression_screen_test.dart`
- `test/presentation/profile_screen_test.dart`
## Pré-requis
```bash
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
```
## Point d'entrée recommandé
```bash
./tool/qa_gate_241_critical_screens_smoke.sh
```
## Commande directe équivalente
```bash
./.ideai/flutter-sdk-1785572580/bin/flutter test \
test/presentation/home_screen_test.dart \
test/presentation/exercise_library_screen_test.dart \
test/presentation/program_screen_test.dart \
test/presentation/workout_template_screen_test.dart \
test/presentation/workout_execution_screen_test.dart \
test/presentation/history_screen_test.dart \
test/presentation/progression_screen_test.dart \
test/presentation/profile_screen_test.dart
```
## Verdict attendu
- vert si les écrans critiques restent fonctionnels sur les parcours nominaux majeurs
- rouge si une régression UI majeure casse la boucle TDD Flutter
## Preuve réelle du 22 août 2026
Exécution rapportée verte sur `/tmp/gametime-src-1787384228`.
Le script `./tool/qa_gate_241_critical_screens_smoke.sh` devient l'entrée de référence pour ce smoke.
## Usage
- avant merge d'un lot Flutter transverse
- après toute modification navigation/formulaires/exécution/historique/profil
- en complément de la gate `#239`

View File

@ -0,0 +1,80 @@
# QA Gate #242 — Monétisation Free/Pro et partage/quota
## But
Matérialiser une première gate runnable autour de ce qui est déjà testable aujourd'hui, tout en isolant les blockers réels pour les règles Free/Pro non encore implémentées.
## Ce qui est runnable maintenant
Couverture partielle déjà testable :
- profil et synchronisation côté Flutter
- partage / inbox / acceptation locale
- Health Connect gratuit
- companion montre gratuit
Fichiers déjà exploitables :
- `test/presentation/profile_screen_test.dart`
- `test/presentation/share_inbox_screen_test.dart`
- `test/presentation/settings_screen_test.dart`
- `test/application/health_connect_use_cases_test.dart`
- `test/application/watch_companion_projection_test.dart`
## Point d'entrée recommandé
```bash
./tool/qa_gate_242_monetization_partial.sh
```
## Pré-requis
```bash
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
```
## Commande directe équivalente
```bash
./.ideai/flutter-sdk-1785572580/bin/flutter test \
test/presentation/profile_screen_test.dart \
test/presentation/share_inbox_screen_test.dart \
test/presentation/settings_screen_test.dart \
test/application/health_connect_use_cases_test.dart \
test/application/watch_companion_projection_test.dart
```
## Blockers réels
Les contrats suivants ne sont pas encore testables proprement contre du code produit, car la surface métier n'existe pas encore clairement dans le repo :
- quota `Free/Pro`
- refus création/édition quand quota plein
- import de partage chez receveur plein
- restauration d'achat
- statut `Pro`
- sync `Pro`
- multi-comptes monétisation
## Recommandation de suite
Avant d'écrire une vraie suite verte sur ces règles, il faut introduire un contrat métier testable, par exemple :
- une policy `LibraryEntitlementPolicy`
- un use case `QuotaGuard`
- un état de compte distinguant `Free` et `Pro`
- une règle explicite d'acceptation de partage sous quota
## Verdict attendu
- vert aujourd'hui sur la gate partielle gratuite / partage
- rouge ou non applicable sur Free/Pro tant que la couche métier quota/billing n'existe pas
## Preuve réelle du 22 août 2026
Exécution rapportée verte sur `/tmp/gametime-src-1787384228`.
Cette gate valide la surface monétisation actuellement testable en local, sans fermer la QA release Android Play Store.

View File

@ -0,0 +1,105 @@
# QA Gate #243 — Runbooks manuels device
## But
Standardiser les validations device non encore raisonnablement automatisables, avec des scripts réexécutables de collecte d'artefacts et un déroulé manuel stable.
Périmètre couvert :
- Health Connect téléphone Android
- watch companion téléphone + Wear
- import/export réel sur téléphone
## Références officielles
### Health Connect
Préparer la collecte :
```bash
./tool/qa_device_health_connect.sh start
```
Finaliser la collecte :
```bash
./tool/qa_device_health_connect.sh stop <artifact_dir>
```
### Watch companion
Préparer la collecte :
```bash
WATCH_SERIAL=<watch-adb-serial> ./tool/qa_device_watch_companion.sh start
```
Finaliser la collecte :
```bash
WATCH_SERIAL=<watch-adb-serial> ./tool/qa_device_watch_companion.sh stop <artifact_dir>
```
### Import / export réel
Préparer la collecte :
```bash
./tool/qa_device_import_export_real.sh start
```
Finaliser la collecte :
```bash
./tool/qa_device_import_export_real.sh stop <artifact_dir>
```
## Pré-requis communs
- `adb` disponible dans le `PATH`
- appareil téléphone connecté et autorisé
- pour la montre : `WATCH_SERIAL` renseigné et montre visible via `adb devices -l`
- build GameTime fraîche installée sur le ou les appareils sous test
## Artefacts attendus
Chaque script crée un dossier sous :
```bash
.ideai/artifacts/qa-device/
```
Le dossier produit doit contenir au minimum :
- `adb-devices.txt`
- fingerprint build device
- `logcat.txt` téléphone et/ou montre
- `steps.md` avec le scénario manuel joué
- `package-dumpsys-before.txt` et `package-dumpsys-after.txt` quand applicable
## Verdict attendu par runbook
### Health Connect
- ouverture propre du flux permission ou fallback réglages
- retour propre dans GameTime
- état UI cohérent avant/après
- export fin de séance vérifiable si exercé
### Watch companion
- projection de séance visible sur montre
- actions montre répercutées sur téléphone
- métriques montre lisibles, dont les pas si disponibles
- clôture de séance cohérente côté téléphone et montre
### Import / export réel
- export de fichier réussi
- réimport `merge` cohérent
- réimport `replaceAll` cohérent sur dataset jetable
- refus attendu si une séance active bloque l'import
## Limite actuelle au 22 août 2026
Ces runbooks rendent `#243` matérialisable et rejouable, mais la clôture QA device reste dépendante de la disponibilité réelle d'un appareil `adb`.

View File

@ -0,0 +1,275 @@
# Ticket #261 — Volet QA première release Android en test fermé Play
## Objectif
Préparer une validation QA exécutable avant le premier upload Android en test fermé Play pour GameTime, en réutilisant les gates et runbooks déjà présents dans le repo.
Références de base :
- `docs/qa-gate-239-functional-flutter.md`
- `docs/qa-gate-241-critical-screens-smoke.md`
- `docs/qa-gate-242-monetization-free-pro.md`
- `docs/qa-gate-243-device-runbooks.md`
- `docs/release-compliance-checklist.md`
- `server/docs/integration-checklist.md`
## 1. Checklist de validation exécutable avant upload
### A. Repo-only obligatoire avant upload
Préparer l'environnement Flutter :
```bash
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
```
Exécuter les vérifications suivantes :
1. Politique release Android et manifests
```bash
rg -n 'usesCleartextTraffic="true"' android/app/src/main watch_app/android/app/src/main
rg -n 'usesCleartextTraffic="true"' android/app/src/debug android/app/src/profile
rg -n 'android.permission.INTERNET' android/app/src/main/AndroidManifest.xml
```
Verdict attendu :
- aucun `usesCleartextTraffic="true"` en release/main
- `usesCleartextTraffic="true"` autorisé uniquement en `debug/profile`
- permission `INTERNET` présente sur l'app téléphone
2. Smoke écrans critiques téléphone
```bash
./tool/qa_gate_241_critical_screens_smoke.sh
```
3. Gate monétisation actuellement testable en local
```bash
./tool/qa_gate_242_monetization_partial.sh
```
4. Suite watch widget locale
```bash
cd watch_app
/home/anthony/Documents/Projects/GameTime/.ideai/flutter-sdk-1785572580/bin/flutter test test/presentation/watch_session_screen_test.dart
```
5. Suite fonctionnelle transverse téléphone
```bash
cd /home/anthony/Documents/Projects/GameTime
./.ideai/flutter-sdk-1785572580/bin/flutter test test/functional/functional_coverage_test.dart
```
Règle de sortie :
- upload interdit si une suite repo-only censée être gate release est rouge
- les blockers connus doivent être enregistrés avant handoff device/Play
### B. Build/signature à vérifier avant upload
Le repo impose une signature release locale partagée côté téléphone et montre :
- `android/app/build.gradle.kts`
- `watch_app/android/app/build.gradle.kts`
- `android/key.properties`
Points à vérifier :
- `android/key.properties` présent
- keystore référencé présent
- même signature pour téléphone et montre
- `versionCode` et `versionName` cohérents pour le lot livré
- URL API release en HTTPS si build release connecté
### C. Device/manual obligatoire avant upload ou juste après distribution interne
Téléphone :
- runbook Health Connect
- runbook import/export réel
- smoke nominal installation / lancement / navigation / exécution d'une séance
Montre :
- runbook watch companion complet avec collecte d'artefacts téléphone + montre
Entrées de référence :
```bash
./tool/qa_device_health_connect.sh start
./tool/qa_device_import_export_real.sh start
WATCH_SERIAL=<watch-adb-serial> ./tool/qa_device_watch_companion.sh start
```
## 2. Cas de tests réels prioritaires phone et watch
### Priorité P0 téléphone
1. Installation release puis premier lancement
- l'app démarre sans crash
- l'icône, le nom et le thème de lancement sont corrects
2. Parcours coeur offline
- créer ou utiliser un contenu existant
- lancer une séance
- exécuter score manuel, chrono, repos, skip
- terminer la séance
- vérifier historique et reprise si sortie intermédiaire
3. Import/export réel
- export réussi
- import `merge` réussi
- import `replaceAll` sur dataset jetable
- refus d'import si séance active
4. Profil / partage / sync exposés sans blocage
- profil déconnecté utilisable
- navigation vers boîte de réception des partages
- aucun blocage du coeur gratuit si non connecté
### Priorité P0 montre
1. Projection d'une séance téléphone vers montre
- la séance active apparaît côté montre
- le statut de connexion est cohérent
2. Action round-trip montre -> téléphone
- pause / reprise / validation ou autre action watch
- effet visible et cohérent sur le téléphone
3. Fin de séance cohérente sur les deux appareils
- teardown propre côté montre
- pas d'état fantôme après clôture
### Priorité P1 téléphone
1. Health Connect gratuit
- ouverture du flux permission ou fallback réglages
- retour propre dans GameTime
- état UI rafraîchi
2. Cas dégradés réseau / sync
- l'app reste utilisable hors connexion pour le coeur offline
- un échec de sync reste neutre pour le flux principal
### Priorité P1 montre
1. Télémétrie visible
- fréquence cardiaque si autorisée
- distance / pas si disponibles sur l'appareil
2. Reconnexion
- perte puis retour de connexion
- resync cohérent sans actions fantômes
## 3. Ce qui peut être validé sans Play Console ni devices
### Validable maintenant depuis le repo
1. Politique manifests/release
- `usesCleartextTraffic` absent des manifests release/main
- `INTERNET` présent côté téléphone
2. Smoke UI téléphone
- `./tool/qa_gate_241_critical_screens_smoke.sh`
3. Surface monétisation gratuite actuellement testable
- `./tool/qa_gate_242_monetization_partial.sh`
4. UI/watch locale
- `watch_app/test/presentation/watch_session_screen_test.dart`
5. Présence du câblage signature/build
- `android/key.properties`
- config release téléphone + montre
### Preuves réellement exécutées le 28 août 2026
Vert :
- `./tool/qa_gate_241_critical_screens_smoke.sh`
- `./tool/qa_gate_242_monetization_partial.sh`
- `watch_app/test/presentation/watch_session_screen_test.dart` exécuté depuis `watch_app/` : `33` tests verts
Rouge :
- `./.ideai/flutter-sdk-1785572580/bin/flutter test test/functional/functional_coverage_test.dart`
Échec réel constaté :
- test en échec : `QA functional fast foundation QA seed inserts deterministic coverage content once`
- fichier : `test/functional/functional_coverage_test.dart`
- ligne signalée : `48`
- symptôme : attente de `3` exercices, valeur réelle `5`
Conséquence QA :
- la gate transverse `#239` ne doit pas être considérée comme verte au `28 août 2026`
- un upload peut être préparé administrativement, mais pas validé “QA green” tant que ce rouge reste ouvert si `#239` fait partie de la barrière release retenue
## 4. Ce qui reste bloquant sans l'utilisateur
### Bloquant sans Play Console
- création/configuration du closed testing track
- upload réel AAB/APK et vérification post-upload
- app content forms Play
- URL publique finale de privacy policy
- éventuelle déclaration/review Play liée à Health Connect
- vérification de la fiche store et des audiences/testeurs
### Bloquant sans devices réels
- installation réelle téléphone Android
- installation réelle montre Wear OS
- permissions Health Connect réelles
- bridge téléphone <-> montre via Data Layer
- télémétrie capteurs réelle
- import/export réel via file picker système
### Bloquant sans intervention utilisateur/produit
- sélection du build exact à uploader
- confirmation du endpoint release HTTPS final
- validation finale des identifiants/signature destinés à la release
- exécution des scénarios manuels sur compte(s) et appareil(s) de test fermés
- arbitrage sur le statut du rouge actuel de la gate `#239`
## Recommandation QA pour le ticket
Ordre recommandé avant premier upload fermé :
1. corriger ou rebaseliner le rouge actuel de `test/functional/functional_coverage_test.dart`
2. rerun repo-only : `#241`, `#242`, `#239`, suite watch
3. exécuter les runbooks device `#243`
4. seulement ensuite procéder à l'upload Play fermé
Dans l'état observé le `28 août 2026`, le repo permet déjà de préparer une grande partie du volet QA release, mais pas de fermer la validation complète sans devices, sans Play Console, et sans décision explicite sur le rouge de la gate fonctionnelle transverse.
## Attentes testables minimales pour guardrails release
### 1. Version / build number phone et watch
- `versionName` doit être identique entre téléphone et montre pour un même lot release
- `versionCode` doit être identique entre téléphone et montre pour un même lot release
- si phone et watch divergent, le build release doit être considéré invalide pour livraison
### 2. `GAMETIME_API_BASE_URL` en release
- si `GAMETIME_API_BASE_URL` est absent en release, le build doit échouer explicitement
- si `GAMETIME_API_BASE_URL` n'utilise pas `https://` en release, le build doit échouer explicitement
- aucun fallback implicite HTTP, localhost ou LAN ne doit être autorisé en release
### 3. Statut du rouge `functional_coverage_test`
- au `28 août 2026`, `test/functional/functional_coverage_test.dart` est rouge
- ce rouge ne doit pas bloquer à lui seul une première release fermée `phone-only` si les gates release retenues sont `#241`, `#242` et les validations device téléphone
- en revanche, il bloque toute décision annonçant la gate fonctionnelle transverse `#239` comme verte

View File

@ -0,0 +1,73 @@
# GameTime Release and Compliance Checklist
This checklist tracks the repo-owned release/compliance work that can be closed
without Play Console access or physical devices.
## Privacy Policy
- Canonical repo draft: `docs/legal/privacy-policy-v1.md`
- Version: `1.0`
- Effective date: `2026-08-22`
- Before Play submission, publish the exact approved policy at a stable HTTPS
URL and enter that URL in Play Console.
- Keep future policy changes versioned in `docs/legal/`.
## Product Entitlements
The release surface must preserve these product invariants:
- sharing is free
- Free limits the editable workout library, not basic workout execution
- imported/shared resources count against the receiver quota on acceptance
- Pro means unlimited library plus sync
- Wear OS companion is free
- Health Connect is free
## Android Network Policy
Phone app release builds:
- keep `android.permission.INTERNET` in `android/app/src/main/AndroidManifest.xml`
- do not enable `android:usesCleartextTraffic` in the main manifest
- use HTTPS for `GAMETIME_API_BASE_URL` in release builds
Phone app debug/profile builds:
- may keep cleartext enabled through the debug/profile manifest overlays
- may use LAN HTTP URLs for local API development
Wear OS companion:
- keep release cleartext disabled
- keep companion functionality free and independent from Pro entitlement
## Repo-Only Validation
Run these checks before handing off to Play/device validation:
```bash
rg -n 'usesCleartextTraffic="true"' android/app/src/main watch_app/android/app/src/main
rg -n 'usesCleartextTraffic="true"' android/app/src/debug android/app/src/profile
rg -n 'android.permission.INTERNET' android/app/src/main/AndroidManifest.xml
rg -n 'Health Connect|Wear OS|sharing is free|Pro means unlimited' docs/legal docs
```
Expected result:
- no `usesCleartextTraffic="true"` in release/main manifests
- debug/profile overlays explicitly allow local cleartext
- phone release manifest still declares `INTERNET`
- privacy and release docs preserve the Free/Pro invariants
## External Blockers
These items cannot be closed from the repository alone:
- Play Console app content forms
- public privacy-policy hosting URL
- production support/contact address approval
- Play Health Connect declaration/review, if required by the final release track
- validation on a real Android phone
- validation on a real Wear OS device
- production HTTPS endpoint and certificate verification

View File

@ -382,7 +382,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.gametime.app; PRODUCT_BUNDLE_IDENTIFIER = com.idea.gametime;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -398,7 +398,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.gametime.app.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.idea.gametime.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -415,7 +415,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.gametime.app.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.idea.gametime.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -430,7 +430,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.gametime.app.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.idea.gametime.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -561,7 +561,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.gametime.app; PRODUCT_BUNDLE_IDENTIFIER = com.idea.gametime;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -583,7 +583,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.gametime.app; PRODUCT_BUNDLE_IDENTIFIER = com.idea.gametime;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 787 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,3 +1,4 @@
import '../infrastructure/health_connect/health_connect.dart';
import '../infrastructure/local/local.dart'; import '../infrastructure/local/local.dart';
import '../infrastructure/remote/remote.dart'; import '../infrastructure/remote/remote.dart';
import '../infrastructure/security/security.dart'; import '../infrastructure/security/security.dart';
@ -14,7 +15,10 @@ abstract interface class AppDependencies {
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases; ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
ActiveExerciseStepUseCases get activeExerciseStepUseCases; ActiveExerciseStepUseCases get activeExerciseStepUseCases;
ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases; ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases;
ExecutionDebugUseCase get executionDebugUseCase;
SeedQaFunctionalContentUseCase get seedQaFunctionalContentUseCase;
WorkoutTelemetryUseCases get workoutTelemetryUseCases; WorkoutTelemetryUseCases get workoutTelemetryUseCases;
WatchCompanionProjectionUseCases get watchCompanionProjectionUseCases;
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase; CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
WorkoutHistoryUseCases get workoutHistoryUseCases; WorkoutHistoryUseCases get workoutHistoryUseCases;
ProgressionStatsUseCase get progressionStatsUseCase; ProgressionStatsUseCase get progressionStatsUseCase;
@ -22,6 +26,8 @@ abstract interface class AppDependencies {
WatchAlertPublisher get watchAlertPublisher; WatchAlertPublisher get watchAlertPublisher;
DataExportUseCase get dataExportUseCase; DataExportUseCase get dataExportUseCase;
DataImportUseCase get dataImportUseCase; DataImportUseCase get dataImportUseCase;
HealthConnectUseCases get healthConnectUseCases;
EntitlementRevalidationUseCase get entitlementRevalidationUseCase;
SyncUseCases get syncUseCases; SyncUseCases get syncUseCases;
ShareUseCases get shareUseCases; ShareUseCases get shareUseCases;
} }
@ -37,6 +43,8 @@ final class AppBootstrap implements AppDependencies {
required this.activeWorkoutSessionUseCases, required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases, required this.activeExerciseStepUseCases,
required this.activeWorkoutSensorUseCases, required this.activeWorkoutSensorUseCases,
required this.executionDebugUseCase,
required this.seedQaFunctionalContentUseCase,
required this.workoutTelemetryUseCases, required this.workoutTelemetryUseCases,
required this.watchCompanionProjectionUseCases, required this.watchCompanionProjectionUseCases,
required this.watchCompanionCommandHandler, required this.watchCompanionCommandHandler,
@ -48,6 +56,8 @@ final class AppBootstrap implements AppDependencies {
required this.exercisePerformanceReferenceUseCase, required this.exercisePerformanceReferenceUseCase,
required this.dataExportUseCase, required this.dataExportUseCase,
required this.dataImportUseCase, required this.dataImportUseCase,
required this.healthConnectUseCases,
required this.entitlementRevalidationUseCase,
required this.syncUseCases, required this.syncUseCases,
required this.shareUseCases, required this.shareUseCases,
required this.syncGateway, required this.syncGateway,
@ -71,7 +81,12 @@ final class AppBootstrap implements AppDependencies {
@override @override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases; final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
@override @override
final ExecutionDebugUseCase executionDebugUseCase;
@override
final SeedQaFunctionalContentUseCase seedQaFunctionalContentUseCase;
@override
final WorkoutTelemetryUseCases workoutTelemetryUseCases; final WorkoutTelemetryUseCases workoutTelemetryUseCases;
@override
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler; final WatchCompanionCommandHandler watchCompanionCommandHandler;
final WatchWearDataLayerAdapter watchWearDataLayerAdapter; final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
@ -91,6 +106,10 @@ final class AppBootstrap implements AppDependencies {
@override @override
final DataImportUseCase dataImportUseCase; final DataImportUseCase dataImportUseCase;
@override @override
final HealthConnectUseCases healthConnectUseCases;
@override
final EntitlementRevalidationUseCase entitlementRevalidationUseCase;
@override
final SyncUseCases syncUseCases; final SyncUseCases syncUseCases;
@override @override
final ShareUseCases shareUseCases; final ShareUseCases shareUseCases;
@ -110,6 +129,9 @@ final class AppBootstrap implements AppDependencies {
final pendingShareActionRepository = DriftPendingShareActionRepository( final pendingShareActionRepository = DriftPendingShareActionRepository(
database, database,
); );
final entitlementSnapshotRepository = DriftEntitlementSnapshotRepository(
database,
);
final programRepository = DriftProgramRepository(database); final programRepository = DriftProgramRepository(database);
final templateRepository = DriftWorkoutTemplateRepository(database); final templateRepository = DriftWorkoutTemplateRepository(database);
final activeSessionRepository = DriftActiveSessionRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database);
@ -124,6 +146,7 @@ final class AppBootstrap implements AppDependencies {
const localBackupMediaStore = PathProviderLocalMediaStorage(); const localBackupMediaStore = PathProviderLocalMediaStorage();
final ids = LocalIdGenerator(); final ids = LocalIdGenerator();
const clock = SystemClock(); const clock = SystemClock();
const authTokenStore = SecureStorageAuthTokenStore();
const originDeviceId = 'local-device'; const originDeviceId = 'local-device';
final activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( final activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
@ -142,6 +165,16 @@ final class AppBootstrap implements AppDependencies {
final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases( final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: clock, clock: clock,
); );
final executionDebugUseCase = ExecutionDebugUseCase(
sessionRepository: activeSessionRepository,
clock: clock,
);
final seedQaFunctionalContentUseCase = SeedQaFunctionalContentUseCase(
seedStateRepository: starterSeedRepository,
contentRepository: starterSeedRepository,
clock: clock,
originDeviceId: originDeviceId,
);
final workoutTelemetryUseCases = WorkoutTelemetryUseCases( final workoutTelemetryUseCases = WorkoutTelemetryUseCases(
repository: telemetryRepository, repository: telemetryRepository,
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
@ -153,6 +186,7 @@ final class AppBootstrap implements AppDependencies {
clock: clock, clock: clock,
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
); );
final watchCompanionCommandHandler = WatchCompanionCommandHandler( final watchCompanionCommandHandler = WatchCompanionCommandHandler(
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
@ -164,6 +198,11 @@ final class AppBootstrap implements AppDependencies {
repository: historyRepository, repository: historyRepository,
clock: clock, clock: clock,
); );
final healthConnectUseCases = HealthConnectUseCases(
gateway: const MethodChannelHealthConnectGateway(),
historyRepository: historyRepository,
telemetryUseCases: workoutTelemetryUseCases,
);
final watchWearDataLayerAdapter = WatchWearDataLayerAdapter( final watchWearDataLayerAdapter = WatchWearDataLayerAdapter(
nativeChannel: const MethodChannelWatchBridgeNativeChannel(), nativeChannel: const MethodChannelWatchBridgeNativeChannel(),
commandIngress: watchCompanionCommandHandler, commandIngress: watchCompanionCommandHandler,
@ -193,11 +232,15 @@ final class AppBootstrap implements AppDependencies {
final remoteShareApi = HttpRemoteShareApi( final remoteShareApi = HttpRemoteShareApi(
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)), HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
); );
final remoteEntitlementSnapshotSource = HttpRemoteEntitlementSnapshotSource(
client: HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
tokenStore: authTokenStore,
);
return AppBootstrap._( return AppBootstrap._(
database: database, database: database,
authUseCases: AuthUseCases( authUseCases: AuthUseCases(
tokenStore: const SecureStorageAuthTokenStore(), tokenStore: authTokenStore,
accountRepository: onlineAccountRepository, accountRepository: onlineAccountRepository,
remoteAuthApi: remoteAuthApi, remoteAuthApi: remoteAuthApi,
clock: clock, clock: clock,
@ -236,6 +279,8 @@ final class AppBootstrap implements AppDependencies {
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases, activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
activeExerciseStepUseCases: activeExerciseStepUseCases, activeExerciseStepUseCases: activeExerciseStepUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases, activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
executionDebugUseCase: executionDebugUseCase,
seedQaFunctionalContentUseCase: seedQaFunctionalContentUseCase,
workoutTelemetryUseCases: workoutTelemetryUseCases, workoutTelemetryUseCases: workoutTelemetryUseCases,
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases, watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
watchCompanionCommandHandler: watchCompanionCommandHandler, watchCompanionCommandHandler: watchCompanionCommandHandler,
@ -245,6 +290,8 @@ final class AppBootstrap implements AppDependencies {
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
historyRepository: historyRepository, historyRepository: historyRepository,
telemetryRepository: telemetryRepository, telemetryRepository: telemetryRepository,
healthConnectUseCases: healthConnectUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
clock: clock, clock: clock,
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
@ -267,8 +314,13 @@ final class AppBootstrap implements AppDependencies {
mediaStore: localBackupMediaStore, mediaStore: localBackupMediaStore,
clock: clock, clock: clock,
), ),
healthConnectUseCases: healthConnectUseCases,
entitlementRevalidationUseCase: EntitlementRevalidationUseCase(
repository: entitlementSnapshotRepository,
remote: remoteEntitlementSnapshotSource,
),
syncUseCases: SyncUseCases( syncUseCases: SyncUseCases(
tokenStore: const SecureStorageAuthTokenStore(), tokenStore: authTokenStore,
remoteSyncApi: remoteSyncApi, remoteSyncApi: remoteSyncApi,
metadataRepository: syncMetadataRepository, metadataRepository: syncMetadataRepository,
mappingRepository: mappingRepository, mappingRepository: mappingRepository,
@ -277,7 +329,7 @@ final class AppBootstrap implements AppDependencies {
deviceId: originDeviceId, deviceId: originDeviceId,
), ),
shareUseCases: ShareUseCases( shareUseCases: ShareUseCases(
tokenStore: const SecureStorageAuthTokenStore(), tokenStore: authTokenStore,
remoteShareApi: remoteShareApi, remoteShareApi: remoteShareApi,
inboxRepository: shareInboxRepository, inboxRepository: shareInboxRepository,
pendingActionRepository: pendingShareActionRepository, pendingActionRepository: pendingShareActionRepository,

View File

@ -56,6 +56,98 @@ final class LocalBackupException implements Exception {
String toString() => error.name; String toString() => error.name;
} }
enum EntitlementTier { free, pro }
final class EntitlementSnapshot {
const EntitlementSnapshot._({
required this.entitlement,
required this.canSync,
required this.hasUnlimitedEditableLibrary,
required this.remainingEditableSlots,
required this.validatedAt,
});
factory EntitlementSnapshot.free({
required int remainingEditableSlots,
required DateTime validatedAt,
}) {
return EntitlementSnapshot._(
entitlement: EntitlementTier.free,
canSync: false,
hasUnlimitedEditableLibrary: false,
remainingEditableSlots: remainingEditableSlots,
validatedAt: validatedAt,
);
}
factory EntitlementSnapshot.pro({required DateTime validatedAt}) {
return EntitlementSnapshot._(
entitlement: EntitlementTier.pro,
canSync: true,
hasUnlimitedEditableLibrary: true,
remainingEditableSlots: null,
validatedAt: validatedAt,
);
}
final EntitlementTier entitlement;
final bool canSync;
final bool hasUnlimitedEditableLibrary;
final int? remainingEditableSlots;
final DateTime validatedAt;
}
abstract interface class EntitlementSnapshotRepository {
Future<EntitlementSnapshot?> read();
Future<void> save(EntitlementSnapshot snapshot);
}
abstract interface class RemoteEntitlementSnapshotSource {
Future<EntitlementSnapshot> read();
}
final class OfflineException implements Exception {
const OfflineException();
}
enum BillingAvailability { available, unavailable }
final class BillingProductRef {
const BillingProductRef({required this.id});
final String id;
}
enum BillingAttemptStatus { pendingVerification }
final class BillingAttemptResult {
const BillingAttemptResult._({required this.status, required this.product});
factory BillingAttemptResult.pendingVerification({
required BillingProductRef product,
}) {
return BillingAttemptResult._(
status: BillingAttemptStatus.pendingVerification,
product: product,
);
}
final BillingAttemptStatus status;
final BillingProductRef product;
}
abstract interface class BillingPort {
Future<BillingAvailability> availability();
Future<BillingAttemptResult> purchasePro({
required BillingProductRef product,
});
Future<BillingAttemptResult> restorePurchases();
Future<BillingAttemptResult> revalidate();
}
final class LocalBackupDocument { final class LocalBackupDocument {
const LocalBackupDocument({required this.fileName, required this.bytes}); const LocalBackupDocument({required this.fileName, required this.bytes});
@ -245,6 +337,136 @@ abstract interface class LocalBackupMediaStore {
); );
} }
enum HealthConnectAvailability {
available,
providerUpdateRequired,
accessBlocked,
unavailable,
}
enum HealthConnectConnectionStatus {
unavailable,
providerUpdateRequired,
accessBlocked,
permissionsRequired,
connected,
}
enum HealthConnectPermissionRequestOutcome {
none,
granted,
denied,
settingsFallback,
launchFailed,
}
enum HealthConnectExportStatus {
exported,
unavailable,
providerUpdateRequired,
accessBlocked,
permissionsRequired,
invalidWorkout,
failed,
}
final class HealthConnectConnectionState {
const HealthConnectConnectionState({
required this.availability,
required this.requiredPermissions,
required this.grantedPermissions,
this.permissionRequestOutcome = HealthConnectPermissionRequestOutcome.none,
});
final HealthConnectAvailability availability;
final Set<String> requiredPermissions;
final Set<String> grantedPermissions;
final HealthConnectPermissionRequestOutcome permissionRequestOutcome;
bool get hasRequiredPermissions =>
grantedPermissions.containsAll(requiredPermissions);
HealthConnectConnectionStatus get status {
switch (availability) {
case HealthConnectAvailability.unavailable:
return HealthConnectConnectionStatus.unavailable;
case HealthConnectAvailability.providerUpdateRequired:
return HealthConnectConnectionStatus.providerUpdateRequired;
case HealthConnectAvailability.accessBlocked:
return HealthConnectConnectionStatus.accessBlocked;
case HealthConnectAvailability.available:
return hasRequiredPermissions
? HealthConnectConnectionStatus.connected
: HealthConnectConnectionStatus.permissionsRequired;
}
}
}
final class HealthConnectHeartRateSample {
const HealthConnectHeartRateSample({
required this.time,
required this.beatsPerMinute,
});
final DateTime time;
final int beatsPerMinute;
}
final class HealthConnectWorkoutExport {
const HealthConnectWorkoutExport({
required this.historyId,
required this.title,
required this.startedAt,
required this.endedAt,
required this.durationMs,
this.totalDistanceMeters,
this.totalCaloriesKcal,
this.minHeartRateBpm,
this.averageHeartRateBpm,
this.maxHeartRateBpm,
this.heartRateSamples = const [],
});
final String historyId;
final String title;
final DateTime startedAt;
final DateTime endedAt;
final int durationMs;
final double? totalDistanceMeters;
final double? totalCaloriesKcal;
final int? minHeartRateBpm;
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
final List<HealthConnectHeartRateSample> heartRateSamples;
bool get isValid =>
historyId.trim().isNotEmpty &&
title.trim().isNotEmpty &&
durationMs > 0 &&
endedAt.isAfter(startedAt);
}
final class HealthConnectWorkoutExportResult {
const HealthConnectWorkoutExportResult({
required this.status,
required this.workout,
this.errorMessage,
});
final HealthConnectExportStatus status;
final HealthConnectWorkoutExport? workout;
final String? errorMessage;
bool get exported => status == HealthConnectExportStatus.exported;
}
abstract interface class HealthConnectGateway {
Future<HealthConnectConnectionState> connectionState();
Future<HealthConnectConnectionState> requestPermissions();
Future<bool> openSettings();
Future<void> exportWorkout(HealthConnectWorkoutExport workout);
}
enum ProgressionPeriod { fourWeeks, threeMonths, all } enum ProgressionPeriod { fourWeeks, threeMonths, all }
enum ProgressionMeasure { manualScore, stopwatchScore, reps, time } enum ProgressionMeasure { manualScore, stopwatchScore, reps, time }

View File

@ -230,6 +230,63 @@ final class DataImportUseCase {
} }
} }
final class EntitlementRevalidationUseCase {
const EntitlementRevalidationUseCase({
required this.repository,
required this.remote,
});
final EntitlementSnapshotRepository repository;
final RemoteEntitlementSnapshotSource remote;
Future<EntitlementSnapshot> readCached() => _readRequiredCached();
Future<EntitlementSnapshot> revalidateIfPossible() async {
try {
final snapshot = await remote.read();
await repository.save(snapshot);
return snapshot;
} on OfflineException {
return _readRequiredCached();
}
}
Future<EntitlementSnapshot> _readRequiredCached() async {
final snapshot = await repository.read();
if (snapshot == null) {
throw const OfflineException();
}
return snapshot;
}
}
final class BillingUseCases {
const BillingUseCases({
required BillingPort billingPort,
required BillingProductRef proProduct,
}) : _billingPort = billingPort,
_proProduct = proProduct;
final BillingPort _billingPort;
final BillingProductRef _proProduct;
Future<BillingAvailability> availability() {
return _billingPort.availability();
}
Future<BillingAttemptResult> purchasePro() {
return _billingPort.purchasePro(product: _proProduct);
}
Future<BillingAttemptResult> restorePurchases() {
return _billingPort.restorePurchases();
}
Future<BillingAttemptResult> revalidate() {
return _billingPort.revalidate();
}
}
enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty } enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty }
final class StarterSeedResult { final class StarterSeedResult {
@ -3461,12 +3518,14 @@ final class WatchCompanionProjectionUseCases implements WatchProjectionSource {
required Clock clock, required Clock clock,
required IdGenerator ids, required IdGenerator ids,
required String originDeviceId, required String originDeviceId,
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
WatchProjectionPublisher? publisher, WatchProjectionPublisher? publisher,
}) : _projector = WatchSessionProjectionProjector( }) : _projector = WatchSessionProjectionProjector(
sessionRepository: sessionRepository, sessionRepository: sessionRepository,
clock: clock, clock: clock,
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
), ),
_publisher = publisher; _publisher = publisher;
@ -3524,6 +3583,12 @@ bool _hasSameWatchCommandRevisionState(
left.stepIndex == right.stepIndex && left.stepIndex == right.stepIndex &&
left.stepTotal == right.stepTotal && left.stepTotal == right.stepTotal &&
left.stepName == right.stepName && left.stepName == right.stepName &&
left.stepType == right.stepType &&
left.stepTargetValue == right.stepTargetValue &&
left.exerciseTargetReps == right.exerciseTargetReps &&
left.exerciseTargetTimeMs == right.exerciseTargetTimeMs &&
left.exerciseTargetScore == right.exerciseTargetScore &&
left.exerciseTargetScoreTimeMs == right.exerciseTargetScoreTimeMs &&
_hasSameWatchCommandRevisionTimerState( _hasSameWatchCommandRevisionTimerState(
left.dominantTimer, left.dominantTimer,
right.dominantTimer, right.dominantTimer,
@ -3543,6 +3608,7 @@ bool _hasSameWatchCommandRevisionState(
left.manualScoreTargetLabel == right.manualScoreTargetLabel && left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue && left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
left.manualScoreScope == right.manualScoreScope && left.manualScoreScope == right.manualScoreScope &&
left.totalDistanceM == right.totalDistanceM &&
_listEquals( _listEquals(
left.healthServicesExerciseTypeStrategy, left.healthServicesExerciseTypeStrategy,
right.healthServicesExerciseTypeStrategy, right.healthServicesExerciseTypeStrategy,
@ -3684,8 +3750,9 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
WatchCommandType.startPreparedTimedStep => WatchCommandType.startPreparedTimedStep =>
projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep, projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep,
WatchCommandType.completeCurrentStep => WatchCommandType.completeCurrentStep =>
projection.stepType == WatchStepType.reps && (projection.stepType == WatchStepType.reps &&
projection.stepTargetValue != null, projection.stepTargetValue != null) ||
projection.manualScoreScope == WatchManualScoreScope.step,
WatchCommandType.skipCurrentStep => projection.secondaryActions.contains( WatchCommandType.skipCurrentStep => projection.secondaryActions.contains(
WatchSecondaryAction.skipCurrentStep, WatchSecondaryAction.skipCurrentStep,
), ),
@ -3713,7 +3780,10 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
bool _allowsStaleRevision(WatchCommandType type) { bool _allowsStaleRevision(WatchCommandType type) {
return type == WatchCommandType.incrementScore || return type == WatchCommandType.incrementScore ||
type == WatchCommandType.decrementScore; type == WatchCommandType.decrementScore ||
type == WatchCommandType.startCurrentExercise ||
type == WatchCommandType.startPreparedTimedStep ||
type == WatchCommandType.completeCurrentStep;
} }
Future<WatchCommandAck> _route( Future<WatchCommandAck> _route(
@ -4050,16 +4120,18 @@ final class WatchSessionProjectionProjector {
required this.clock, required this.clock,
required this.ids, required this.ids,
required this.originDeviceId, required this.originDeviceId,
this.activeWorkoutSensorUseCases,
}); });
final ActiveSessionRepository sessionRepository; final ActiveSessionRepository sessionRepository;
final Clock clock; final Clock clock;
final IdGenerator ids; final IdGenerator ids;
final String originDeviceId; final String originDeviceId;
final ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases;
Future<WatchSessionProjection> project({required int revision}) async { Future<WatchSessionProjection> project({required int revision}) async {
final now = clock.now(); final now = clock.now();
final session = await sessionRepository.findOpen(); var session = await sessionRepository.findOpen();
if (session == null || if (session == null ||
session.status == ActiveWorkoutStatus.completed || session.status == ActiveWorkoutStatus.completed ||
session.status == ActiveWorkoutStatus.abandoned || session.status == ActiveWorkoutStatus.abandoned ||
@ -4078,6 +4150,24 @@ final class WatchSessionProjectionProjector {
statusLabel: 'Aucune séance en cours', statusLabel: 'Aucune séance en cours',
); );
} }
session = await _advanceExpiredRestBeforeProjection(session, now);
if (session.status == ActiveWorkoutStatus.completed ||
session.status == ActiveWorkoutStatus.abandoned ||
session.status == ActiveWorkoutStatus.savedExit) {
return WatchSessionProjection(
deviceSessionId: '',
revision: revision,
projectedAtEpochMs: _epochMs(now),
expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
phase: WatchSessionPhase.noActiveSession,
phoneReachable: true,
seriesIndex: 0,
seriesTotal: 0,
exerciseName: '',
primaryAction: WatchPrimaryAction.none,
statusLabel: 'Aucune séance en cours',
);
}
final snapshot = _findExerciseSnapshot( final snapshot = _findExerciseSnapshot(
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
@ -4121,7 +4211,9 @@ final class WatchSessionProjectionProjector {
); );
final stepView = await _readStepViewIfStarted(session, snapshot); final stepView = await _readStepViewIfStarted(session, snapshot);
final stepState = stepView?.state; final stepState = stepView?.state;
final currentStep = stepView?.currentStep ?? _initialStep(snapshot); final currentStep = stepView == null
? _initialStep(snapshot)
: stepView.currentStep;
final manualScoreProjection = _watchManualScoreProjection( final manualScoreProjection = _watchManualScoreProjection(
snapshot: snapshot, snapshot: snapshot,
currentStep: currentStep, currentStep: currentStep,
@ -4132,22 +4224,25 @@ final class WatchSessionProjectionProjector {
final expectedPassages = _expectedPassages(snapshot); final expectedPassages = _expectedPassages(snapshot);
final projectedAtEpochMs = _epochMs(now); final projectedAtEpochMs = _epochMs(now);
final exerciseTargetTimeMs = snapshot.timeEnabled
? snapshot.targetTimeSeconds == null
? null
: snapshot.targetTimeSeconds! * 1000
: null;
final scoreStopwatchTimer = scoreStopwatch == null final scoreStopwatchTimer = scoreStopwatch == null
? null ? null
: _scoreStopwatchTimerProjection(scoreStopwatch, now); : _scoreStopwatchTimerProjection(scoreStopwatch, now);
final setTimerProjection = setTimer == null final setTimerProjection = setTimer == null
? null ? _pendingSetTimerProjection(snapshot, now)
: _setTimerProjection(setTimer, now); : _setTimerProjection(setTimer, now, targetMs: exerciseTargetTimeMs);
final allTimers = <WatchTimerProjection>[ final allTimers = <WatchTimerProjection>[
if (activeRest != null) _restTimerProjection(activeRest, now), if (activeRest != null) _restTimerProjection(activeRest, now),
if (currentStep != null && currentStep.type == ExerciseStepType.time) if (currentStep != null && currentStep.type == ExerciseStepType.time)
_stepTimerProjection(stepState, currentStep, now), _stepTimerProjection(stepState, currentStep, now),
?setTimerProjection,
?scoreStopwatchTimer, ?scoreStopwatchTimer,
?setTimerProjection,
]; ];
final displayTimers = allTimers final displayTimers = allTimers.toList(growable: false);
.where((timer) => timer.kind != WatchTimerKind.setTimer)
.toList(growable: false);
final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first; final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first;
final secondaryTimers = dominantTimer == null final secondaryTimers = dominantTimer == null
? const <WatchTimerProjection>[] ? const <WatchTimerProjection>[]
@ -4164,6 +4259,9 @@ final class WatchSessionProjectionProjector {
_sessionHealthServicesExerciseTypeStrategy( _sessionHealthServicesExerciseTypeStrategy(
session.resolvedTemplateSnapshotJson, session.resolvedTemplateSnapshotJson,
); );
final sensorState = activeWorkoutSensorUseCases?.current(
session.metadata.id,
);
return WatchSessionProjection( return WatchSessionProjection(
deviceSessionId: session.metadata.id, deviceSessionId: session.metadata.id,
@ -4193,6 +4291,12 @@ final class WatchSessionProjectionProjector {
null => null, null => null,
}, },
stepTargetValue: currentStep?.defaultTargetValue, stepTargetValue: currentStep?.defaultTargetValue,
exerciseTargetReps: snapshot.repsEnabled ? snapshot.targetReps : null,
exerciseTargetTimeMs: exerciseTargetTimeMs,
exerciseTargetScore: snapshot.scoreEnabled ? snapshot.targetScore : null,
exerciseTargetScoreTimeMs: snapshot.scoreEnabled
? snapshot.targetScoreTimeMs
: null,
dominantTimer: dominantTimer, dominantTimer: dominantTimer,
secondaryTimers: secondaryTimers, secondaryTimers: secondaryTimers,
primaryAction: _primaryAction(phase), primaryAction: _primaryAction(phase),
@ -4225,6 +4329,7 @@ final class WatchSessionProjectionProjector {
manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue, manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
manualScoreScope: manualScoreProjection?.scope, manualScoreScope: manualScoreProjection?.scope,
healthServicesExerciseTypeStrategy: healthServicesExerciseTypeStrategy, healthServicesExerciseTypeStrategy: healthServicesExerciseTypeStrategy,
totalDistanceM: sensorState?.latestDistanceMeters,
); );
} }
@ -4288,6 +4393,50 @@ final class WatchSessionProjectionProjector {
..sort((left, right) => right.startedAt.compareTo(left.startedAt)); ..sort((left, right) => right.startedAt.compareTo(left.startedAt));
return active.isEmpty ? null : active.first; return active.isEmpty ? null : active.first;
} }
Future<ActiveWorkoutSession> _advanceExpiredRestBeforeProjection(
ActiveWorkoutSession session,
DateTime now,
) async {
final activeRest = await _findActiveRest(session.metadata.id);
if (activeRest == null ||
activeRest.pausedAt != null ||
activeRest.remainingMillisecondsAt(now) > 0) {
return session;
}
await sessionRepository.saveRestState(
activeRest.copyWith(
metadata: activeRest.metadata.touch(now),
endedAt: now,
),
);
final next = _nextPositionAfter(
session.resolvedTemplateSnapshotJson,
programIndex: activeRest.afterProgramIndex,
exerciseIndex: activeRest.afterExerciseIndex,
setIndex: activeRest.afterSetIndex,
);
final updated = next == null
? session.copyWith(
status: ActiveWorkoutStatus.completed,
endedAt: now,
lastPersistedAt: now,
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
metadata: session.metadata.touch(now),
)
: session.copyWith(
status: ActiveWorkoutStatus.running,
currentProgramIndex: next.programIndex,
currentExerciseIndex: next.exerciseIndex,
currentSetIndex: next.setIndex,
lastPersistedAt: now,
metadata: session.metadata.touch(now),
);
await sessionRepository.save(updated);
return updated;
}
} }
final class _WatchManualScoreProjectionData { final class _WatchManualScoreProjectionData {
@ -4326,8 +4475,6 @@ _WatchManualScoreProjectionData? _watchManualScoreProjection({
return _WatchManualScoreProjectionData( return _WatchManualScoreProjectionData(
scope: WatchManualScoreScope.step, scope: WatchManualScoreScope.step,
value: result?.actualScore ?? 0, value: result?.actualScore ?? 0,
targetValue: step.defaultTargetScore,
targetLabel: step.defaultTargetScore == null ? null : 'Cible',
repsTargetValue: step.type == ExerciseStepType.reps repsTargetValue: step.type == ExerciseStepType.reps
? step.defaultTargetValue ? step.defaultTargetValue
: null, : null,
@ -4508,7 +4655,7 @@ WatchTimerProjection? _scoreStopwatchTimerProjection(
? WatchTimerRunState.running ? WatchTimerRunState.running
: WatchTimerRunState.paused, : WatchTimerRunState.paused,
referenceEpochMs: _epochMs(now), referenceEpochMs: _epochMs(now),
accumulatedMs: state.accumulatedMs, accumulatedMs: state.elapsedMillisecondsAt(now),
startedAtEpochMs: state.status == ActiveScoreStopwatchStatus.running startedAtEpochMs: state.status == ActiveScoreStopwatchStatus.running
? _epochMs(state.startedAt) ? _epochMs(state.startedAt)
: null, : null,
@ -4517,8 +4664,9 @@ WatchTimerProjection? _scoreStopwatchTimerProjection(
WatchTimerProjection? _setTimerProjection( WatchTimerProjection? _setTimerProjection(
ActiveSetTimerState state, ActiveSetTimerState state,
DateTime now, DateTime now, {
) { int? targetMs,
}) {
if (state.status == ActiveSetTimerStatus.stopped || if (state.status == ActiveSetTimerStatus.stopped ||
state.status == ActiveSetTimerStatus.skipped) { state.status == ActiveSetTimerStatus.skipped) {
return null; return null;
@ -4531,11 +4679,30 @@ WatchTimerProjection? _setTimerProjection(
? WatchTimerRunState.running ? WatchTimerRunState.running
: WatchTimerRunState.paused, : WatchTimerRunState.paused,
referenceEpochMs: _epochMs(now), referenceEpochMs: _epochMs(now),
accumulatedMs: state.accumulatedMs, accumulatedMs: state.elapsedMillisecondsAt(now),
startedAtEpochMs: startedAtEpochMs:
state.status == ActiveSetTimerStatus.running && state.startedAt != null state.status == ActiveSetTimerStatus.running && state.startedAt != null
? _epochMs(state.startedAt!) ? _epochMs(state.startedAt!)
: null, : null,
targetMs: targetMs,
);
}
WatchTimerProjection? _pendingSetTimerProjection(
_ResolvedExerciseSnapshot snapshot,
DateTime now,
) {
if (!snapshot.timeEnabled || snapshot.targetTimeSeconds == null) {
return null;
}
return WatchTimerProjection(
kind: WatchTimerKind.setTimer,
label: 'Temps de série',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.stopped,
referenceEpochMs: _epochMs(now),
accumulatedMs: 0,
targetMs: snapshot.targetTimeSeconds! * 1000,
); );
} }
@ -5457,6 +5624,8 @@ final class CloseWorkoutSessionUseCase {
required this.sessionRepository, required this.sessionRepository,
required this.historyRepository, required this.historyRepository,
this.telemetryRepository, this.telemetryRepository,
this.healthConnectUseCases,
this.activeWorkoutSensorUseCases,
required this.clock, required this.clock,
required this.ids, required this.ids,
required this.originDeviceId, required this.originDeviceId,
@ -5465,6 +5634,8 @@ final class CloseWorkoutSessionUseCase {
final ActiveSessionRepository sessionRepository; final ActiveSessionRepository sessionRepository;
final WorkoutHistoryRepository historyRepository; final WorkoutHistoryRepository historyRepository;
final WorkoutTelemetryRepository? telemetryRepository; final WorkoutTelemetryRepository? telemetryRepository;
final HealthConnectUseCases? healthConnectUseCases;
final ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases;
final Clock clock; final Clock clock;
final IdGenerator ids; final IdGenerator ids;
final String originDeviceId; final String originDeviceId;
@ -5510,6 +5681,11 @@ final class CloseWorkoutSessionUseCase {
final telemetrySamples = final telemetrySamples =
await telemetryRepository?.listSamples(session.metadata.id) ?? await telemetryRepository?.listSamples(session.metadata.id) ??
const <WorkoutTelemetrySample>[]; const <WorkoutTelemetrySample>[];
final totalSteps =
_totalStepsFromSamples(telemetrySamples) ??
activeWorkoutSensorUseCases
?.current(session.metadata.id)
?.latestStepCount;
final history = WorkoutHistory( final history = WorkoutHistory(
metadata: EntityMetadata( metadata: EntityMetadata(
id: historyId, id: historyId,
@ -5582,16 +5758,19 @@ final class CloseWorkoutSessionUseCase {
'telemetrySamples': telemetrySamples 'telemetrySamples': telemetrySamples
.map(_workoutTelemetrySampleSnapshotJson) .map(_workoutTelemetrySampleSnapshotJson)
.toList(), .toList(),
'totalSteps': totalSteps,
}), }),
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm, minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm, averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm, maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm,
totalDistanceMeters: telemetryAggregate?.totalDistanceMeters, totalDistanceMeters: telemetryAggregate?.totalDistanceMeters,
totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal, totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal,
totalSteps: totalSteps,
results: historyResults, results: historyResults,
stepResults: historyStepResults, stepResults: historyStepResults,
); );
await historyRepository.save(history); await historyRepository.save(history);
await healthConnectUseCases?.exportAfterWorkout(history);
return history; return history;
} }
} }
@ -5822,6 +6001,7 @@ final class WorkoutTelemetryUseCases {
heartRateBpm: state.heartRateBpm, heartRateBpm: state.heartRateBpm,
distanceMeters: state.distanceMeters, distanceMeters: state.distanceMeters,
caloriesKcal: state.caloriesKcal, caloriesKcal: state.caloriesKcal,
stepCount: state.stepCount,
); );
} }
@ -5859,6 +6039,9 @@ final class WorkoutTelemetryUseCases {
heartRateBpm: hasHeartRate ? sample.heartRateBpm : null, heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
distanceMeters: hasDistance ? sample.distanceMeters : null, distanceMeters: hasDistance ? sample.distanceMeters : null,
caloriesKcal: hasCalories ? sample.caloriesKcal : null, caloriesKcal: hasCalories ? sample.caloriesKcal : null,
stepCount: sample.stepCount != null && sample.stepCount! >= 0
? sample.stepCount
: null,
); );
} }
@ -6135,6 +6318,7 @@ Map<String, Object?> _workoutTelemetrySampleSnapshotJson(
'heartRateBpm': sample.heartRateBpm, 'heartRateBpm': sample.heartRateBpm,
'distanceMeters': sample.distanceMeters, 'distanceMeters': sample.distanceMeters,
'caloriesKcal': sample.caloriesKcal, 'caloriesKcal': sample.caloriesKcal,
'stepCount': sample.stepCount,
}; };
} }
@ -6177,12 +6361,26 @@ List<WorkoutTelemetrySample> _telemetrySamplesFromHistorySnapshot(
heartRateBpm: json['heartRateBpm'] as int?, heartRateBpm: json['heartRateBpm'] as int?,
distanceMeters: (json['distanceMeters'] as num?)?.toDouble(), distanceMeters: (json['distanceMeters'] as num?)?.toDouble(),
caloriesKcal: (json['caloriesKcal'] as num?)?.toDouble(), caloriesKcal: (json['caloriesKcal'] as num?)?.toDouble(),
stepCount: (json['stepCount'] as num?)?.toInt(),
), ),
); );
} }
return output; return output;
} }
int? _totalStepsFromSamples(List<WorkoutTelemetrySample> samples) {
int? totalSteps;
for (final sample in samples) {
final stepCount = sample.stepCount;
if (stepCount != null &&
stepCount >= 0 &&
(totalSteps == null || stepCount > totalSteps)) {
totalSteps = stepCount;
}
}
return totalSteps;
}
bool _matchesTelemetryScope( bool _matchesTelemetryScope(
WorkoutTelemetrySample sample, { WorkoutTelemetrySample sample, {
required WorkoutTelemetryAggregateScope scope, required WorkoutTelemetryAggregateScope scope,
@ -6390,6 +6588,131 @@ final class _TelemetryAggregateBuilder {
} }
} }
final class HealthConnectUseCases {
const HealthConnectUseCases({
required this.gateway,
required this.historyRepository,
this.telemetryUseCases,
});
final HealthConnectGateway gateway;
final WorkoutHistoryRepository historyRepository;
final WorkoutTelemetryUseCases? telemetryUseCases;
Future<HealthConnectConnectionState> connectionState() {
return gateway.connectionState();
}
Future<HealthConnectConnectionState> requestPermissions() {
return gateway.requestPermissions();
}
Future<bool> openSettings() {
return gateway.openSettings();
}
Future<HealthConnectWorkoutExportResult> exportHistory(
String historyId,
) async {
final history = await historyRepository.findById(historyId);
if (history == null) {
return const HealthConnectWorkoutExportResult(
status: HealthConnectExportStatus.invalidWorkout,
workout: null,
errorMessage: 'Workout history not found.',
);
}
return exportAfterWorkout(history);
}
Future<HealthConnectWorkoutExportResult> exportAfterWorkout(
WorkoutHistory history,
) async {
final workout = await _workoutExportFromHistory(history);
if (!workout.isValid) {
return HealthConnectWorkoutExportResult(
status: HealthConnectExportStatus.invalidWorkout,
workout: workout,
);
}
try {
final state = await gateway.connectionState();
switch (state.status) {
case HealthConnectConnectionStatus.unavailable:
return HealthConnectWorkoutExportResult(
status: HealthConnectExportStatus.unavailable,
workout: workout,
);
case HealthConnectConnectionStatus.providerUpdateRequired:
return HealthConnectWorkoutExportResult(
status: HealthConnectExportStatus.providerUpdateRequired,
workout: workout,
);
case HealthConnectConnectionStatus.accessBlocked:
return HealthConnectWorkoutExportResult(
status: HealthConnectExportStatus.accessBlocked,
workout: workout,
);
case HealthConnectConnectionStatus.permissionsRequired:
return HealthConnectWorkoutExportResult(
status: HealthConnectExportStatus.permissionsRequired,
workout: workout,
);
case HealthConnectConnectionStatus.connected:
await gateway.exportWorkout(workout);
return HealthConnectWorkoutExportResult(
status: HealthConnectExportStatus.exported,
workout: workout,
);
}
} on Object catch (error) {
return HealthConnectWorkoutExportResult(
status: HealthConnectExportStatus.failed,
workout: workout,
errorMessage: error.toString(),
);
}
}
Future<HealthConnectWorkoutExport> _workoutExportFromHistory(
WorkoutHistory history,
) async {
final samples =
await telemetryUseCases?.listSamplesForHistory(history) ??
const <WorkoutTelemetrySample>[];
return HealthConnectWorkoutExport(
historyId: history.metadata.id,
title: history.nameSnapshot,
startedAt: history.startedAt,
endedAt: history.endedAt,
durationMs: history.totalActiveMs,
totalDistanceMeters: _positiveDoubleOrNull(history.totalDistanceMeters),
totalCaloriesKcal: _positiveDoubleOrNull(history.totalCaloriesKcal),
minHeartRateBpm: history.minHeartRateBpm,
averageHeartRateBpm: history.averageHeartRateBpm,
maxHeartRateBpm: history.maxHeartRateBpm,
heartRateSamples: [
for (final sample in samples)
if (sample.heartRateBpm != null &&
!sample.capturedAt.isBefore(history.startedAt) &&
!sample.capturedAt.isAfter(history.endedAt))
HealthConnectHeartRateSample(
time: sample.capturedAt,
beatsPerMinute: sample.heartRateBpm!,
),
],
);
}
}
double? _positiveDoubleOrNull(double? value) {
if (value == null || value <= 0) {
return null;
}
return value;
}
final class ActiveWorkoutSensorState { final class ActiveWorkoutSensorState {
const ActiveWorkoutSensorState({ const ActiveWorkoutSensorState({
required this.sessionId, required this.sessionId,
@ -6403,6 +6726,7 @@ final class ActiveWorkoutSensorState {
this.latestDistanceMeters, this.latestDistanceMeters,
required this.latestDistanceAvailable, required this.latestDistanceAvailable,
this.latestCaloriesKcal, this.latestCaloriesKcal,
this.latestStepCount,
required this.estimatedCaloriesKcal, required this.estimatedCaloriesKcal,
}); });
@ -6417,6 +6741,7 @@ final class ActiveWorkoutSensorState {
final double? latestDistanceMeters; final double? latestDistanceMeters;
final bool latestDistanceAvailable; final bool latestDistanceAvailable;
final double? latestCaloriesKcal; final double? latestCaloriesKcal;
final int? latestStepCount;
final double estimatedCaloriesKcal; final double estimatedCaloriesKcal;
} }
@ -6445,7 +6770,9 @@ final class ActiveWorkoutSensorUseCases {
sample.distanceMeters != null && sample.distanceMeters! >= 0; sample.distanceMeters != null && sample.distanceMeters! >= 0;
final hasCalories = final hasCalories =
sample.caloriesKcal != null && sample.caloriesKcal! >= 0; sample.caloriesKcal != null && sample.caloriesKcal! >= 0;
if (sessionId.isEmpty || (!hasHeartRate && !hasDistance && !hasCalories)) { final hasSteps = sample.stepCount != null && sample.stepCount! >= 0;
if (sessionId.isEmpty ||
(!hasHeartRate && !hasDistance && !hasCalories && !hasSteps)) {
return null; return null;
} }
final recordedAt = sample.capturedAtEpochMs > 0 final recordedAt = sample.capturedAtEpochMs > 0
@ -6463,6 +6790,7 @@ final class ActiveWorkoutSensorUseCases {
heartRateBpm: hasHeartRate ? sample.heartRateBpm : null, heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
distanceMeters: hasDistance ? sample.distanceMeters : null, distanceMeters: hasDistance ? sample.distanceMeters : null,
caloriesKcal: hasCalories ? sample.caloriesKcal : null, caloriesKcal: hasCalories ? sample.caloriesKcal : null,
stepCount: hasSteps ? sample.stepCount : null,
recordedAt: recordedAt, recordedAt: recordedAt,
); );
if (snapshot == null) { if (snapshot == null) {
@ -6497,6 +6825,7 @@ final class _ActiveWorkoutSensorAccumulator {
double? _latestDistanceMeters; double? _latestDistanceMeters;
var _latestDistanceAvailable = false; var _latestDistanceAvailable = false;
double? _latestCaloriesKcal; double? _latestCaloriesKcal;
int? _latestStepCount;
ActiveWorkoutSensorState get snapshot { ActiveWorkoutSensorState get snapshot {
final averageHeartRateBpm = _heartRateSampleCount == 0 final averageHeartRateBpm = _heartRateSampleCount == 0
@ -6514,6 +6843,7 @@ final class _ActiveWorkoutSensorAccumulator {
latestDistanceMeters: _latestDistanceMeters, latestDistanceMeters: _latestDistanceMeters,
latestDistanceAvailable: _latestDistanceAvailable, latestDistanceAvailable: _latestDistanceAvailable,
latestCaloriesKcal: _latestCaloriesKcal, latestCaloriesKcal: _latestCaloriesKcal,
latestStepCount: _latestStepCount,
estimatedCaloriesKcal: _estimatedCaloriesKcal( estimatedCaloriesKcal: _estimatedCaloriesKcal(
averageHeartRateBpm: averageHeartRateBpm ?? 0, averageHeartRateBpm: averageHeartRateBpm ?? 0,
activeDuration: _latestSampleAt.difference(firstSampleAt), activeDuration: _latestSampleAt.difference(firstSampleAt),
@ -6526,6 +6856,7 @@ final class _ActiveWorkoutSensorAccumulator {
required int? heartRateBpm, required int? heartRateBpm,
required double? distanceMeters, required double? distanceMeters,
required double? caloriesKcal, required double? caloriesKcal,
required int? stepCount,
required DateTime recordedAt, required DateTime recordedAt,
}) { }) {
final stableSampleId = sampleId?.trim(); final stableSampleId = sampleId?.trim();
@ -6564,6 +6895,10 @@ final class _ActiveWorkoutSensorAccumulator {
(_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) { (_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) {
_latestCaloriesKcal = caloriesKcal; _latestCaloriesKcal = caloriesKcal;
} }
if (stepCount != null &&
(_latestStepCount == null || stepCount >= _latestStepCount!)) {
_latestStepCount = stepCount;
}
return snapshot; return snapshot;
} }
} }

View File

@ -52,7 +52,7 @@ final class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 26; int get schemaVersion => 27;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -62,6 +62,7 @@ final class AppDatabase extends _$AppDatabase {
await _migrateToSchema15(); await _migrateToSchema15();
await _migrateToSchema16(migrator); await _migrateToSchema16(migrator);
await _migrateToSchema25(); await _migrateToSchema25();
await _migrateToSchema27();
await _createIndexes(); await _createIndexes();
}, },
onUpgrade: (migrator, from, to) async { onUpgrade: (migrator, from, to) async {
@ -145,6 +146,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 26) { if (from < 26) {
await _migrateToSchema26(migrator); await _migrateToSchema26(migrator);
} }
if (from < 27) {
await _migrateToSchema27();
}
await _createIndexes(); await _createIndexes();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@ -485,6 +489,23 @@ extension on AppDatabase {
await migrator.createTable(activeWorkoutTelemetryWindowStates); await migrator.createTable(activeWorkoutTelemetryWindowStates);
} }
Future<void> _migrateToSchema27() async {
await customStatement('''
CREATE TABLE IF NOT EXISTS entitlement_snapshots (
id TEXT NOT NULL PRIMARY KEY CHECK (id = 'current'),
entitlement TEXT NOT NULL CHECK (entitlement IN ('free', 'pro')),
can_sync INTEGER NOT NULL CHECK (can_sync IN (0, 1)),
has_unlimited_editable_library INTEGER NOT NULL CHECK (
has_unlimited_editable_library IN (0, 1)
),
remaining_editable_slots INTEGER CHECK (
remaining_editable_slots IS NULL OR remaining_editable_slots >= 0
),
validated_at INTEGER NOT NULL
)
''');
}
Future<void> _migrateToSchema13() async { Future<void> _migrateToSchema13() async {
await customStatement('PRAGMA foreign_keys = OFF'); await customStatement('PRAGMA foreign_keys = OFF');
await customStatement(''' await customStatement('''

View File

@ -133,7 +133,11 @@ final class DriftExerciseRepository implements ExerciseRepository {
} }
final class DriftStarterSeedRepository final class DriftStarterSeedRepository
implements StarterSeedStateRepository, StarterContentRepository { implements
StarterSeedStateRepository,
StarterContentRepository,
QaContentSeedStateRepository,
QaContentRepository {
const DriftStarterSeedRepository(this.database); const DriftStarterSeedRepository(this.database);
static const _starterSeedKey = 'starter'; static const _starterSeedKey = 'starter';
@ -165,6 +169,35 @@ final class DriftStarterSeedRepository
); );
} }
@override
Future<int> readAppliedQaContentSeedVersion() async {
final row = await database
.customSelect(
'SELECT version FROM local_seed_metadata WHERE key = ? LIMIT 1',
variables: [Variable<String>(qaFunctionalContentSeedKey)],
)
.getSingleOrNull();
return row?.read<int>('version') ?? 0;
}
@override
Future<void> writeAppliedQaContentSeedVersion(
int version,
DateTime appliedAt,
) async {
await database.customStatement(
'INSERT INTO local_seed_metadata (key, version, applied_at) '
'VALUES (?, ?, ?) '
'ON CONFLICT(key) DO UPDATE SET '
'version = excluded.version, applied_at = excluded.applied_at',
[
qaFunctionalContentSeedKey,
version,
appliedAt.toUtc().millisecondsSinceEpoch,
],
);
}
@override @override
Future<bool> isLocalContentEmpty() async { Future<bool> isLocalContentEmpty() async {
final exerciseCount = await _tableCount(database.exercises.actualTableName); final exerciseCount = await _tableCount(database.exercises.actualTableName);
@ -215,6 +248,11 @@ final class DriftStarterSeedRepository
}); });
} }
@override
Future<void> insertQaContent(StarterContent content) {
return insertStarterContent(content);
}
Future<int> _tableCount(String tableName) async { Future<int> _tableCount(String tableName) async {
final row = await database final row = await database
.customSelect('SELECT COUNT(*) AS count FROM $tableName') .customSelect('SELECT COUNT(*) AS count FROM $tableName')
@ -223,6 +261,73 @@ final class DriftStarterSeedRepository
} }
} }
final class DriftEntitlementSnapshotRepository
implements EntitlementSnapshotRepository {
const DriftEntitlementSnapshotRepository(this.database);
final db.AppDatabase database;
@override
Future<EntitlementSnapshot?> read() async {
final row = await database.customSelect('''
SELECT
entitlement,
can_sync,
has_unlimited_editable_library,
remaining_editable_slots,
validated_at
FROM entitlement_snapshots
WHERE id = 'current'
LIMIT 1
''').getSingleOrNull();
if (row == null) {
return null;
}
final tier = _entitlementTierFromDb(row.read<String>('entitlement'));
final validatedAt = DateTime.fromMillisecondsSinceEpoch(
row.read<int>('validated_at'),
isUtc: true,
);
return switch (tier) {
EntitlementTier.free => EntitlementSnapshot.free(
remainingEditableSlots:
row.readNullable<int>('remaining_editable_slots') ?? 0,
validatedAt: validatedAt,
),
EntitlementTier.pro => EntitlementSnapshot.pro(validatedAt: validatedAt),
};
}
@override
Future<void> save(EntitlementSnapshot snapshot) {
return database.customStatement(
'''
INSERT INTO entitlement_snapshots (
id,
entitlement,
can_sync,
has_unlimited_editable_library,
remaining_editable_slots,
validated_at
) VALUES ('current', ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
entitlement = excluded.entitlement,
can_sync = excluded.can_sync,
has_unlimited_editable_library = excluded.has_unlimited_editable_library,
remaining_editable_slots = excluded.remaining_editable_slots,
validated_at = excluded.validated_at
''',
[
_entitlementTierToDb(snapshot.entitlement),
snapshot.canSync ? 1 : 0,
snapshot.hasUnlimitedEditableLibrary ? 1 : 0,
snapshot.remainingEditableSlots,
snapshot.validatedAt.toUtc().millisecondsSinceEpoch,
],
);
}
}
final class DriftMediaAssetRepository implements MediaAssetRepository { final class DriftMediaAssetRepository implements MediaAssetRepository {
const DriftMediaAssetRepository(this.database); const DriftMediaAssetRepository(this.database);
@ -5549,6 +5654,7 @@ domain.WorkoutHistory _workoutHistoryFromRow(
maxHeartRateBpm: row.maxHeartRateBpm, maxHeartRateBpm: row.maxHeartRateBpm,
totalDistanceMeters: row.totalDistanceMeters, totalDistanceMeters: row.totalDistanceMeters,
totalCaloriesKcal: row.totalCaloriesKcal, totalCaloriesKcal: row.totalCaloriesKcal,
totalSteps: _totalStepsFromHistorySnapshotJson(row.historySnapshotJson),
results: results, results: results,
stepResults: stepResults, stepResults: stepResults,
); );
@ -5907,6 +6013,7 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
'maxHeartRateBpm': history.maxHeartRateBpm, 'maxHeartRateBpm': history.maxHeartRateBpm,
'totalDistanceMeters': history.totalDistanceMeters, 'totalDistanceMeters': history.totalDistanceMeters,
'totalCaloriesKcal': history.totalCaloriesKcal, 'totalCaloriesKcal': history.totalCaloriesKcal,
'totalSteps': history.totalSteps,
}; };
Map<String, Object?> _localWorkoutHistoryPayload( Map<String, Object?> _localWorkoutHistoryPayload(
@ -6006,6 +6113,7 @@ Map<String, Object?> _workoutTelemetrySamplePayload(
'heartRateBpm': sample.heartRateBpm, 'heartRateBpm': sample.heartRateBpm,
'distanceMeters': sample.distanceMeters, 'distanceMeters': sample.distanceMeters,
'caloriesKcal': sample.caloriesKcal, 'caloriesKcal': sample.caloriesKcal,
'stepCount': sample.stepCount,
}; };
List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromHistoryPayload( List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromHistoryPayload(
@ -6056,12 +6164,45 @@ List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromPayload(
heartRateBpm: map['heartRateBpm'] as int?, heartRateBpm: map['heartRateBpm'] as int?,
distanceMeters: (map['distanceMeters'] as num?)?.toDouble(), distanceMeters: (map['distanceMeters'] as num?)?.toDouble(),
caloriesKcal: (map['caloriesKcal'] as num?)?.toDouble(), caloriesKcal: (map['caloriesKcal'] as num?)?.toDouble(),
stepCount: (map['stepCount'] as num?)?.toInt(),
), ),
); );
} }
return output; return output;
} }
int? _totalStepsFromHistorySnapshotJson(String snapshotJson) {
try {
final decoded = jsonDecode(snapshotJson);
if (decoded is! Map) {
return null;
}
final explicitTotal = decoded['totalSteps'];
if (explicitTotal is num && explicitTotal >= 0) {
return explicitTotal.toInt();
}
final rawSamples = decoded['telemetrySamples'];
if (rawSamples is! List) {
return null;
}
int? totalSteps;
for (final rawSample in rawSamples) {
if (rawSample is! Map) {
continue;
}
final stepCount = rawSample['stepCount'];
if (stepCount is num &&
stepCount >= 0 &&
(totalSteps == null || stepCount > totalSteps)) {
totalSteps = stepCount.toInt();
}
}
return totalSteps;
} on FormatException {
return null;
}
}
List<domain.WorkoutTelemetryAggregate> _workoutTelemetryAggregatesFromSamples( List<domain.WorkoutTelemetryAggregate> _workoutTelemetryAggregatesFromSamples(
List<domain.WorkoutTelemetrySample> samples, List<domain.WorkoutTelemetrySample> samples,
) { ) {
@ -6311,6 +6452,9 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?, maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(), totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(),
totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(), totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(),
totalSteps:
(payload['totalSteps'] as num?)?.toInt() ??
_totalStepsFromHistorySnapshotJson(historySnapshotJson),
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata), results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
stepResults: _workoutHistoryStepResultsFromPayload( stepResults: _workoutHistoryStepResultsFromPayload(
payload['stepResults'], payload['stepResults'],
@ -6890,6 +7034,17 @@ String _syncResourceTypeToDb(SyncResourceType type) => switch (type) {
SyncResourceType.mediaAsset => 'mediaAsset', SyncResourceType.mediaAsset => 'mediaAsset',
}; };
String _entitlementTierToDb(EntitlementTier tier) => switch (tier) {
EntitlementTier.free => 'free',
EntitlementTier.pro => 'pro',
};
EntitlementTier _entitlementTierFromDb(String value) => switch (value) {
'free' => EntitlementTier.free,
'pro' => EntitlementTier.pro,
_ => throw domain.DomainException('Unknown entitlement tier: $value'),
};
SyncResourceType _syncResourceTypeFromDb(String value) => switch (value) { SyncResourceType _syncResourceTypeFromDb(String value) => switch (value) {
'exercise' => SyncResourceType.exercise, 'exercise' => SyncResourceType.exercise,
'program' => SyncResourceType.program, 'program' => SyncResourceType.program,

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../application/application.dart'; import '../../application/application.dart';
@ -27,11 +28,20 @@ final class HttpApiClient {
static String defaultBaseUrlFor({ static String defaultBaseUrlFor({
required bool isAndroid, required bool isAndroid,
String configuredBaseUrl = _configuredBaseUrl, String configuredBaseUrl = _configuredBaseUrl,
bool isReleaseMode = kReleaseMode,
}) { }) {
final configured = configuredBaseUrl.trim(); final configured = configuredBaseUrl.trim();
if (configured.isNotEmpty) { if (configured.isNotEmpty) {
if (isReleaseMode && Uri.tryParse(configured)?.scheme != 'https') {
throw StateError(
'GAMETIME_API_BASE_URL must be an HTTPS URL in release builds.',
);
}
return configured; return configured;
} }
if (isReleaseMode) {
throw StateError('GAMETIME_API_BASE_URL is required in release builds.');
}
if (isAndroid) { if (isAndroid) {
return androidEmulatorDefaultBaseUrl; return androidEmulatorDefaultBaseUrl;
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'monetization_ui.dart';
import 'theme.dart'; import 'theme.dart';
final class ShareInboxScreen extends StatefulWidget { final class ShareInboxScreen extends StatefulWidget {
@ -176,6 +177,11 @@ final class _ShareInboxTile extends StatelessWidget {
], ],
), ),
if (item.status == ShareInboxStatus.pending) ...[ if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
QuotaImpactNotice(
acceptedItemCount: _quotaImpactCount(item, summary),
acceptedItemLabel: _quotaImpactLabel(item.resourceType),
),
const SizedBox(height: 16), const SizedBox(height: 16),
if (item.resourceType == ShareResourceType.pack) if (item.resourceType == ShareResourceType.pack)
FilledButton(onPressed: onOpen, child: const Text('Voir le pack')) FilledButton(onPressed: onOpen, child: const Text('Voir le pack'))
@ -273,6 +279,11 @@ final class _SharePackDetailScreen extends StatelessWidget {
title: Text(name), title: Text(name),
), ),
if (item.status == ShareInboxStatus.pending) ...[ if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
QuotaImpactNotice(
acceptedItemCount: _quotaImpactCount(item, summary),
acceptedItemLabel: _quotaImpactLabel(item.resourceType),
),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: onImport == null onPressed: onImport == null
@ -390,6 +401,22 @@ String _packBadge(int count) {
return 'Pack · $count séance${count > 1 ? 's' : ''}'; return 'Pack · $count séance${count > 1 ? 's' : ''}';
} }
int _quotaImpactCount(ShareInboxItem item, _InboxSummary summary) {
return switch (item.resourceType) {
ShareResourceType.program => 1,
ShareResourceType.workoutTemplate => 1,
ShareResourceType.pack => summary.itemCount,
};
}
String _quotaImpactLabel(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'programme',
ShareResourceType.workoutTemplate => 'séance',
ShareResourceType.pack => 'séance',
};
}
IconData _resourceIcon(ShareResourceType type) { IconData _resourceIcon(ShareResourceType type) {
return switch (type) { return switch (type) {
ShareResourceType.program => Icons.list_alt, ShareResourceType.program => Icons.list_alt,

View File

@ -2,7 +2,7 @@ name: gametime
description: Offline-first workout planning and tracking app. description: Offline-first workout planning and tracking app.
publish_to: 'none' publish_to: 'none'
version: 0.1.0+1 version: 0.1.1+2
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0

View File

@ -1216,10 +1216,19 @@ void main() {
totalCaloriesKcal: 56, totalCaloriesKcal: 56,
), ),
); );
final activeWorkoutSensorUseCases =
ActiveWorkoutSensorUseCases(clock: clock)..recordTelemetrySample(
WatchSensorSample(
sessionId: session.metadata.id,
capturedAtEpochMs: clock.now().millisecondsSinceEpoch,
stepCount: 1320,
),
);
final useCase = CloseWorkoutSessionUseCase( final useCase = CloseWorkoutSessionUseCase(
sessionRepository: sessionRepository, sessionRepository: sessionRepository,
historyRepository: historyRepository, historyRepository: historyRepository,
telemetryRepository: telemetryRepository, telemetryRepository: telemetryRepository,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
clock: clock, clock: clock,
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1', originDeviceId: 'device-1',
@ -1236,7 +1245,13 @@ void main() {
expect(history.maxHeartRateBpm, 151); expect(history.maxHeartRateBpm, 151);
expect(history.totalDistanceMeters, 720); expect(history.totalDistanceMeters, 720);
expect(history.totalCaloriesKcal, 56); expect(history.totalCaloriesKcal, 56);
expect(history.totalSteps, 1320);
expect(
jsonDecode(history.historySnapshotJson),
containsPair('totalSteps', 1320),
);
expect(historyRepository.histories.single, history); expect(historyRepository.histories.single, history);
await activeWorkoutSensorUseCases.dispose();
}); });
test('adjustRestSeconds persists adjusted rest duration', () async { test('adjustRestSeconds persists adjusted rest duration', () async {
@ -3240,6 +3255,7 @@ void main() {
heartRateBpm: 120, heartRateBpm: 120,
distanceMeters: 500, distanceMeters: 500,
caloriesKcal: 42, caloriesKcal: 42,
stepCount: 900,
), ),
); );
final duplicate = useCase.recordTelemetrySample( final duplicate = useCase.recordTelemetrySample(
@ -3271,18 +3287,35 @@ void main() {
).millisecondsSinceEpoch, ).millisecondsSinceEpoch,
distanceMeters: 620, distanceMeters: 620,
caloriesKcal: 48, caloriesKcal: 48,
stepCount: 1320,
),
);
final stepsOnly = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-3',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
3,
).millisecondsSinceEpoch,
stepCount: 1480,
), ),
); );
expect(first?.latestHeartRateBpm, 120); expect(first?.latestHeartRateBpm, 120);
expect(duplicate, isNull); expect(duplicate, isNull);
expect(distanceOnly?.sampleCount, 2); expect(distanceOnly?.sampleCount, 2);
expect(stepsOnly?.sampleCount, 3);
expect(distanceOnly?.latestHeartRateBpm, 120); expect(distanceOnly?.latestHeartRateBpm, 120);
expect(distanceOnly?.minHeartRateBpm, 120); expect(distanceOnly?.minHeartRateBpm, 120);
expect(distanceOnly?.averageHeartRateBpm, 120); expect(distanceOnly?.averageHeartRateBpm, 120);
expect(distanceOnly?.maxHeartRateBpm, 120); expect(distanceOnly?.maxHeartRateBpm, 120);
expect(distanceOnly?.latestDistanceMeters, 620); expect(distanceOnly?.latestDistanceMeters, 620);
expect(distanceOnly?.latestCaloriesKcal, 48); expect(distanceOnly?.latestCaloriesKcal, 48);
expect(stepsOnly?.latestStepCount, 1480);
await useCase.dispose(); await useCase.dispose();
}, },
); );

View File

@ -22,6 +22,7 @@ void main() {
late local.DriftWorkoutTelemetryRepository telemetryRepository; late local.DriftWorkoutTelemetryRepository telemetryRepository;
late local.DriftExercisePerformanceReferenceRepository late local.DriftExercisePerformanceReferenceRepository
performanceReferenceRepository; performanceReferenceRepository;
late local.DriftEntitlementSnapshotRepository entitlementSnapshotRepository;
setUp(() { setUp(() {
database = local.AppDatabase(NativeDatabase.memory()); database = local.AppDatabase(NativeDatabase.memory());
@ -39,6 +40,9 @@ void main() {
telemetryRepository = local.DriftWorkoutTelemetryRepository(database); telemetryRepository = local.DriftWorkoutTelemetryRepository(database);
performanceReferenceRepository = performanceReferenceRepository =
local.DriftExercisePerformanceReferenceRepository(database); local.DriftExercisePerformanceReferenceRepository(database);
entitlementSnapshotRepository = local.DriftEntitlementSnapshotRepository(
database,
);
}); });
tearDown(() async { tearDown(() async {
@ -148,7 +152,33 @@ void main() {
await columnNames('workout_telemetry_aggregates'), await columnNames('workout_telemetry_aggregates'),
contains('sample_count'), contains('sample_count'),
); );
expect(database.schemaVersion, 26); expect(await columnNames('entitlement_snapshots'), contains('entitlement'));
expect(database.schemaVersion, 27);
});
test('entitlement snapshot is persisted locally for offline reads', () async {
final free = EntitlementSnapshot.free(
remainingEditableSlots: 2,
validatedAt: DateTime.utc(2026, 8, 22, 8),
);
await entitlementSnapshotRepository.save(free);
final restoredFree = await entitlementSnapshotRepository.read();
expect(restoredFree?.entitlement, EntitlementTier.free);
expect(restoredFree?.canSync, isFalse);
expect(restoredFree?.remainingEditableSlots, 2);
expect(restoredFree?.validatedAt, DateTime.utc(2026, 8, 22, 8));
final pro = EntitlementSnapshot.pro(
validatedAt: DateTime.utc(2026, 8, 22, 9),
);
await entitlementSnapshotRepository.save(pro);
final restoredPro = await entitlementSnapshotRepository.read();
expect(restoredPro?.entitlement, EntitlementTier.pro);
expect(restoredPro?.canSync, isTrue);
expect(restoredPro?.remainingEditableSlots, isNull);
expect(restoredPro?.validatedAt, DateTime.utc(2026, 8, 22, 9));
}); });
test('exercise business types persist with category fallback', () async { test('exercise business types persist with category fallback', () async {
@ -2759,6 +2789,161 @@ CREATE TABLE pending_share_actions (
}, },
); );
test(
'QA core offline journey persists exercise to progression through local repositories',
() async {
final now = DateTime.utc(2026, 8, 21, 9);
final clock = _FakeClock(now);
final ids = _FakeIds();
final exerciseUseCases = ExerciseUseCases(
repository: exerciseRepository,
programRepository: programRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final programUseCases = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: templateRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final templateUseCases = WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final sessionUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final closeUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final exercise = await exerciseUseCases.create(
name: 'QA drift offline squat',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 8,
tags: const ['qa', 'offline'],
);
final program = await programUseCases.saveConfigured(
name: 'QA drift offline program',
defaultRestSeconds: 30,
tags: const ['qa', 'offline'],
exercises: [
ProgramExerciseConfig(
sourceExerciseId: exercise.metadata.id,
exerciseNameSnapshot: exercise.name,
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
availableScoreSnapshot: exercise.hasScoreMeasure,
setsCount: 1,
enabledMeasures: const {WorkoutMeasure.reps},
targetReps: 8,
),
],
);
final template = await templateUseCases.saveConfigured(
name: 'QA drift offline template',
tags: const ['qa', 'offline'],
programs: [
WorkoutTemplateProgramConfig(
clientKey: 'program-1',
sourceProgramId: program.metadata.id,
programNameSnapshot: program.name,
defaultRestSecondsSnapshot: program.defaultRestSeconds,
programSnapshotJson: _programSnapshotJson(program),
),
],
overrides: const [],
);
final session = await sessionUseCases.startFromTemplate(
template.metadata.id,
);
await sessionUseCases.recordCurrentSetResult(
sessionId: session.metadata.id,
programSnapshotId: template.programs.single.metadata.id,
exerciseSnapshotId: program.exercises.single.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualReps: 9,
scoreInputModeSnapshot: ScoreInputMode.manual,
);
clock.value = now.add(const Duration(minutes: 6));
await sessionUseCases.complete(session.metadata.id);
final history = await closeUseCase.close(
sessionId: session.metadata.id,
nameSnapshot: template.name,
completed: true,
);
final restoredExercise = await exerciseRepository.findById(
exercise.metadata.id,
);
final restoredProgram = await programRepository.findById(
program.metadata.id,
);
final restoredTemplate = await templateRepository.findById(
template.metadata.id,
);
final restoredHistory = await historyRepository.findById(
history.metadata.id,
);
final progressionStats = await progressionStatsRepository.readGlobalStats(
ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 1)),
endedAt: now.add(const Duration(days: 1)),
),
);
final progressionSeries = await progressionStatsRepository
.readExerciseSeries(
range: ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 1)),
endedAt: now.add(const Duration(days: 1)),
),
exerciseKey: exercise.metadata.id,
measure: ProgressionMeasure.reps,
);
expect(restoredExercise, isNotNull);
expect(restoredProgram, isNotNull);
expect(restoredTemplate, isNotNull);
expect(restoredHistory, isNotNull);
expect(restoredHistory!.completed, isTrue);
expect(restoredHistory.results, hasLength(1));
expect(restoredHistory.results.single.actualReps, 9);
expect(
restoredHistory.results.single.sourceExerciseIdSnapshot,
exercise.metadata.id,
);
expect(progressionStats.completedSessionCount, 1);
expect(progressionStats.hasAnyCompletedHistory, isTrue);
expect(progressionSeries.hasAnyCompletedExerciseResult, isTrue);
expect(progressionSeries.points.single.rawValue, 9);
expect(
progressionSeries.points.single.workoutHistoryId,
history.metadata.id,
);
},
);
test( test(
'closing a session stores autonomous history rows with set snapshots', 'closing a session stores autonomous history rows with set snapshots',
() async { () async {
@ -3733,14 +3918,26 @@ final class _ExerciseRoundTripCase {
} }
final class _FakeClock implements Clock { final class _FakeClock implements Clock {
const _FakeClock(this.value); _FakeClock(this.value);
final DateTime value; DateTime value;
@override @override
DateTime now() => value; DateTime now() => value;
} }
String _programSnapshotJson(Program program) {
return WorkoutTemplateProgram.snapshotFromProgram(
metadata: _metadata(
'snapshot-${program.metadata.id}',
program.metadata.updatedAt,
),
workoutTemplateId: 'template-preview',
program: program,
position: 0,
).programSnapshotJson;
}
final class _FakeIds implements IdGenerator { final class _FakeIds implements IdGenerator {
var _next = 0; var _next = 0;

View File

@ -43,6 +43,18 @@ void main() {
expect(find.text('Programme tirs'), findsOneWidget); expect(find.text('Programme tirs'), findsOneWidget);
expect(find.text('Séance match'), findsOneWidget); expect(find.text('Séance match'), findsOneWidget);
expect(
find.text(
'À lacceptation, votre bibliothèque éditable recevra 1 programme. Le quota receveur sera vérifié au moment de lacceptation.',
),
findsOneWidget,
);
expect(
find.text(
'À lacceptation, votre bibliothèque éditable recevra 1 séance. Le quota receveur sera vérifié au moment de lacceptation.',
),
findsOneWidget,
);
expect(find.text('Accepter'), findsNWidgets(2)); expect(find.text('Accepter'), findsNWidgets(2));
expect(find.text('Refuser'), findsNWidgets(2)); expect(find.text('Refuser'), findsNWidgets(2));
@ -111,6 +123,12 @@ void main() {
expect(find.text('Pack reçu'), findsOneWidget); expect(find.text('Pack reçu'), findsOneWidget);
expect(find.text('Séance tirs'), findsOneWidget); expect(find.text('Séance tirs'), findsOneWidget);
expect(find.text('Séance jambes'), findsOneWidget); expect(find.text('Séance jambes'), findsOneWidget);
expect(
find.text(
'À lacceptation, votre bibliothèque éditable recevra 2 séances. Le quota receveur sera vérifié au moment de lacceptation.',
),
findsWidgets,
);
await tester.tap(find.text('Importer')); await tester.tap(find.text('Importer'));
await tester.pump(); await tester.pump();

138
tool/qa_device_health_connect.sh Executable file
View File

@ -0,0 +1,138 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="/home/anthony/Documents/Projects/GameTime"
ARTIFACT_BASE="${ARTIFACT_BASE:-$PROJECT_ROOT/.ideai/artifacts/qa-device}"
PHONE_SERIAL="${PHONE_SERIAL:-}"
PACKAGE_NAME="${PACKAGE_NAME:-com.idea.gametime}"
usage() {
cat <<'EOF'
Usage:
./tool/qa_device_health_connect.sh start
./tool/qa_device_health_connect.sh stop <artifact_dir>
Environment:
PHONE_SERIAL Optional adb serial for the phone under test
ARTIFACT_BASE Optional artifact root
PACKAGE_NAME Optional Android package name (default: com.idea.gametime)
EOF
}
adb_cmd() {
if [[ -n "$PHONE_SERIAL" ]]; then
adb -s "$PHONE_SERIAL" "$@"
else
adb "$@"
fi
}
require_adb() {
command -v adb >/dev/null 2>&1 || {
echo "adb not found in PATH." >&2
exit 1
}
adb start-server >/dev/null
adb_cmd get-state >/dev/null 2>&1 || {
echo "No adb device available. Connect the phone or set PHONE_SERIAL." >&2
exit 1
}
}
timestamp() {
date -u +"%Y%m%dT%H%M%SZ"
}
start_capture() {
require_adb
local dir="$ARTIFACT_BASE/health-connect-$(timestamp)"
mkdir -p "$dir"
adb devices -l >"$dir/adb-devices.txt"
adb_cmd shell getprop ro.build.fingerprint >"$dir/phone-build-fingerprint.txt"
adb_cmd shell dumpsys package "$PACKAGE_NAME" >"$dir/package-dumpsys-before.txt" || true
adb_cmd logcat -c
adb_cmd logcat -v threadtime >"$dir/logcat.txt" 2>&1 &
local pid=$!
echo "$pid" >"$dir/logcat.pid"
cat >"$dir/steps.md" <<EOF
# Health Connect manual QA
Date: $(date -u +"%Y-%m-%d %H:%M:%SZ")
Package: $PACKAGE_NAME
## Preconditions
- Fresh GameTime phone build installed on the connected Android device.
- Health Connect available on device.
- Device unlocked.
## Manual flow
1. Open GameTime.
2. Go to Paramètres > Intégrations > Health Connect.
3. Validate the visible initial status.
4. Tap "Autoriser l'accès" or "Gérer les autorisations".
5. Validate the expected path:
- permission sheet opens, or
- settings fallback opens cleanly.
6. Grant or deny permissions according to the scenario under test.
7. Return to GameTime and validate the refreshed status.
8. Start and finish a workout if export-after-workout must be checked.
9. Confirm the final Health Connect export outcome shown by the app.
## Evidence to collect
- Screenshot(s) of the visible status before and after permission handling.
- Screenshot(s) of Health Connect permission/settings UI if opened.
- Notes on whether the path used the permission contract or settings fallback.
- Result of export-after-workout if exercised.
EOF
echo "$dir"
}
stop_capture() {
local dir="${1:-}"
[[ -n "$dir" ]] || {
usage >&2
exit 2
}
require_adb
[[ -d "$dir" ]] || {
echo "Artifact directory not found: $dir" >&2
exit 1
}
if [[ -f "$dir/logcat.pid" ]]; then
kill "$(cat "$dir/logcat.pid")" >/dev/null 2>&1 || true
fi
adb_cmd shell dumpsys package "$PACKAGE_NAME" >"$dir/package-dumpsys-after.txt" || true
adb_cmd shell settings list secure >"$dir/settings-secure.txt" || true
cat <<EOF
Health Connect manual QA artifacts finalized in:
$dir
Archive expected:
- adb-devices.txt
- phone-build-fingerprint.txt
- package-dumpsys-before.txt
- package-dumpsys-after.txt
- logcat.txt
- steps.md
EOF
}
case "${1:-}" in
start)
start_capture
;;
stop)
shift
stop_capture "${1:-}"
;;
*)
usage
exit 2
;;
esac

View File

@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="/home/anthony/Documents/Projects/GameTime"
ARTIFACT_BASE="${ARTIFACT_BASE:-$PROJECT_ROOT/.ideai/artifacts/qa-device}"
PHONE_SERIAL="${PHONE_SERIAL:-}"
PACKAGE_NAME="${PACKAGE_NAME:-com.idea.gametime}"
usage() {
cat <<'EOF'
Usage:
./tool/qa_device_import_export_real.sh start
./tool/qa_device_import_export_real.sh stop <artifact_dir>
Environment:
PHONE_SERIAL Optional adb serial for the phone under test
ARTIFACT_BASE Optional artifact root
PACKAGE_NAME Optional Android package name (default: com.idea.gametime)
EOF
}
adb_cmd() {
if [[ -n "$PHONE_SERIAL" ]]; then
adb -s "$PHONE_SERIAL" "$@"
else
adb "$@"
fi
}
require_adb() {
command -v adb >/dev/null 2>&1 || {
echo "adb not found in PATH." >&2
exit 1
}
adb start-server >/dev/null
adb_cmd get-state >/dev/null 2>&1 || {
echo "No adb device available. Connect the phone or set PHONE_SERIAL." >&2
exit 1
}
}
timestamp() {
date -u +"%Y%m%dT%H%M%SZ"
}
start_capture() {
require_adb
local dir="$ARTIFACT_BASE/import-export-real-$(timestamp)"
mkdir -p "$dir"
adb devices -l >"$dir/adb-devices.txt"
adb_cmd shell getprop ro.build.fingerprint >"$dir/phone-build-fingerprint.txt"
adb_cmd shell dumpsys package "$PACKAGE_NAME" >"$dir/package-dumpsys-before.txt" || true
adb_cmd logcat -c
adb_cmd logcat -v threadtime >"$dir/logcat.txt" 2>&1 &
echo "$!" >"$dir/logcat.pid"
cat >"$dir/steps.md" <<EOF
# Import / export real-device QA
Date: $(date -u +"%Y-%m-%d %H:%M:%SZ")
Package: $PACKAGE_NAME
## Preconditions
- Fresh GameTime phone build installed on the connected Android device.
- At least one exercise, one program and one workout template available locally.
- File picker available on device.
## Manual flow
1. Open GameTime.
2. Go to the profile/settings surface exposing import/export.
3. Export local data and confirm a file is produced.
4. Record the exported filename and storage location.
5. Re-import the same file in merge mode and validate the expected result.
6. Re-import in replace-all mode only on a disposable dataset.
7. If relevant, try importing while an active workout exists and validate the expected refusal.
## Evidence to collect
- Screenshot(s) before export and after export confirmation.
- Exported filename and location.
- Screenshot(s) after merge import and replace-all import.
- Notes on any refusal caused by active workout protection.
EOF
echo "$dir"
}
stop_capture() {
local dir="${1:-}"
[[ -n "$dir" ]] || {
usage >&2
exit 2
}
require_adb
[[ -d "$dir" ]] || {
echo "Artifact directory not found: $dir" >&2
exit 1
}
[[ -f "$dir/logcat.pid" ]] && kill "$(cat "$dir/logcat.pid")" >/dev/null 2>&1 || true
adb_cmd shell dumpsys package "$PACKAGE_NAME" >"$dir/package-dumpsys-after.txt" || true
cat <<EOF
Import/export manual QA artifacts finalized in:
$dir
Archive expected:
- adb-devices.txt
- phone-build-fingerprint.txt
- package-dumpsys-before.txt
- package-dumpsys-after.txt
- logcat.txt
- steps.md
EOF
}
case "${1:-}" in
start)
start_capture
;;
stop)
shift
stop_capture "${1:-}"
;;
*)
usage
exit 2
;;
esac

154
tool/qa_device_watch_companion.sh Executable file
View File

@ -0,0 +1,154 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="/home/anthony/Documents/Projects/GameTime"
ARTIFACT_BASE="${ARTIFACT_BASE:-$PROJECT_ROOT/.ideai/artifacts/qa-device}"
PHONE_SERIAL="${PHONE_SERIAL:-}"
WATCH_SERIAL="${WATCH_SERIAL:-}"
PACKAGE_NAME="${PACKAGE_NAME:-com.idea.gametime}"
WATCH_PACKAGE_NAME="${WATCH_PACKAGE_NAME:-com.idea.gametime}"
usage() {
cat <<'EOF'
Usage:
./tool/qa_device_watch_companion.sh start
./tool/qa_device_watch_companion.sh stop <artifact_dir>
Environment:
PHONE_SERIAL Optional adb serial for the phone under test
WATCH_SERIAL Optional adb serial for the Wear device under test
ARTIFACT_BASE Optional artifact root
PACKAGE_NAME Optional phone package name
WATCH_PACKAGE_NAME Optional watch package name
EOF
}
adb_phone() {
if [[ -n "$PHONE_SERIAL" ]]; then
adb -s "$PHONE_SERIAL" "$@"
else
adb "$@"
fi
}
adb_watch() {
[[ -n "$WATCH_SERIAL" ]] || {
echo "WATCH_SERIAL is required for watch companion QA." >&2
exit 1
}
adb -s "$WATCH_SERIAL" "$@"
}
require_adb() {
command -v adb >/dev/null 2>&1 || {
echo "adb not found in PATH." >&2
exit 1
}
adb start-server >/dev/null
adb_phone get-state >/dev/null 2>&1 || {
echo "No phone adb device available." >&2
exit 1
}
adb_watch get-state >/dev/null 2>&1 || {
echo "No watch adb device available." >&2
exit 1
}
}
timestamp() {
date -u +"%Y%m%dT%H%M%SZ"
}
start_capture() {
require_adb
local dir="$ARTIFACT_BASE/watch-companion-$(timestamp)"
mkdir -p "$dir"
adb devices -l >"$dir/adb-devices.txt"
adb_phone shell getprop ro.build.fingerprint >"$dir/phone-build-fingerprint.txt"
adb_watch shell getprop ro.build.fingerprint >"$dir/watch-build-fingerprint.txt"
adb_phone shell dumpsys package "$PACKAGE_NAME" >"$dir/phone-package-dumpsys-before.txt" || true
adb_watch shell dumpsys package "$WATCH_PACKAGE_NAME" >"$dir/watch-package-dumpsys-before.txt" || true
adb_phone logcat -c
adb_watch logcat -c
adb_phone logcat -v threadtime >"$dir/phone-logcat.txt" 2>&1 &
echo "$!" >"$dir/phone-logcat.pid"
adb_watch logcat -v threadtime >"$dir/watch-logcat.txt" 2>&1 &
echo "$!" >"$dir/watch-logcat.pid"
cat >"$dir/steps.md" <<EOF
# Watch companion manual QA
Date: $(date -u +"%Y-%m-%d %H:%M:%SZ")
Phone package: $PACKAGE_NAME
Watch package: $WATCH_PACKAGE_NAME
## Preconditions
- Phone and watch connected through adb.
- Same GameTime app version installed on both.
- Watch paired to the phone and reachable.
## Manual flow
1. Launch GameTime on the phone.
2. Start a workout from the phone.
3. Validate that the active session is projected to the watch.
4. Validate visible metrics on watch, including steps if available.
5. Trigger at least one action from the watch that must affect the phone session.
6. Validate phone UI updates correctly after the watch action.
7. Finish the workout and validate the watch teardown / final state.
## Evidence to collect
- Screenshot(s) on phone and watch while a session is active.
- Notes on connection status and any visible lag.
- Notes on step visibility if data is present.
- Final verdict on session projection and round-trip actions.
EOF
echo "$dir"
}
stop_capture() {
local dir="${1:-}"
[[ -n "$dir" ]] || {
usage >&2
exit 2
}
require_adb
[[ -d "$dir" ]] || {
echo "Artifact directory not found: $dir" >&2
exit 1
}
[[ -f "$dir/phone-logcat.pid" ]] && kill "$(cat "$dir/phone-logcat.pid")" >/dev/null 2>&1 || true
[[ -f "$dir/watch-logcat.pid" ]] && kill "$(cat "$dir/watch-logcat.pid")" >/dev/null 2>&1 || true
adb_phone shell dumpsys package "$PACKAGE_NAME" >"$dir/phone-package-dumpsys-after.txt" || true
adb_watch shell dumpsys package "$WATCH_PACKAGE_NAME" >"$dir/watch-package-dumpsys-after.txt" || true
cat <<EOF
Watch companion manual QA artifacts finalized in:
$dir
Archive expected:
- adb-devices.txt
- phone-build-fingerprint.txt
- watch-build-fingerprint.txt
- phone-logcat.txt
- watch-logcat.txt
- steps.md
EOF
}
case "${1:-}" in
start)
start_capture
;;
stop)
shift
stop_capture "${1:-}"
;;
*)
usage
exit 2
;;
esac

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
exec ./.ideai/flutter-sdk-1785572580/bin/flutter test \
test/presentation/home_screen_test.dart \
test/presentation/exercise_library_screen_test.dart \
test/presentation/program_screen_test.dart \
test/presentation/workout_template_screen_test.dart \
test/presentation/workout_execution_screen_test.dart \
test/presentation/history_screen_test.dart \
test/presentation/progression_screen_test.dart \
test/presentation/profile_screen_test.dart

View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/tmp
export XDG_CONFIG_HOME=/tmp
export FLUTTER_SUPPRESS_ANALYTICS=true
export DART_SUPPRESS_ANALYTICS=true
exec ./.ideai/flutter-sdk-1785572580/bin/flutter test \
test/presentation/profile_screen_test.dart \
test/presentation/share_inbox_screen_test.dart \
test/presentation/settings_screen_test.dart \
test/application/health_connect_use_cases_test.dart \
test/application/watch_companion_projection_test.dart

View File

@ -40,7 +40,7 @@ android {
defaultConfig { defaultConfig {
// Wear OS Data Layer requires matching package names and signatures // Wear OS Data Layer requires matching package names and signatures
// across phone and watch APKs. // across phone and watch APKs.
applicationId = "com.gametime.app" applicationId = "com.idea.gametime"
minSdk = 30 minSdk = 30
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -6,7 +6,14 @@
<path <path
android:fillColor="#080A12" android:fillColor="#080A12"
android:pathData="M24,2a22,22 0,1 0,0.1 0z" /> android:pathData="M24,2a22,22 0,1 0,0.1 0z" />
<path
android:fillType="evenOdd"
android:fillColor="#C9A24A"
android:pathData="M24,6a18,18 0,1 0,0 36a18,18 0,0 0,0 -36zM24,11a13,13 0,1 1,0 26a13,13 0,0 1,0 -26z" />
<path <path
android:fillColor="#D72638" android:fillColor="#D72638"
android:pathData="M13,12h22v6H22v5h11v6H22v7h-9z" /> android:pathData="M22,12h4v12l8,5l-2.4,3.5L22,26z" />
<path
android:fillColor="#D72638"
android:pathData="M13,19h10v4h-6v4h6v4H13zM25,14h12v4h-4v14h-4V18h-4z" />
</vector> </vector>

View File

@ -4,6 +4,13 @@
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:fillType="evenOdd"
android:fillColor="#FFFFFFFF" android:fillColor="#FFFFFFFF"
android:pathData="M5,4h14v3H11v3h7v3h-7v7H5z" /> android:pathData="M12,2a10,10 0,1 0,0.1 0zM12,4.6a7.4,7.4 0,1 1,0 14.8a7.4,7.4 0,0 1,0 -14.8z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8.8,10.4h5.2v1.6h-2v6.2h-1.8V12h-1.4z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9.9,6.6h1.7v4.1l2.9,1.9l-0.9,1.3L9.9,11.3z" />
</vector> </vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -2,4 +2,5 @@
android.builtInKotlin=false android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator # This newDsl flag was added automatically by Flutter migrator
android.newDsl=false android.newDsl=false
android.useAndroidX=true
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk org.gradle.java.home=/usr/lib/jvm/java-21-openjdk

View File

@ -2,7 +2,7 @@ name: gametime_watch
description: Wear OS companion app for GameTime workout sessions. description: Wear OS companion app for GameTime workout sessions.
publish_to: 'none' publish_to: 'none'
version: 0.1.0+1 version: 0.1.1+301
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0