Initial commit: HabitForge - habit tracking application with FastAPI backend, React frontend, and Zepp integration

This commit is contained in:
2026-07-31 08:58:53 -04:00
commit c7fce339c2
124 changed files with 17558 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
# HabitForge Android Companion App
Application Android permettant de synchroniser les données de Health Connect avec votre serveur HabitForge.
## 🆕 Nouvelles Fonctionnalités
### Suivi des Synchronisations
- **Historique complet** : Visualisez l'historique de toutes les synchronisations
- **Statistiques** : Nombre de syncs réussies/échouées, métriques totales synchronisées
- **Détails** : Chaque sync affiche le temps de traitement, les données transférées et les erreurs
### Connectivité Serveur
- **Indicateur en temps réel** : Voyez immédiatement si le serveur est en ligne
- **Test de connexion** : Bouton dédié pour tester la connexion et l'authentification
- **Temps de réponse** : Affichage du temps de réponse du serveur
### Validation des Données
- **Vérification de compatibilité** : Validez que vos données Health Connect sont compatibles avec le schéma HabitForge
- **Aperçu des données** : Prévisualisation des données avant synchronisation
- **Diagnostic** : Identification des problèmes potentiels de mapping
### Configuration Avancée
- **Période de sync configurable** : Choisissez la fréquence de synchronisation automatique
- Toutes les heures
- Toutes les 3 heures
- Toutes les 6 heures (par défaut)
- Toutes les 12 heures
- Une fois par jour
## Prérequis
- Android Studio (version 2024.2.1 ou plus récente)
- JDK 17
- Un appareil Android avec Android 9+ (API 28+)
- Health Connect installé sur l'appareil
## Structure du projet
```
android-companion/
├── app/
│ ├── src/main/
│ │ ├── java/com/habitforge/companion/
│ │ │ ├── HabitForgeApp.kt # Application class
│ │ │ ├── MainActivity.kt # UI principale
│ │ │ ├── HealthConnectManager.kt # Lecture des données Health Connect
│ │ │ ├── SyncService.kt # Service de sync en arrière-plan
│ │ │ ├── SyncHistoryManager.kt # Gestion de l'historique des syncs
│ │ │ ├── ServerConnectionManager.kt # Test de connectivité serveur
│ │ │ ├── DataCompatibilityValidator.kt # Validation schéma de données
│ │ │ ├── HabitForgeApi.kt # API Retrofit pour le backend
│ │ │ └── PrivacyPolicyActivity.kt # Page de politique de confidentialité
│ │ ├── res/
│ │ │ ├── layout/ # Layouts XML
│ │ │ ├── values/ # Couleurs, strings, thèmes
│ │ │ └── drawable/ # Icônes et indicateurs de status
│ │ └── AndroidManifest.xml # Permissions Health Connect
│ └── build.gradle.kts # Dépendances
├── build.gradle.kts # Configuration root
├── settings.gradle.kts # Settings Gradle
└── gradle.properties # Propriétés Gradle
```
## Données synchronisées
L'application lit les données suivantes depuis Health Connect :
### Métriques quotidiennes
- Pas (Steps)
- Distance parcourue
- Calories brûlées
- Fréquence cardiaque (moyenne, min, max, repos)
- Sommeil (durée, phases : profond, léger, REM, éveillé)
- Poids
- SpO2
### Sessions d'exercice
- Type d'exercice (Running, Walking, Cycling, Swimming, Weight Training, etc.)
- Durée
- Heure de début/fin
- Notes
## Compilation
### Option 1 : Via Android Studio
1. Ouvrir Android Studio
2. `File` > `Open` > Sélectionner le dossier `android-companion`
3. Attendre que Gradle synchronise
4. `Build` > `Build Bundle(s) / APK(s)` > `Build APK(s)`
### Option 2 : Via ligne de commande
```powershell
# Se placer dans le dossier du projet
cd c:\dev\git\python\HabitForge\android-companion
# Compiler en mode debug
.\gradlew assembleDebug
# Le fichier APK sera dans :
# app\build\outputs\apk\debug\app-debug.apk
# Compiler en mode release (nécessite une signature)
.\gradlew assembleRelease
```
## Installation sur téléphone
### Via ADB
```powershell
adb install app\build\outputs\apk\debug\app-debug.apk
```
### Via Android Studio
- Connecter le téléphone en USB (Mode débogage activé)
- Cliquer sur "Run" (bouton vert)
## Configuration
1. **Installer Health Connect** sur le téléphone (Play Store)
2. **Ouvrir HabitForge Companion**
3. **Accorder les permissions** quand demandé
4. **Se connecter à votre compte** :
- Cliquez sur "Login"
- Entrez l'URL du serveur (ex: `http://192.168.x.x:8000`)
- Utilisez les **mêmes identifiants** que l'interface web HabitForge
5. **Tester la connexion** : Utilisez le bouton "Test Connection" pour vérifier
6. **Valider les données** : Cliquez sur "Validate Data Schema"
7. **Configurer la fréquence** : Choisissez l'intervalle de sync souhaité
8. **Synchroniser** manuellement ou laisser l'auto-sync
## Interface Utilisateur
### Cartes de l'écran principal
| Carte | Description |
|-------|-------------|
| **Health Connect** | Statut de Health Connect, bouton pour l'ouvrir |
| **Permissions** | Permissions accordées/requises, bouton pour les demander |
| **Server** | Configuration serveur, indicateur de disponibilité, test de connexion |
| **Sync Settings** | Fréquence de sync, dernière sync, bouton Sync Now |
| **Data Compatibility** | Validation du schéma, aperçu des données |
| **Sync History** | Historique récent, statistiques, bouton historique complet |
### Indicateurs de Status Serveur
| Indicateur | Signification |
|------------|---------------|
| 🟢 Online | Serveur accessible et connecté |
| 🟠 Auth needed | Serveur accessible mais non connecté |
| 🟠 Timeout | Serveur lent à répondre |
| 🔴 Offline | Serveur inaccessible |
| ⚫ Unknown | Statut non vérifié |
## Dépannage
### "Health Connect not available"
- L'appareil doit être sous Android 9+
- Installer Health Connect depuis le Play Store
### "Permissions not granted"
- Réouvrir l'app et cliquer sur "Grant Permissions"
- S'assurer que Health Connect a des données (d'autres apps comme Google Fit)
### "Login failed" / "Invalid username or password"
- Vérifier que le serveur HabitForge est accessible (utilisez "Test Connection")
- Vérifier que les identifiants sont corrects (mêmes que l'interface web)
- Consulter les logs via `adb logcat | grep HabitForge`
### "Sync failed"
- Vérifier que vous êtes bien connecté
- Vérifier l'historique des syncs pour voir le message d'erreur
### "Connection timeout"
- Vérifier que le serveur est démarré
- Vérifier la connexion réseau
- Si sur le même réseau local, utiliser l'IP locale (ex: 192.168.x.x)
### "Data validation issues"
- Cliquez sur "Validate Data Schema" pour diagnostiquer
- Vérifiez que Health Connect contient des données récentes
- Accordez toutes les permissions demandées
## API Backend requise
L'application nécessite les endpoints suivants sur votre serveur HabitForge :
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/api/health` | GET | Health check (retourne `{"status": "healthy"}`) |
| `/api/auth/token` | POST | Authentification (OAuth2 form: username, password) |
| `/api/auth/me` | GET | Récupérer les infos utilisateur connecté |
| `/api/health-connect/sync` | POST | Réception des données synchronisées |
## Sécurité
- L'authentification utilise les mêmes identifiants que l'interface web
- Les tokens JWT sont stockés dans SharedPreferences (mode privé)
- Toutes les connexions utilisent HTTPS en production
- Les données ne quittent jamais l'appareil sauf vers votre serveur configuré
- L'historique des syncs est stocké localement uniquement
- Déconnexion possible via le bouton "Logout"
+115
View File
@@ -0,0 +1,115 @@
import java.util.Properties
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
fun getBuildNumber(): Int {
val versionFile = file("version.properties")
val properties = Properties()
if (versionFile.exists()) {
properties.load(FileInputStream(versionFile))
}
val buildNumber = (properties.getProperty("build.number") ?: "0").toInt() + 1
properties.setProperty("build.number", buildNumber.toString())
properties.store(FileOutputStream(versionFile), "Auto-incremented Build Number")
return buildNumber
}
android {
namespace = "com.habitforge.companion"
compileSdk = 35
defaultConfig {
applicationId = "com.habitforge.companion"
minSdk = 28 // Health Connect requires Android 9+
targetSdk = 34
val buildNum = getBuildNumber()
versionCode = buildNum
versionName = "1.0.0.$buildNum"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Backend URL - change this to your server address
buildConfigField("String", "BACKEND_URL", "\"http://10.0.2.2:8000\"") // 10.0.2.2 = localhost from emulator
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// For production, change this to your actual server URL
buildConfigField("String", "BACKEND_URL", "\"https://your-habitforge-server.com\"")
}
debug {
buildConfigField("String", "BACKEND_URL", "\"http://10.0.2.2:8000\"")
}
}
applicationVariants.all {
val variant = this
outputs.all {
(this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl)?.outputFileName = "HabitForge-companion-v${variant.versionName}.apk"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
buildConfig = true
}
}
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.activity:activity-ktx:1.8.2")
// Health Connect SDK
implementation("androidx.health.connect:connect-client:1.1.0-alpha10")
// WorkManager for background sync
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
+11
View File
@@ -0,0 +1,11 @@
# Add project specific ProGuard rules here.
# Keep Retrofit and Gson
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.habitforge.companion.** { *; }
-keep class retrofit2.** { *; }
-keep class com.google.gson.** { *; }
# Keep Health Connect classes
-keep class androidx.health.connect.** { *; }
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for syncing with backend -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Foreground service permission for reliable syncing -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Health Connect Permissions -->
<!-- Activity Data -->
<uses-permission android:name="android.permission.health.READ_STEPS" />
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<!-- Heart & Vitals -->
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE_VARIABILITY" />
<uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION" />
<uses-permission android:name="android.permission.health.READ_RESPIRATORY_RATE" />
<uses-permission android:name="android.permission.health.READ_BLOOD_PRESSURE" />
<!-- Body Measurements -->
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
<uses-permission android:name="android.permission.health.READ_HEIGHT" />
<uses-permission android:name="android.permission.health.READ_BODY_FAT" />
<uses-permission android:name="android.permission.health.READ_BODY_WATER_MASS" />
<uses-permission android:name="android.permission.health.READ_LEAN_BODY_MASS" />
<uses-permission android:name="android.permission.health.READ_BONE_MASS" />
<uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
<!-- Sleep -->
<uses-permission android:name="android.permission.health.READ_SLEEP" />
<!-- Exercise / Workouts -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.READ_POWER" />
<uses-permission android:name="android.permission.health.READ_SPEED" />
<!-- Nutrition (optional) -->
<uses-permission android:name="android.permission.health.READ_NUTRITION" />
<uses-permission android:name="android.permission.health.READ_HYDRATION" />
<!-- Historical Data Access (for data older than 30 days) -->
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY" />
<application
android:name=".HabitForgeApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HabitForgeCompanion"
android:usesCleartextTraffic="true"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.HabitForgeCompanion">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Receive Health Connect permission results -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- Privacy Policy Activity (Required by Health Connect) -->
<activity
android:name=".PrivacyPolicyActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity>
<!-- WorkManager initializer -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup" />
</provider>
</application>
<!-- Declare that this app uses Health Connect -->
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>
@@ -0,0 +1,236 @@
package com.habitforge.companion
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Manages user authentication and session state.
* Handles login, logout, and secure token storage.
*/
class AuthManager(private val context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
companion object {
private const val PREFS_NAME = "habitforge_auth"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_USERNAME = "username"
private const val KEY_USER_ID = "user_id"
private const val KEY_SERVER_URL = "server_url"
private const val KEY_IS_LOGGED_IN = "is_logged_in"
// Default server URL (can be changed)
const val DEFAULT_SERVER_URL = "http://192.168.1.100:8000"
}
/**
* Check if user is currently logged in
*/
fun isLoggedIn(): Boolean {
return prefs.getBoolean(KEY_IS_LOGGED_IN, false) &&
!prefs.getString(KEY_ACCESS_TOKEN, null).isNullOrEmpty()
}
/**
* Get stored access token with Bearer prefix
*/
fun getAuthorizationHeader(): String? {
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
return if (token != null) "Bearer $token" else null
}
/**
* Get raw access token (without Bearer prefix)
*/
fun getAccessToken(): String? {
return prefs.getString(KEY_ACCESS_TOKEN, null)
}
/**
* Get current username
*/
fun getUsername(): String? {
return prefs.getString(KEY_USERNAME, null)
}
/**
* Get current user ID
*/
fun getUserId(): Int {
return prefs.getInt(KEY_USER_ID, -1)
}
/**
* Get configured server URL
*/
fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
}
/**
* Set server URL
*/
fun setServerUrl(url: String) {
prefs.edit().putString(KEY_SERVER_URL, url).apply()
}
/**
* Attempt to login with username and password
* @return LoginResult with success/failure and message
*/
suspend fun login(serverUrl: String, username: String, password: String): LoginResult {
return withContext(Dispatchers.IO) {
try {
// Ensure URL has proper format
val formattedUrl = formatServerUrl(serverUrl)
// Create API client
val api = HabitForgeApi.create(formattedUrl)
// Attempt login
val response = api.login(username, password)
if (response.isSuccessful && response.body() != null) {
val loginResponse = response.body()!!
val token = loginResponse.access_token
// Verify token by getting user info
val userResponse = api.getCurrentUser("Bearer $token")
if (userResponse.isSuccessful && userResponse.body() != null) {
val user = userResponse.body()!!
// Save credentials
saveSession(
serverUrl = formattedUrl,
token = token,
username = user.username,
userId = user.id
)
LoginResult(
success = true,
message = "Welcome back, ${user.username}!",
username = user.username
)
} else {
LoginResult(
success = false,
message = "Login successful but failed to get user info"
)
}
} else {
val errorBody = response.errorBody()?.string()
val errorMessage = when (response.code()) {
401 -> "Invalid username or password"
404 -> "Server not found. Check URL."
500 -> "Server error. Please try again."
else -> errorBody ?: "Login failed (${response.code()})"
}
LoginResult(success = false, message = errorMessage)
}
} catch (e: java.net.UnknownHostException) {
LoginResult(success = false, message = "Cannot reach server. Check URL and network.")
} catch (e: java.net.ConnectException) {
LoginResult(success = false, message = "Connection refused. Is the server running?")
} catch (e: java.net.SocketTimeoutException) {
LoginResult(success = false, message = "Connection timeout. Server may be slow.")
} catch (e: Exception) {
LoginResult(success = false, message = "Error: ${e.message}")
}
}
}
/**
* Save session data after successful login
*/
private fun saveSession(serverUrl: String, token: String, username: String, userId: Int) {
prefs.edit()
.putString(KEY_SERVER_URL, serverUrl)
.putString(KEY_ACCESS_TOKEN, token)
.putString(KEY_USERNAME, username)
.putInt(KEY_USER_ID, userId)
.putBoolean(KEY_IS_LOGGED_IN, true)
.apply()
// Also update the old prefs for backward compatibility with SyncService
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.edit()
.putString("server_url", serverUrl)
.putString("auth_token", token)
.apply()
}
/**
* Clear session (logout)
*/
fun logout() {
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.remove(KEY_USERNAME)
.remove(KEY_USER_ID)
.putBoolean(KEY_IS_LOGGED_IN, false)
.apply()
// Clear old prefs too
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.edit()
.remove("auth_token")
.apply()
}
/**
* Refresh token if needed (for future token refresh implementation)
*/
suspend fun refreshTokenIfNeeded(): Boolean {
// Future: implement token refresh
// For now, just verify current token is valid
val token = getAuthorizationHeader() ?: return false
val serverUrl = getServerUrl()
return withContext(Dispatchers.IO) {
try {
val api = HabitForgeApi.create(serverUrl)
val response = api.getCurrentUser(token)
response.isSuccessful
} catch (e: Exception) {
false
}
}
}
/**
* Format server URL to ensure it has proper protocol and no trailing slash
*/
private fun formatServerUrl(url: String): String {
var formatted = url.trim()
// Add protocol if missing
if (!formatted.startsWith("http://") && !formatted.startsWith("https://")) {
formatted = "http://$formatted"
}
// Remove trailing slash
if (formatted.endsWith("/")) {
formatted = formatted.dropLast(1)
}
// Ensure ends with proper format for Retrofit baseUrl
if (!formatted.endsWith("/")) {
formatted = "$formatted/"
}
return formatted
}
}
/**
* Result of a login attempt
*/
data class LoginResult(
val success: Boolean,
val message: String,
val username: String? = null
)
@@ -0,0 +1,354 @@
package com.habitforge.companion
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.util.Locale
/**
* Validates that Health Connect data can be properly mapped to HabitForge schema
*/
class DataCompatibilityValidator(
private val context: Context,
private val healthConnectManager: HealthConnectManager
) {
companion object {
private const val TAG = "DataCompatibilityValidator"
}
/**
* Run full compatibility validation
*/
suspend fun validateCompatibility(): ValidationResult = withContext(Dispatchers.IO) {
val issues = mutableListOf<ValidationIssue>()
val validations = mutableListOf<ValidationItem>()
Log.i(TAG, "Starting data compatibility validation...")
// Check Health Connect availability
val availability = healthConnectManager.isAvailable()
validations.add(
ValidationItem(
name = "Health Connect Availability",
passed = availability == HealthConnectAvailability.AVAILABLE,
message = when (availability) {
HealthConnectAvailability.AVAILABLE -> "Health Connect is available"
HealthConnectAvailability.NOT_INSTALLED -> "Health Connect is not installed"
HealthConnectAvailability.NOT_SUPPORTED -> "Health Connect is not supported on this device"
}
)
)
if (availability != HealthConnectAvailability.AVAILABLE) {
return@withContext ValidationResult(
isCompatible = false,
validations = validations,
issues = listOf(ValidationIssue(
severity = IssueSeverity.ERROR,
category = "Health Connect",
message = "Health Connect is not available"
)),
dataPreview = null
)
}
// Check permissions
val hasPermissions = healthConnectManager.hasAllPermissions()
val grantedPermissions = healthConnectManager.getGrantedPermissions()
val totalPermissions = HealthConnectManager.PERMISSIONS.size
validations.add(
ValidationItem(
name = "Permissions",
passed = hasPermissions,
message = "Granted: ${grantedPermissions.size}/$totalPermissions"
)
)
if (!hasPermissions) {
issues.add(ValidationIssue(
severity = IssueSeverity.WARNING,
category = "Permissions",
message = "Not all permissions granted. Some data types may not sync."
))
}
// Try to read sample data to validate schema mapping
try {
val endDate = LocalDate.now()
val startDate = endDate.minusDays(1)
// Test daily metrics reading
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
validations.add(
ValidationItem(
name = "Daily Metrics Schema",
passed = true,
message = "Successfully read ${metrics.size} day(s) of metrics"
)
)
// Validate each metric field
val fieldValidations = validateDailyMetricsFields(metrics)
validations.addAll(fieldValidations.validations)
issues.addAll(fieldValidations.issues)
// Test exercise sessions reading
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
validations.add(
ValidationItem(
name = "Exercise Sessions Schema",
passed = true,
message = "Successfully read ${exercises.size} exercise session(s)"
)
)
// Validate exercise fields
val exerciseValidations = validateExerciseFields(exercises)
validations.addAll(exerciseValidations.validations)
issues.addAll(exerciseValidations.issues)
// Build data preview
val dataPreview = buildDataPreview(metrics, exercises)
// Final compatibility check
val hasErrors = issues.any { it.severity == IssueSeverity.ERROR }
ValidationResult(
isCompatible = !hasErrors,
validations = validations,
issues = issues,
dataPreview = dataPreview
)
} catch (e: Exception) {
Log.e(TAG, "Validation failed", e)
issues.add(ValidationIssue(
severity = IssueSeverity.ERROR,
category = "Data Reading",
message = "Failed to read data: ${e.localizedMessage}"
))
ValidationResult(
isCompatible = false,
validations = validations,
issues = issues,
dataPreview = null
)
}
}
private fun validateDailyMetricsFields(metrics: List<DailyMetrics>): FieldValidationResult {
val validations = mutableListOf<ValidationItem>()
val issues = mutableListOf<ValidationIssue>()
if (metrics.isEmpty()) {
issues.add(ValidationIssue(
severity = IssueSeverity.INFO,
category = "Daily Metrics",
message = "No daily metrics data found for the test period"
))
return FieldValidationResult(validations, issues)
}
val sample = metrics.first()
// Steps validation
validations.add(ValidationItem(
name = "Steps Field",
passed = sample.steps >= 0,
message = "Value: ${sample.steps}"
))
// Distance validation
validations.add(ValidationItem(
name = "Distance Field",
passed = sample.distanceMeters >= 0,
message = "Value: ${String.format(Locale.US, "%.2f", sample.distanceMeters)} meters"
))
// Calories validation
validations.add(ValidationItem(
name = "Calories Field",
passed = sample.caloriesBurned >= 0,
message = "Value: ${sample.caloriesBurned} kcal"
))
// Heart rate validation (optional field)
val hrStatus = if (sample.avgHeartRate != null) {
"Value: ${sample.avgHeartRate} bpm"
} else {
"No data available (optional)"
}
validations.add(ValidationItem(
name = "Heart Rate Field",
passed = true,
message = hrStatus
))
// Sleep validation
validations.add(ValidationItem(
name = "Sleep Fields",
passed = sample.sleepDurationMinutes >= 0,
message = "Duration: ${sample.sleepDurationMinutes} min, Deep: ${sample.deepSleepMinutes} min"
))
// Weight validation (optional)
val weightStatus = if (sample.weight != null) {
"Value: ${String.format(Locale.US, "%.1f", sample.weight)} kg"
} else {
"No data available (optional)"
}
validations.add(ValidationItem(
name = "Weight Field",
passed = true,
message = weightStatus
))
// SpO2 validation (optional)
val spo2Status = if (sample.spo2 != null) {
"Value: ${String.format(Locale.US, "%.1f", sample.spo2)}%"
} else {
"No data available (optional)"
}
validations.add(ValidationItem(
name = "SpO2 Field",
passed = true,
message = spo2Status
))
return FieldValidationResult(validations, issues)
}
private fun validateExerciseFields(exercises: List<ExerciseData>): FieldValidationResult {
val validations = mutableListOf<ValidationItem>()
val issues = mutableListOf<ValidationIssue>()
if (exercises.isEmpty()) {
issues.add(ValidationIssue(
severity = IssueSeverity.INFO,
category = "Exercise Sessions",
message = "No exercise sessions found for the test period"
))
return FieldValidationResult(validations, issues)
}
val sample = exercises.first()
// Time fields validation
validations.add(ValidationItem(
name = "Exercise Time Fields",
passed = sample.startTime.isNotEmpty() && sample.endTime.isNotEmpty(),
message = "Start: ${sample.startTime.take(19)}"
))
// Exercise type validation
validations.add(ValidationItem(
name = "Exercise Type Field",
passed = sample.exerciseType >= 0,
message = "Type: ${sample.exerciseTypeName}"
))
// Duration validation
validations.add(ValidationItem(
name = "Duration Field",
passed = sample.durationMinutes > 0,
message = "Duration: ${sample.durationMinutes} minutes"
))
return FieldValidationResult(validations, issues)
}
private fun buildDataPreview(metrics: List<DailyMetrics>, exercises: List<ExerciseData>): DataPreview {
val metricsPreview = if (metrics.isNotEmpty()) {
val m = metrics.first()
"""
|Date: ${m.date}
|Steps: ${m.steps}
|Distance: ${String.format(Locale.US, "%.0f", m.distanceMeters)}m
|Calories: ${m.caloriesBurned}
|Heart Rate: ${m.avgHeartRate ?: "N/A"} bpm
|Sleep: ${m.sleepDurationMinutes} min
""".trimMargin()
} else {
"No metrics data available"
}
val exercisesPreview = if (exercises.isNotEmpty()) {
exercises.take(3).joinToString("\n") { e ->
"• ${e.exerciseTypeName}: ${e.durationMinutes} min"
}
} else {
"No exercise sessions available"
}
return DataPreview(
dailyMetricsPreview = metricsPreview,
exerciseSessionsPreview = exercisesPreview,
metricsCount = metrics.size,
exercisesCount = exercises.size
)
}
}
// ==================== DATA CLASSES ====================
data class ValidationResult(
val isCompatible: Boolean,
val validations: List<ValidationItem>,
val issues: List<ValidationIssue>,
val dataPreview: DataPreview?
) {
fun getOverallStatus(): String {
return if (isCompatible) {
val warnings = issues.count { it.severity == IssueSeverity.WARNING }
if (warnings > 0) {
"Compatible with $warnings warning(s)"
} else {
"Fully compatible"
}
} else {
"Incompatible - see errors"
}
}
fun getStatusIcon(): String {
return if (isCompatible) {
if (issues.any { it.severity == IssueSeverity.WARNING }) "⚠" else "✓"
} else {
"✗"
}
}
}
data class ValidationItem(
val name: String,
val passed: Boolean,
val message: String
)
data class ValidationIssue(
val severity: IssueSeverity,
val category: String,
val message: String
)
enum class IssueSeverity {
INFO,
WARNING,
ERROR
}
data class DataPreview(
val dailyMetricsPreview: String,
val exerciseSessionsPreview: String,
val metricsCount: Int,
val exercisesCount: Int
)
private data class FieldValidationResult(
val validations: List<ValidationItem>,
val issues: List<ValidationIssue>
)
@@ -0,0 +1,140 @@
package com.habitforge.companion
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
/**
* Retrofit API interface for communicating with HabitForge backend
*/
interface HabitForgeApi {
/**
* Login with username and password (OAuth2 form data)
* Returns access token on success
*/
@FormUrlEncoded
@POST("api/auth/token")
suspend fun login(
@Field("username") username: String,
@Field("password") password: String
): Response<LoginResponse>
/**
* Get current user information
*/
@GET("api/auth/me")
suspend fun getCurrentUser(
@Header("Authorization") token: String
): Response<UserResponse>
@POST("api/health-connect/sync")
suspend fun syncHealthData(
@Header("Authorization") token: String,
@Body request: HealthConnectSyncRequest
): Response<SyncResponse>
@GET("api/health-connect/status")
suspend fun getSyncStatus(
@Header("Authorization") token: String
): Response<SyncStatusResponse>
companion object {
fun create(baseUrl: String): HabitForgeApi {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(HabitForgeApi::class.java)
}
}
}
// ==================== REQUEST/RESPONSE MODELS ====================
data class HealthConnectSyncRequest(
val device_id: String,
val sync_timestamp: String,
val daily_metrics: List<DailyMetricsPayload>,
val exercise_sessions: List<ExerciseSessionPayload>
)
data class DailyMetricsPayload(
val date: String,
val step_count: Int,
val distance_meters: Double,
val calories_burned: Double,
val total_calories: Double = 0.0,
val avg_heart_rate: Int?,
val min_heart_rate: Int?,
val max_heart_rate: Int?,
val resting_heart_rate: Int?,
val sleep_duration_minutes: Int,
val deep_sleep_minutes: Int,
val light_sleep_minutes: Int,
val rem_sleep_minutes: Int,
val awake_duration_minutes: Int,
val weight: Double?,
val avg_spo2: Double?,
val min_spo2: Double? = null,
val max_spo2: Double? = null
)
data class ExerciseSessionPayload(
val start_time: String,
val end_time: String,
val exercise_type: Int,
val exercise_type_name: String?,
val duration_seconds: Int,
val title: String?,
val notes: String?,
val calories_burned: Double? = null,
val distance_meters: Double? = null,
val avg_heart_rate: Int? = null,
val max_heart_rate: Int? = null,
val min_heart_rate: Int? = null,
val steps: Int? = null,
val elevation_gained: Double? = null
)
data class SyncResponse(
val status: String,
val message: String,
val sync_id: Int,
val metrics_synced: Int,
val exercises_synced: Int
)
data class SyncStatusResponse(
val has_data: Boolean,
val last_sync: String?,
val total_records: Int
)
// ==================== AUTH MODELS ====================
data class LoginResponse(
val access_token: String,
val token_type: String
)
data class UserResponse(
val id: Int,
val username: String
)
@@ -0,0 +1,27 @@
package com.habitforge.companion
import android.app.Application
import androidx.work.Configuration
import androidx.work.WorkManager
class HabitForgeApp : Application(), Configuration.Provider {
lateinit var healthConnectManager: HealthConnectManager
private set
override fun onCreate() {
super.onCreate()
instance = this
healthConnectManager = HealthConnectManager(this)
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.INFO)
.build()
companion object {
lateinit var instance: HabitForgeApp
private set
}
}
@@ -0,0 +1,483 @@
package com.habitforge.companion
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.*
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
/**
* Health Connect Manager - Handles all interactions with Health Connect API
* Implements: Permission requests and data reading for all required health types
*/
class HealthConnectManager(private val context: Context) {
private var healthConnectClient: HealthConnectClient? = null
companion object {
private const val TAG = "HealthConnectManager"
// All permissions we need to read health data
val PERMISSIONS = setOf(
// Activity
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(DistanceRecord::class),
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
// Heart & Vitals
HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getReadPermission(RestingHeartRateRecord::class),
HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class),
HealthPermission.getReadPermission(OxygenSaturationRecord::class),
HealthPermission.getReadPermission(RespiratoryRateRecord::class),
// Body Measurements
HealthPermission.getReadPermission(WeightRecord::class),
HealthPermission.getReadPermission(HeightRecord::class),
HealthPermission.getReadPermission(BodyFatRecord::class),
HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
// Sleep
HealthPermission.getReadPermission(SleepSessionRecord::class),
// Exercise
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(PowerRecord::class),
HealthPermission.getReadPermission(SpeedRecord::class),
// Nutrition
HealthPermission.getReadPermission(NutritionRecord::class),
HealthPermission.getReadPermission(HydrationRecord::class),
// Historical data access (for data older than 30 days)
"android.permission.health.READ_HEALTH_DATA_HISTORY"
)
}
/**
* Check if Health Connect is available on this device
*/
fun isAvailable(): HealthConnectAvailability {
return when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> {
healthConnectClient = HealthConnectClient.getOrCreate(context)
HealthConnectAvailability.AVAILABLE
}
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
HealthConnectAvailability.NOT_INSTALLED
}
else -> HealthConnectAvailability.NOT_SUPPORTED
}
}
/**
* Check which permissions are currently granted
*/
suspend fun getGrantedPermissions(): Set<String> {
val client = healthConnectClient ?: return emptySet()
return client.permissionController.getGrantedPermissions()
}
/**
* Check if we have all required permissions
*/
suspend fun hasAllPermissions(): Boolean {
val granted = getGrantedPermissions()
return PERMISSIONS.all { it in granted }
}
// ==================== DATA READING METHODS ====================
/**
* Read daily metrics for a specific date range
*/
/**
* Read daily metrics for a specific date range efficiently
*/
suspend fun readDailyMetrics(startDate: LocalDate, endDate: LocalDate): List<DailyMetrics> = withContext(Dispatchers.IO) {
val client = healthConnectClient ?: run {
if (isAvailable() == HealthConnectAvailability.AVAILABLE) healthConnectClient else null
} ?: return@withContext emptyList()
// Validate date range
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
val actualEndDate = if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
Log.d(TAG, "Reading daily metrics (bulk) from $actualStartDate to $actualEndDate")
val startInstant = actualStartDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
val endInstant = actualEndDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
val timeRange = TimeRangeFilter.between(startInstant, endInstant)
// Fetch all metrics in bulk WITH PAGINATION for complete history
val allSteps = try { readAllRecordsPaginated<StepsRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading steps", e); emptyList() }
val allDistance = try { readAllRecordsPaginated<DistanceRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading distance", e); emptyList() }
val allCalories = try { readAllRecordsPaginated<TotalCaloriesBurnedRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading calories", e); emptyList() }
val allHeartRate = try { readAllRecordsPaginated<HeartRateRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading heart rate", e); emptyList() }
val allRestingHR = try { readAllRecordsPaginated<RestingHeartRateRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading resting HR", e); emptyList() }
val allSleep = try { readAllRecordsPaginated<SleepSessionRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading sleep", e); emptyList() }
val allWeight = try { readAllRecordsPaginated<WeightRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading weight", e); emptyList() }
val allSpo2 = try { readAllRecordsPaginated<OxygenSaturationRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading spo2", e); emptyList() }
Log.d(TAG, "Loaded totals - Steps: ${allSteps.size}, Distance: ${allDistance.size}, Calories: ${allCalories.size}, HR: ${allHeartRate.size}, SpO2: ${allSpo2.size}")
val metrics = mutableListOf<DailyMetrics>()
var currentDate = actualStartDate
while (!currentDate.isAfter(actualEndDate)) {
val dayStart = currentDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
val dayEnd = currentDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
// Filter records for this specific day (in memory)
val daySteps = allSteps.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.count }
val dayDistance = allDistance.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.distance.inMeters }
val dayCalories = allCalories.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.energy.inKilocalories }
val dayHRRecords = allHeartRate.filter { it.startTime >= dayStart && it.endTime <= dayEnd }
val hrSamples = dayHRRecords.flatMap { it.samples }
val dayRestingHR = allRestingHR.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.beatsPerMinute?.toInt()
// For weight and spo2, take the latest of the day
val dayWeight = allWeight.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.weight?.inKilograms
val daySpo2 = allSpo2.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.percentage?.value
// Sleep logic - sleep for "today" is usually the one finishing today
val daySleep = allSleep.filter { it.endTime >= dayStart && it.endTime <= dayEnd }
var totalSleep = 0
var deep = 0
var light = 0
var rem = 0
var awake = 0
daySleep.forEach { session ->
totalSleep += ChronoUnit.MINUTES.between(session.startTime, session.endTime).toInt()
session.stages.forEach { stage ->
val dur = ChronoUnit.MINUTES.between(stage.startTime, stage.endTime).toInt()
when (stage.stage) {
SleepSessionRecord.STAGE_TYPE_DEEP -> deep += dur
SleepSessionRecord.STAGE_TYPE_LIGHT -> light += dur
SleepSessionRecord.STAGE_TYPE_REM -> rem += dur
SleepSessionRecord.STAGE_TYPE_AWAKE -> awake += dur
}
}
}
metrics.add(DailyMetrics(
date = currentDate.toString(),
steps = daySteps,
distanceMeters = dayDistance,
caloriesBurned = dayCalories,
avgHeartRate = if (hrSamples.isNotEmpty()) hrSamples.map { it.beatsPerMinute }.average().toInt() else null,
minHeartRate = if (hrSamples.isNotEmpty()) hrSamples.minOf { it.beatsPerMinute }.toInt() else null,
maxHeartRate = if (hrSamples.isNotEmpty()) hrSamples.maxOf { it.beatsPerMinute }.toInt() else null,
restingHeartRate = dayRestingHR,
sleepDurationMinutes = totalSleep,
deepSleepMinutes = deep,
lightSleepMinutes = light,
remSleepMinutes = rem,
awakeMinutes = awake,
weight = dayWeight,
spo2 = daySpo2
))
currentDate = currentDate.plusDays(1)
}
metrics
}
/**
* Read exercise sessions for a date range with pagination support
*/
suspend fun readExerciseSessions(startDate: LocalDate, endDate: LocalDate): List<ExerciseData> = withContext(Dispatchers.IO) {
// Ensure client is initialized
val client = healthConnectClient ?: run {
if (isAvailable() == HealthConnectAvailability.AVAILABLE) {
healthConnectClient
} else {
Log.e(TAG, "Health Connect client not available for exercises")
return@withContext emptyList()
}
} ?: return@withContext emptyList()
// Validate date range
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
val actualEndDate = if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
Log.d(TAG, "Reading exercise sessions from $actualStartDate to $actualEndDate")
val exercises = mutableListOf<ExerciseData>()
try {
val timeRange = TimeRangeFilter.between(
actualStartDate.atStartOfDay(ZoneId.systemDefault()).toInstant(),
actualEndDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
)
// Use pagination to get ALL exercise records
var pageToken: String? = null
var totalRecords = 0
do {
val request = if (pageToken != null) {
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = timeRange,
pageToken = pageToken
)
} else {
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = timeRange
)
}
val response = client.readRecords(request)
totalRecords += response.records.size
for (record in response.records) {
// Validate that startTime is before endTime for this record
if (record.startTime.isBefore(record.endTime)) {
val durationMinutes = ChronoUnit.MINUTES.between(record.startTime, record.endTime).toInt()
exercises.add(
ExerciseData(
startTime = record.startTime.toString(),
endTime = record.endTime.toString(),
exerciseType = record.exerciseType,
exerciseTypeName = mapExerciseType(record.exerciseType),
durationMinutes = durationMinutes,
title = record.title ?: mapExerciseType(record.exerciseType),
notes = record.notes
)
)
} else {
Log.w(TAG, "Skipping exercise record with invalid time: start=${record.startTime}, end=${record.endTime}")
}
}
pageToken = response.pageToken
Log.d(TAG, "Read ${response.records.size} exercise records (total: $totalRecords, hasMore: ${pageToken != null})")
} while (pageToken != null)
} catch (e: Exception) {
Log.e(TAG, "Error reading exercise sessions", e)
}
Log.d(TAG, "Total exercise sessions found: ${exercises.size}")
exercises
}
// ==================== PRIVATE HELPER METHODS ====================
/**
* Generic helper to read ALL records with pagination support.
* Health Connect returns max 1000 records per request by default.
* We use pageToken to fetch all pages until no more data is available.
*/
private suspend inline fun <reified T : Record> readAllRecordsPaginated(
client: HealthConnectClient,
timeRange: TimeRangeFilter
): List<T> {
val allRecords = mutableListOf<T>()
var pageToken: String? = null
do {
val request = if (pageToken != null) {
ReadRecordsRequest(
recordType = T::class,
timeRangeFilter = timeRange,
pageToken = pageToken
)
} else {
ReadRecordsRequest(
recordType = T::class,
timeRangeFilter = timeRange
)
}
val response = client.readRecords(request)
allRecords.addAll(response.records)
pageToken = response.pageToken
Log.d(TAG, "Read ${response.records.size} ${T::class.simpleName} records (total: ${allRecords.size}, hasMore: ${pageToken != null})")
} while (pageToken != null)
return allRecords
}
private suspend fun readSteps(client: HealthConnectClient, range: TimeRangeFilter): Long {
return try {
val response = client.readRecords(ReadRecordsRequest(StepsRecord::class, range))
response.records.sumOf { it.count }
} catch (e: Exception) {
Log.w(TAG, "Could not read steps", e)
0L
}
}
private suspend fun readDistance(client: HealthConnectClient, range: TimeRangeFilter): Double {
return try {
val response = client.readRecords(ReadRecordsRequest(DistanceRecord::class, range))
response.records.sumOf { it.distance.inMeters }
} catch (e: Exception) {
Log.w(TAG, "Could not read distance", e)
0.0
}
}
private suspend fun readCalories(client: HealthConnectClient, range: TimeRangeFilter): Double {
return try {
val response = client.readRecords(ReadRecordsRequest(TotalCaloriesBurnedRecord::class, range))
response.records.sumOf { it.energy.inKilocalories }
} catch (e: Exception) {
Log.w(TAG, "Could not read calories", e)
0.0
}
}
private suspend fun readHeartRate(client: HealthConnectClient, range: TimeRangeFilter): HeartRateData {
return try {
val response = client.readRecords(ReadRecordsRequest(HeartRateRecord::class, range))
val samples = response.records.flatMap { it.samples }
val resting = try {
client.readRecords(ReadRecordsRequest(RestingHeartRateRecord::class, range))
.records.firstOrNull()?.beatsPerMinute
} catch (e: Exception) {
null
}
if (samples.isEmpty()) {
HeartRateData(avg = null, min = null, max = null, resting = resting?.toInt())
} else {
HeartRateData(
avg = samples.map { it.beatsPerMinute }.average().toInt(),
min = samples.minOfOrNull { it.beatsPerMinute }?.toInt(),
max = samples.maxOfOrNull { it.beatsPerMinute }?.toInt(),
resting = resting?.toInt()
)
}
} catch (e: Exception) {
Log.w(TAG, "Could not read heart rate", e)
HeartRateData(null, null, null, null)
}
}
private suspend fun readSleep(client: HealthConnectClient, date: LocalDate): SleepData {
val start = date.minusDays(1).atTime(12, 0).toInstant(ZoneId.systemDefault().rules.getOffset(Instant.now()))
val end = date.atTime(11, 59).toInstant(ZoneId.systemDefault().rules.getOffset(Instant.now()))
val range = TimeRangeFilter.between(start, end)
return try {
val response = client.readRecords(ReadRecordsRequest(SleepSessionRecord::class, range))
val session = response.records.firstOrNull()
SleepData(
totalMinutes = session?.let { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
deepMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_DEEP }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
lightMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_LIGHT }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
remSleepMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_REM }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
awakeMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_AWAKE }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
)
} catch (e: Exception) {
Log.w(TAG, "Could not read sleep", e)
SleepData(0, 0, 0, 0, 0)
}
}
private suspend fun readLatestWeight(client: HealthConnectClient, range: TimeRangeFilter): Double? {
return try {
val response = client.readRecords(ReadRecordsRequest(WeightRecord::class, range))
response.records.lastOrNull()?.weight?.inKilograms
} catch (e: Exception) {
Log.w(TAG, "Could not read weight", e)
null
}
}
private suspend fun readSpO2(client: HealthConnectClient, range: TimeRangeFilter): Double? {
return try {
val response = client.readRecords(ReadRecordsRequest(OxygenSaturationRecord::class, range))
response.records.lastOrNull()?.percentage?.value
} catch (e: Exception) {
Log.w(TAG, "Could not read SpO2", e)
null
}
}
private fun mapExerciseType(type: Int): String {
return when (type) {
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "Walking"
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING -> "Running"
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "Hiking"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "Biking"
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL -> "Swimming"
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING -> "Strength Training"
ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "Yoga"
ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "Pilates"
else -> "Other"
}
}
}
// ==================== DATA CLASSES ====================
enum class HealthConnectAvailability {
AVAILABLE,
NOT_INSTALLED,
NOT_SUPPORTED
}
data class DailyMetrics(
val date: String,
val steps: Long,
val distanceMeters: Double,
val caloriesBurned: Double,
val avgHeartRate: Int?,
val minHeartRate: Int?,
val maxHeartRate: Int?,
val restingHeartRate: Int?,
val sleepDurationMinutes: Int,
val deepSleepMinutes: Int,
val lightSleepMinutes: Int,
val remSleepMinutes: Int,
val awakeMinutes: Int,
val weight: Double?,
val spo2: Double?
)
data class ExerciseData(
val startTime: String,
val endTime: String,
val exerciseType: Int,
val exerciseTypeName: String,
val durationMinutes: Int,
val title: String?,
val notes: String?
)
data class HeartRateData(
val avg: Int?,
val min: Int?,
val max: Int?,
val resting: Int?
)
data class SleepData(
val totalMinutes: Int,
val deepMinutes: Int,
val lightMinutes: Int,
val remSleepMinutes: Int,
val awakeMinutes: Int
)
@@ -0,0 +1,973 @@
package com.habitforge.companion
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.health.connect.client.PermissionController
import androidx.lifecycle.lifecycleScope
import com.habitforge.companion.databinding.ActivityMainBinding
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
/**
* Main Activity - Handles UI, permission requests, sync triggers, and status monitoring
*/
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var healthConnectManager: HealthConnectManager
private lateinit var syncHistoryManager: SyncHistoryManager
private lateinit var serverConnectionManager: ServerConnectionManager
private lateinit var dataValidator: DataCompatibilityValidator
private lateinit var authManager: AuthManager
private lateinit var metricsSyncTracker: MetricsSyncTracker
// Permission request launcher
private val permissionLauncher = registerForActivityResult(
PermissionController.createRequestPermissionResultContract()
) { granted ->
Log.i(TAG, "Permissions granted: $granted")
lifecycleScope.launch {
updateUI()
}
}
companion object {
private const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
healthConnectManager = HabitForgeApp.instance.healthConnectManager
syncHistoryManager = SyncHistoryManager(this)
serverConnectionManager = ServerConnectionManager(this)
dataValidator = DataCompatibilityValidator(this, healthConnectManager)
authManager = AuthManager(this)
metricsSyncTracker = MetricsSyncTracker(this)
setupUI()
setupSyncPeriodSpinner()
checkHealthConnectAvailability()
}
override fun onResume() {
super.onResume()
lifecycleScope.launch {
updateUI()
checkServerAvailability()
updateSyncHistory()
updateMetricsOverview()
}
}
private fun setupUI() {
// Request Permissions Button
binding.btnRequestPermissions.setOnClickListener {
requestHealthConnectPermissions()
}
// Sync Now Button
binding.btnSyncNow.setOnClickListener {
performSync()
}
// Configure Server Button
binding.btnConfigureServer.setOnClickListener {
showServerConfigDialog()
}
// Test Connection Button
binding.btnTestConnection.setOnClickListener {
testServerConnection()
}
// Open Health Connect Button
binding.btnOpenHealthConnect.setOnClickListener {
openHealthConnect()
}
// Validate Data Button
binding.btnValidateData.setOnClickListener {
validateDataCompatibility()
}
// View All History Button
binding.btnViewAllHistory.setOnClickListener {
showFullSyncHistory()
}
// Refresh Metrics Button
binding.btnRefreshMetrics.setOnClickListener {
refreshMetricsOverview()
}
// Initialize metrics display
updateMetricsOverview()
}
private fun setupSyncPeriodSpinner() {
val options = SyncService.getSyncPeriodOptions()
val adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
options.map { it.label }
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerSyncPeriod.adapter = adapter
// Set current selection
val currentPeriod = SyncService.getSyncPeriod(this)
val currentIndex = options.indexOfFirst { it.minutes == currentPeriod }
if (currentIndex >= 0) {
binding.spinnerSyncPeriod.setSelection(currentIndex)
}
// Handle selection changes
binding.spinnerSyncPeriod.setOnItemSelectedListener(object : android.widget.AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
val selectedPeriod = options[position].minutes
if (selectedPeriod != SyncService.getSyncPeriod(this@MainActivity)) {
SyncService.schedulePeriodicSync(this@MainActivity, selectedPeriod)
Toast.makeText(
this@MainActivity,
"Sync period updated to ${options[position].label}",
Toast.LENGTH_SHORT
).show()
}
}
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
})
}
private fun checkHealthConnectAvailability() {
when (healthConnectManager.isAvailable()) {
HealthConnectAvailability.AVAILABLE -> {
binding.statusHealthConnect.text = "✓ Health Connect Available"
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.green))
binding.layoutAvailable.visibility = View.VISIBLE
binding.layoutUnavailable.visibility = View.GONE
lifecycleScope.launch {
updateUI()
}
}
HealthConnectAvailability.NOT_INSTALLED -> {
binding.statusHealthConnect.text = "✗ Health Connect Not Installed"
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.orange))
binding.layoutAvailable.visibility = View.GONE
binding.layoutUnavailable.visibility = View.VISIBLE
binding.btnInstallHealthConnect.setOnClickListener {
installHealthConnect()
}
}
HealthConnectAvailability.NOT_SUPPORTED -> {
binding.statusHealthConnect.text = "✗ Health Connect Not Supported"
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.red))
binding.layoutAvailable.visibility = View.GONE
binding.layoutUnavailable.visibility = View.VISIBLE
binding.btnInstallHealthConnect.visibility = View.GONE
binding.tvUnavailableMessage.text = "Your device does not support Health Connect. Android 9+ is required."
}
}
}
private suspend fun updateUI() {
// Check permissions status
val hasPermissions = healthConnectManager.hasAllPermissions()
val grantedCount = healthConnectManager.getGrantedPermissions().size
val totalCount = HealthConnectManager.PERMISSIONS.size
if (hasPermissions) {
binding.statusPermissions.text = "✓ All Permissions Granted ($grantedCount/$totalCount)"
binding.statusPermissions.setTextColor(ContextCompat.getColor(this, R.color.green))
binding.btnRequestPermissions.text = "Manage Permissions"
binding.btnSyncNow.isEnabled = true
} else {
binding.statusPermissions.text = "⚠ Permissions Required ($grantedCount/$totalCount)"
binding.statusPermissions.setTextColor(ContextCompat.getColor(this, R.color.orange))
binding.btnRequestPermissions.text = "Grant Permissions"
binding.btnSyncNow.isEnabled = false
}
// Check authentication status
if (authManager.isLoggedIn()) {
val username = authManager.getUsername()
binding.statusServer.text = "✓ Logged in as $username"
binding.statusServer.setTextColor(ContextCompat.getColor(this, R.color.green))
binding.tvServerUrl.text = authManager.getServerUrl()
binding.btnConfigureServer.text = "Logout"
} else {
binding.statusServer.text = "⚠ Not logged in"
binding.statusServer.setTextColor(ContextCompat.getColor(this, R.color.orange))
binding.tvServerUrl.text = "Tap Login to connect your account"
binding.btnConfigureServer.text = "Login"
}
// Last sync time
val prefs = getSharedPreferences("habitforge", MODE_PRIVATE)
val lastSync = prefs.getLong("last_sync", 0)
if (lastSync > 0) {
val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
binding.tvLastSync.text = "Last sync: ${dateFormat.format(Date(lastSync))}"
} else {
binding.tvLastSync.text = "Never synced"
}
}
private suspend fun checkServerAvailability() {
binding.tvServerAvailability.text = "Checking..."
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_gray)
val result = serverConnectionManager.testConnection()
when (result.status) {
ServerStatus.ONLINE -> {
if (result.authenticated) {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_green)
binding.tvServerAvailability.text = "Online"
binding.tvServerResponseTime.text = "Response: ${result.responseTimeMs}ms"
binding.tvServerResponseTime.visibility = View.VISIBLE
} else {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
binding.tvServerAvailability.text = "Auth needed"
binding.tvServerResponseTime.visibility = View.GONE
}
}
ServerStatus.UNREACHABLE, ServerStatus.OFFLINE -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_red)
binding.tvServerAvailability.text = "Offline"
binding.tvServerResponseTime.visibility = View.GONE
}
ServerStatus.TIMEOUT -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
binding.tvServerAvailability.text = "Timeout"
binding.tvServerResponseTime.visibility = View.GONE
}
ServerStatus.AUTH_ERROR -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
binding.tvServerAvailability.text = "Auth error"
binding.tvServerResponseTime.visibility = View.GONE
}
ServerStatus.SERVER_ERROR -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_red)
binding.tvServerAvailability.text = "Error"
binding.tvServerResponseTime.visibility = View.GONE
}
else -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_gray)
binding.tvServerAvailability.text = "Unknown"
binding.tvServerResponseTime.visibility = View.GONE
}
}
}
private fun updateSyncHistory() {
val stats = syncHistoryManager.getStats()
val recentSyncs = syncHistoryManager.getRecentHistory(5)
// Update stats
binding.tvSuccessCount.text = stats.successfulSyncs.toString()
binding.tvFailedCount.text = stats.failedSyncs.toString()
binding.tvMetricsSynced.text = stats.totalMetricsSynced.toString()
if (stats.totalSyncs > 0) {
binding.tvSyncStats.text = "${String.format("%.0f", stats.getSuccessRate())}% success"
} else {
binding.tvSyncStats.text = ""
}
// Update recent syncs list
binding.layoutRecentSyncs.removeAllViews()
if (recentSyncs.isEmpty()) {
binding.tvNoSyncHistory.visibility = View.VISIBLE
binding.btnViewAllHistory.visibility = View.GONE
} else {
binding.tvNoSyncHistory.visibility = View.GONE
binding.btnViewAllHistory.visibility = if (syncHistoryManager.getHistory().size > 5) View.VISIBLE else View.GONE
for (sync in recentSyncs) {
val itemView = createSyncHistoryItem(sync)
binding.layoutRecentSyncs.addView(itemView)
}
}
}
private fun createSyncHistoryItem(sync: SyncRecord): View {
val itemView = LayoutInflater.from(this).inflate(android.R.layout.simple_list_item_2, null)
val text1 = itemView.findViewById<TextView>(android.R.id.text1)
val text2 = itemView.findViewById<TextView>(android.R.id.text2)
text1.text = "${sync.getStatusIcon()} ${sync.getFormattedTimestamp()}"
text1.setTextColor(ContextCompat.getColor(this, sync.getStatusColor()))
text1.textSize = 13f
val details = if (sync.status == SyncStatus.SUCCESS) {
"Metrics: ${sync.metricsSynced}, Exercises: ${sync.exercisesSynced} (${sync.syncDurationMs}ms)"
} else {
sync.errorMessage ?: "Unknown error"
}
text2.text = details
text2.textSize = 11f
text2.setTextColor(ContextCompat.getColor(this, android.R.color.darker_gray))
val params = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
params.setMargins(0, 4, 0, 4)
itemView.layoutParams = params
return itemView
}
private fun testServerConnection() {
binding.btnTestConnection.isEnabled = false
binding.btnTestConnection.text = "Testing..."
lifecycleScope.launch {
val result = serverConnectionManager.testConnection()
binding.btnTestConnection.isEnabled = true
binding.btnTestConnection.text = "Test Connection"
// Show result dialog
AlertDialog.Builder(this@MainActivity)
.setTitle("Connection Test Result")
.setMessage("""
Status: ${result.getStatusIcon()} ${result.status}
${result.message}
${if (result.responseTimeMs > 0) "Response time: ${result.responseTimeMs}ms" else ""}
${if (result.authenticated) "✓ Authentication: Valid" else ""}
""".trimIndent().trim())
.setPositiveButton("OK", null)
.show()
// Update server status indicator
checkServerAvailability()
}
}
private fun validateDataCompatibility() {
binding.btnValidateData.isEnabled = false
binding.btnValidateData.text = "Validating..."
lifecycleScope.launch {
val result = dataValidator.validateCompatibility()
runOnUiThread {
binding.btnValidateData.isEnabled = true
binding.btnValidateData.text = "Validate Data Schema"
// Update status
binding.statusDataCompatibility.text = "${result.getStatusIcon()} ${result.getOverallStatus()}"
if (result.isCompatible) {
binding.statusDataCompatibility.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.green))
} else {
binding.statusDataCompatibility.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.red))
}
// Show validation results
binding.layoutValidationResults.visibility = View.VISIBLE
val resultsText = StringBuilder()
// Add validation items
for (item in result.validations) {
val icon = if (item.passed) "✓" else "✗"
resultsText.appendLine("$icon ${item.name}")
resultsText.appendLine(" ${item.message}")
}
// Add issues if any
if (result.issues.isNotEmpty()) {
resultsText.appendLine("\nIssues:")
for (issue in result.issues) {
val icon = when (issue.severity) {
IssueSeverity.ERROR -> "❌"
IssueSeverity.WARNING -> "⚠️"
IssueSeverity.INFO -> "ℹ️"
}
resultsText.appendLine("$icon [${issue.category}] ${issue.message}")
}
}
// Add data preview if available
result.dataPreview?.let { preview ->
resultsText.appendLine("\nData Preview:")
resultsText.appendLine(" Metrics: ${preview.metricsCount} day(s)")
resultsText.appendLine(" Exercises: ${preview.exercisesCount} session(s)")
}
binding.tvValidationResultsList.text = resultsText.toString()
// Update details text
binding.tvCompatibilityDetails.text = if (result.isCompatible) {
"All Health Connect data types can be synced with HabitForge."
} else {
"Some issues were found. Check the validation results below."
}
}
}
}
private fun showFullSyncHistory() {
val history = syncHistoryManager.getHistory()
val message = if (history.isEmpty()) {
"No sync history available."
} else {
history.take(20).joinToString("\n\n") { sync ->
val details = if (sync.status == SyncStatus.SUCCESS) {
"Metrics: ${sync.metricsSynced}, Exercises: ${sync.exercisesSynced}"
} else {
sync.errorMessage ?: "Unknown error"
}
"${sync.getStatusIcon()} ${sync.getFormattedTimestamp()}\n$details"
}
}
AlertDialog.Builder(this)
.setTitle("Sync History")
.setMessage(message)
.setPositiveButton("OK", null)
.setNeutralButton("Clear History") { _, _ ->
syncHistoryManager.clearHistory()
updateSyncHistory()
Toast.makeText(this, "History cleared", Toast.LENGTH_SHORT).show()
}
.show()
}
private fun requestHealthConnectPermissions() {
permissionLauncher.launch(HealthConnectManager.PERMISSIONS)
}
private fun performSync() {
if (!authManager.isLoggedIn()) {
Toast.makeText(this, "Please login first", Toast.LENGTH_SHORT).show()
showLoginDialog()
return
}
binding.btnSyncNow.isEnabled = false
binding.btnSyncNow.text = "Syncing..."
binding.progressSync.visibility = View.VISIBLE
SyncService.syncNow(this)
// Re-enable after delay (actual completion is async)
binding.root.postDelayed({
binding.btnSyncNow.isEnabled = true
binding.btnSyncNow.text = "Sync Now"
binding.progressSync.visibility = View.GONE
lifecycleScope.launch {
updateUI()
updateSyncHistory()
}
Toast.makeText(this, "Sync started in background", Toast.LENGTH_SHORT).show()
}, 2000)
}
private fun showServerConfigDialog() {
// If already logged in, show logout confirmation
if (authManager.isLoggedIn()) {
AlertDialog.Builder(this)
.setTitle("Logout")
.setMessage("Are you sure you want to logout from ${authManager.getUsername()}?")
.setPositiveButton("Logout") { _, _ ->
authManager.logout()
lifecycleScope.launch {
updateUI()
checkServerAvailability()
}
Toast.makeText(this, "Logged out successfully", Toast.LENGTH_SHORT).show()
}
.setNegativeButton("Cancel", null)
.show()
return
}
// Show login dialog
showLoginDialog()
}
private fun showLoginDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_server_config, null)
val etServerUrl = dialogView.findViewById<android.widget.EditText>(R.id.etServerUrl)
val etUsername = dialogView.findViewById<android.widget.EditText>(R.id.etUsername)
val etPassword = dialogView.findViewById<android.widget.EditText>(R.id.etPassword)
val tvError = dialogView.findViewById<TextView>(R.id.tvError)
val progressLogin = dialogView.findViewById<android.widget.ProgressBar>(R.id.progressLogin)
// Pre-fill server URL
etServerUrl.setText(authManager.getServerUrl())
val dialog = AlertDialog.Builder(this)
.setTitle("Login to HabitForge")
.setView(dialogView)
.setPositiveButton("Login", null) // Set to null to prevent auto-dismiss
.setNegativeButton("Cancel", null)
.create()
dialog.setOnShowListener {
val loginButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
loginButton.setOnClickListener {
val serverUrl = etServerUrl.text.toString().trim()
val username = etUsername.text.toString().trim()
val password = etPassword.text.toString()
// Validation
if (serverUrl.isEmpty()) {
tvError.text = "Please enter server URL"
tvError.visibility = View.VISIBLE
return@setOnClickListener
}
if (username.isEmpty()) {
tvError.text = "Please enter username"
tvError.visibility = View.VISIBLE
return@setOnClickListener
}
if (password.isEmpty()) {
tvError.text = "Please enter password"
tvError.visibility = View.VISIBLE
return@setOnClickListener
}
// Show loading
tvError.visibility = View.GONE
progressLogin.visibility = View.VISIBLE
loginButton.isEnabled = false
etServerUrl.isEnabled = false
etUsername.isEnabled = false
etPassword.isEnabled = false
// Attempt login
lifecycleScope.launch {
val result = authManager.login(serverUrl, username, password)
if (result.success) {
dialog.dismiss()
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
// Schedule periodic sync
SyncService.schedulePeriodicSync(this@MainActivity)
updateUI()
checkServerAvailability()
} else {
// Show error
progressLogin.visibility = View.GONE
tvError.text = result.message
tvError.visibility = View.VISIBLE
loginButton.isEnabled = true
etServerUrl.isEnabled = true
etUsername.isEnabled = true
etPassword.isEnabled = true
}
}
}
}
dialog.show()
}
private fun openHealthConnect() {
try {
val intent = Intent("android.health.connect.action.HEALTH_HOME_SETTINGS")
startActivity(intent)
} catch (e: Exception) {
// Fallback for older versions
try {
val intent = Intent(Intent.ACTION_VIEW).apply {
setPackage("com.google.android.apps.healthdata")
}
startActivity(intent)
} catch (e2: Exception) {
Toast.makeText(this, "Could not open Health Connect", Toast.LENGTH_SHORT).show()
}
}
}
private fun installHealthConnect() {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(
"https://play.google.com/store/apps/details?id=com.google.android.apps.healthdata"
)))
} catch (e: Exception) {
Toast.makeText(this, "Could not open Play Store", Toast.LENGTH_SHORT).show()
}
}
// ==================== METRICS OVERVIEW ====================
private fun updateMetricsOverview() {
val allStatus = metricsSyncTracker.getAllMetricsStatus()
// Update summary text
val syncedCount = allStatus.count { it.lastSyncTime != null }
val availableCount = allStatus.count { it.isAvailable }
binding.tvMetricsSummary.text = "$syncedCount/${allStatus.size} metrics synced, $availableCount with data"
// Update each metric view with click listener
setupMetricClickListener(binding.metricSteps.root, "steps", allStatus.find { it.metricType.id == "steps" })
setupMetricClickListener(binding.metricDistance.root, "distance", allStatus.find { it.metricType.id == "distance" })
setupMetricClickListener(binding.metricCalories.root, "calories", allStatus.find { it.metricType.id == "calories" })
setupMetricClickListener(binding.metricHeartRate.root, "heart_rate", allStatus.find { it.metricType.id == "heart_rate" })
setupMetricClickListener(binding.metricRestingHR.root, "resting_hr", allStatus.find { it.metricType.id == "resting_hr" })
setupMetricClickListener(binding.metricSleep.root, "sleep", allStatus.find { it.metricType.id == "sleep" })
setupMetricClickListener(binding.metricWeight.root, "weight", allStatus.find { it.metricType.id == "weight" })
setupMetricClickListener(binding.metricSpO2.root, "spo2", allStatus.find { it.metricType.id == "spo2" })
setupMetricClickListener(binding.metricExercise.root, "exercise", allStatus.find { it.metricType.id == "exercise" })
}
private fun setupMetricClickListener(metricView: View, metricId: String, status: MetricSyncStatus?) {
updateMetricView(metricView, status)
metricView.setOnClickListener {
showMetricHistory(metricId)
}
}
private fun updateMetricView(metricView: View, status: MetricSyncStatus?) {
val metricType = status?.metricType ?: return
val tvIcon = metricView.findViewById<TextView>(R.id.tvMetricIcon)
val tvName = metricView.findViewById<TextView>(R.id.tvMetricName)
val tvValue = metricView.findViewById<TextView>(R.id.tvMetricValue)
val tvStatus = metricView.findViewById<TextView>(R.id.tvMetricStatus)
val tvSyncTime = metricView.findViewById<TextView>(R.id.tvMetricSyncTime)
tvIcon.text = metricType.icon
tvName.text = metricType.displayName
tvStatus.text = status.getStatusIcon()
tvValue.text = status.getStatusText()
if (status.lastSyncTime != null) {
tvSyncTime.visibility = View.VISIBLE
tvSyncTime.text = status.getFormattedSyncTime()
} else {
tvSyncTime.visibility = View.GONE
}
// Set background color based on status
val bgColor = when {
status.isAvailable && status.lastSyncTime != null -> "#E8F5E9" // Light green
status.lastSyncTime != null -> "#FFF3E0" // Light orange
else -> "#F5F5F5" // Light gray
}
metricView.setBackgroundColor(android.graphics.Color.parseColor(bgColor))
}
private fun refreshMetricsOverview() {
lifecycleScope.launch {
binding.btnRefreshMetrics.isEnabled = false
binding.btnRefreshMetrics.text = "Reading from Health Connect..."
try {
// Read latest data from Health Connect (last 7 days for better data)
val endDate = java.time.LocalDate.now()
val startDate = endDate.minusDays(7)
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
if (metrics.isNotEmpty()) {
// Find the best (most recent non-zero) values for each metric
val sortedMetrics = metrics.sortedByDescending { it.date }
// For steps - find most recent day with steps > 0 (could be today or yesterday)
val stepsMetric = sortedMetrics.firstOrNull { it.steps > 0 } ?: sortedMetrics.first()
// Find best values from available data
val bestSteps = stepsMetric.steps
val bestDistance = sortedMetrics.firstOrNull { it.distanceMeters > 0 }?.distanceMeters ?: 0.0
val bestCalories = sortedMetrics.firstOrNull { it.caloriesBurned > 0 }?.caloriesBurned ?: 0.0
val bestAvgHR = sortedMetrics.mapNotNull { it.avgHeartRate }.filter { it > 0 }.firstOrNull()
val bestRestingHR = sortedMetrics.mapNotNull { it.restingHeartRate }.filter { it > 0 }.firstOrNull()
val bestSleep = sortedMetrics.filter { it.sleepDurationMinutes > 0 }.firstOrNull()?.sleepDurationMinutes
val bestWeight = sortedMetrics.mapNotNull { it.weight }.filter { it > 0 }.firstOrNull()
val bestSpo2 = sortedMetrics.mapNotNull { it.spo2 }.filter { it > 0 }.firstOrNull()
Log.d(TAG, "Best values found - Steps: $bestSteps, Distance: $bestDistance, Calories: $bestCalories")
// Update tracker with best available values
metricsSyncTracker.updateFromSyncData(
steps = bestSteps,
distance = bestDistance,
calories = bestCalories,
avgHeartRate = bestAvgHR,
restingHeartRate = bestRestingHR,
sleepMinutes = bestSleep,
weight = bestWeight,
spo2 = bestSpo2,
exerciseCount = exercises.size
)
Toast.makeText(this@MainActivity, "Metrics refreshed from Health Connect", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@MainActivity, "No metrics data available", Toast.LENGTH_SHORT).show()
}
updateMetricsOverview()
} catch (e: Exception) {
Log.e(TAG, "Error refreshing metrics", e)
Toast.makeText(this@MainActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
binding.btnRefreshMetrics.isEnabled = true
binding.btnRefreshMetrics.text = "Refresh Metrics"
}
}
}
/**
* Show full metric history in a dialog
*/
private fun showMetricHistory(metricId: String) {
val progressDialog = AlertDialog.Builder(this)
.setMessage("Loading 365 days of history...")
.setCancelable(false)
.show()
lifecycleScope.launch {
try {
// Read all available data from Health Connect (last 365 days for full history)
val endDate = java.time.LocalDate.now()
val startDate = endDate.minusDays(365)
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
val exercises = if (metricId == "exercise") {
healthConnectManager.readExerciseSessions(startDate, endDate)
} else emptyList()
progressDialog.dismiss()
if (metrics.isEmpty() && exercises.isEmpty()) {
Toast.makeText(this@MainActivity, "No history data available", Toast.LENGTH_SHORT).show()
return@launch
}
// Create and show dialog
showMetricHistoryDialog(metricId, metrics, exercises)
} catch (e: Exception) {
progressDialog.dismiss()
Log.e(TAG, "Error loading history", e)
Toast.makeText(this@MainActivity, "Error loading history: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
private fun showMetricHistoryDialog(
metricId: String,
metrics: List<DailyMetrics>,
exercises: List<ExerciseData>
) {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_metric_history, null)
val tvTitle = dialogView.findViewById<TextView>(R.id.tvHistoryTitle)
val tvSubtitle = dialogView.findViewById<TextView>(R.id.tvHistorySubtitle)
val tvIcon = dialogView.findViewById<TextView>(R.id.tvHistoryIcon)
val tvDataPoints = dialogView.findViewById<TextView>(R.id.tvHistoryDataPoints)
val tvLatestValue = dialogView.findViewById<TextView>(R.id.tvHistoryLatestValue)
val tvAverage = dialogView.findViewById<TextView>(R.id.tvHistoryAverage)
val layoutList = dialogView.findViewById<LinearLayout>(R.id.layoutHistoryList)
// Get metric info
val metricInfo = MetricsSyncTracker.ALL_METRICS.find { it.id == metricId }
tvIcon.text = metricInfo?.icon ?: "📊"
tvTitle.text = "${metricInfo?.displayName ?: metricId} History"
tvSubtitle.text = "Last year from Health Connect"
// Extract values based on metric type
val historyData = mutableListOf<Pair<String, String>>()
var unit = ""
val values = mutableListOf<Double>()
when (metricId) {
"steps" -> {
unit = " steps"
metrics.forEach { m ->
if (m.steps > 0) {
historyData.add(m.date to "%,d".format(m.steps))
values.add(m.steps.toDouble())
}
}
}
"distance" -> {
unit = " m"
metrics.forEach { m ->
if (m.distanceMeters > 0) {
historyData.add(m.date to "%.1f".format(m.distanceMeters))
values.add(m.distanceMeters)
}
}
}
"calories" -> {
unit = " kcal"
metrics.forEach { m ->
if (m.caloriesBurned > 0) {
historyData.add(m.date to "%.0f".format(m.caloriesBurned))
values.add(m.caloriesBurned)
}
}
}
"heart_rate" -> {
unit = " bpm"
metrics.forEach { m ->
m.avgHeartRate?.let {
if (it > 0) {
historyData.add(m.date to it.toString())
values.add(it.toDouble())
}
}
}
}
"resting_hr" -> {
unit = " bpm"
metrics.forEach { m ->
m.restingHeartRate?.let {
if (it > 0) {
historyData.add(m.date to it.toString())
values.add(it.toDouble())
}
}
}
}
"sleep" -> {
unit = ""
metrics.forEach { m ->
if (m.sleepDurationMinutes > 0) {
val hours = m.sleepDurationMinutes / 60
val mins = m.sleepDurationMinutes % 60
historyData.add(m.date to "${hours}h ${mins}m")
values.add(m.sleepDurationMinutes.toDouble())
}
}
}
"weight" -> {
unit = " kg"
metrics.forEach { m ->
m.weight?.let {
if (it > 0) {
historyData.add(m.date to "%.1f".format(it))
values.add(it)
}
}
}
}
"spo2" -> {
unit = "%"
metrics.forEach { m ->
m.spo2?.let {
if (it > 0) {
historyData.add(m.date to "%.0f".format(it))
values.add(it)
}
}
}
}
"exercise" -> {
unit = " sessions"
// Group exercises by date - extract date safely
val byDate = exercises.groupBy { ex ->
try {
// Handle ISO timestamp format (2026-01-02T10:30:00Z)
if (ex.startTime.length >= 10) ex.startTime.substring(0, 10) else ex.startTime
} catch (e: Exception) {
ex.startTime
}
}
byDate.forEach { (date, exs) ->
val summary = if (exs.size <= 3) {
exs.joinToString { it.exerciseTypeName }
} else {
"${exs.take(2).joinToString { it.exerciseTypeName }} +${exs.size - 2} more"
}
historyData.add(date to "${exs.size} ($summary)")
values.add(exs.size.toDouble())
}
}
}
// Sort by date descending
historyData.sortByDescending { it.first }
// Update summary
tvDataPoints.text = historyData.size.toString()
tvLatestValue.text = if (historyData.isNotEmpty()) historyData.first().second + unit else "--"
tvAverage.text = if (values.isNotEmpty()) {
val avg = values.average()
if (metricId == "sleep") {
val hours = (avg / 60).toInt()
val mins = (avg % 60).toInt()
"${hours}h ${mins}m"
} else {
"%.1f".format(avg) + unit
}
} else "--"
// Populate list
val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
val parseFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
for ((dateStr, value) in historyData) {
val rowView = LayoutInflater.from(this).inflate(R.layout.item_history_row, layoutList, false)
val tvDate = rowView.findViewById<TextView>(R.id.tvHistoryDate)
val tvValue = rowView.findViewById<TextView>(R.id.tvHistoryValue)
val tvUnit = rowView.findViewById<TextView>(R.id.tvHistoryUnit)
try {
val date = parseFormat.parse(dateStr)
tvDate.text = dateFormat.format(date!!)
} catch (e: Exception) {
tvDate.text = dateStr
}
tvValue.text = value
tvUnit.text = if (metricId != "exercise") unit else ""
layoutList.addView(rowView)
// Add divider
val divider = View(this)
divider.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
1
)
divider.setBackgroundColor(android.graphics.Color.parseColor("#E5E7EB"))
layoutList.addView(divider)
}
// Show dialog
val dialog = AlertDialog.Builder(this)
.setView(dialogView)
.create()
dialogView.findViewById<View>(R.id.btnCloseHistory).setOnClickListener {
dialog.dismiss()
}
dialog.show()
}
}
@@ -0,0 +1,211 @@
package com.habitforge.companion
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.text.SimpleDateFormat
import java.util.*
/**
* Tracks sync status for individual metric types
*/
class MetricsSyncTracker(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("metrics_sync", Context.MODE_PRIVATE)
private val gson = Gson()
companion object {
private const val KEY_METRICS_STATUS = "metrics_status"
// All available metric types
val ALL_METRICS = listOf(
MetricType("steps", "Steps", "👣", "Daily step count"),
MetricType("distance", "Distance", "📏", "Distance walked/run"),
MetricType("calories", "Calories", "🔥", "Calories burned"),
MetricType("heart_rate", "Heart Rate", "❤️", "Average, min, max heart rate"),
MetricType("resting_hr", "Resting HR", "💗", "Resting heart rate"),
MetricType("sleep", "Sleep", "😴", "Sleep duration and stages"),
MetricType("weight", "Weight", "⚖️", "Body weight"),
MetricType("spo2", "SpO2", "🫁", "Blood oxygen saturation"),
MetricType("exercise", "Exercise", "🏃", "Workout sessions")
)
}
/**
* Get current status of all metrics
*/
fun getAllMetricsStatus(): List<MetricSyncStatus> {
val savedStatus = loadSavedStatus()
return ALL_METRICS.map { metricType ->
savedStatus[metricType.id] ?: MetricSyncStatus(
metricType = metricType,
lastSyncTime = null,
lastValue = null,
syncCount = 0,
isAvailable = false
)
}
}
/**
* Update sync status for a specific metric
*/
fun updateMetricStatus(
metricId: String,
lastValue: String?,
isAvailable: Boolean = true
) {
val savedStatus = loadSavedStatus().toMutableMap()
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
val existing = savedStatus[metricId]
savedStatus[metricId] = MetricSyncStatus(
metricType = metricType,
lastSyncTime = System.currentTimeMillis(),
lastValue = lastValue,
syncCount = (existing?.syncCount ?: 0) + 1,
isAvailable = isAvailable
)
saveStatus(savedStatus)
}
/**
* Bulk update metrics after a sync operation
*/
fun updateFromSyncData(
steps: Long?,
distance: Double?,
calories: Double?,
avgHeartRate: Int?,
restingHeartRate: Int?,
sleepMinutes: Int?,
weight: Double?,
spo2: Double?,
exerciseCount: Int
) {
// Steps: always mark as available if we have any value (including 0)
steps?.let { updateMetricStatus("steps", "$it steps", true) }
// Distance: available if value exists
distance?.let { updateMetricStatus("distance", String.format(Locale.US, "%.1f m", it), true) }
// Calories: available if value exists
calories?.let { updateMetricStatus("calories", String.format(Locale.US, "%.0f kcal", it), true) }
// Heart rate: available if we have a reading
avgHeartRate?.let { updateMetricStatus("heart_rate", "$it bpm", it > 0) }
restingHeartRate?.let { updateMetricStatus("resting_hr", "$it bpm", it > 0) }
// Sleep: available if we have duration
sleepMinutes?.let {
val hours = it / 60
val mins = it % 60
updateMetricStatus("sleep", "${hours}h ${mins}m", it > 0)
}
// Weight: available if we have a measurement
weight?.let { updateMetricStatus("weight", String.format(Locale.US, "%.1f kg", it), it > 0) }
// SpO2: available if we have a reading
spo2?.let { updateMetricStatus("spo2", String.format(Locale.US, "%.0f%%", it), it > 0) }
// Exercise: available if we have sessions
if (exerciseCount > 0) {
updateMetricStatus("exercise", "$exerciseCount sessions", true)
} else {
updateMetricStatus("exercise", "0 sessions", false)
}
}
/**
* Mark metric as unavailable (no data from Health Connect)
*/
fun markUnavailable(metricId: String) {
val savedStatus = loadSavedStatus().toMutableMap()
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
val existing = savedStatus[metricId]
savedStatus[metricId] = MetricSyncStatus(
metricType = metricType,
lastSyncTime = existing?.lastSyncTime,
lastValue = existing?.lastValue,
syncCount = existing?.syncCount ?: 0,
isAvailable = false
)
saveStatus(savedStatus)
}
/**
* Clear all sync history
*/
fun clearAll() {
prefs.edit().remove(KEY_METRICS_STATUS).apply()
}
private fun loadSavedStatus(): Map<String, MetricSyncStatus> {
val json = prefs.getString(KEY_METRICS_STATUS, null) ?: return emptyMap()
return try {
val type = object : TypeToken<Map<String, MetricSyncStatus>>() {}.type
gson.fromJson(json, type)
} catch (e: Exception) {
emptyMap()
}
}
private fun saveStatus(status: Map<String, MetricSyncStatus>) {
val json = gson.toJson(status)
prefs.edit().putString(KEY_METRICS_STATUS, json).apply()
}
}
/**
* Represents a type of health metric
*/
data class MetricType(
val id: String,
val displayName: String,
val icon: String,
val description: String
)
/**
* Sync status for a specific metric
*/
data class MetricSyncStatus(
val metricType: MetricType,
val lastSyncTime: Long?,
val lastValue: String?,
val syncCount: Int,
val isAvailable: Boolean
) {
fun getFormattedSyncTime(): String {
return if (lastSyncTime != null) {
val format = SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault())
format.format(Date(lastSyncTime))
} else {
"Never"
}
}
fun getStatusIcon(): String {
return when {
!isAvailable && lastSyncTime == null -> "⚪" // Never synced
!isAvailable -> "🔴" // Was synced but no data available
lastSyncTime != null -> "🟢" // Synced with data
else -> "⚪"
}
}
fun getStatusText(): String {
return when {
!isAvailable && lastSyncTime == null -> "Not synced"
!isAvailable -> "No data"
lastSyncTime != null -> lastValue ?: "Synced"
else -> "Pending"
}
}
}
@@ -0,0 +1,99 @@
package com.habitforge.companion
import android.os.Bundle
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
/**
* Privacy Policy Activity - Required by Health Connect
* Shows why we need access to health data
*/
class PrivacyPolicyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val webView = WebView(this)
setContentView(webView)
webView.loadData(
"""
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: sans-serif;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 { color: #0ea5e9; }
h2 { color: #666; margin-top: 24px; }
ul { padding-left: 20px; }
.highlight {
background: #f0f9ff;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
</style>
</head>
<body>
<h1>HabitForge Companion</h1>
<h2>Privacy Policy & Health Data Usage</h2>
<div class="highlight">
<strong>Why we need access to your health data:</strong>
<p>HabitForge uses your health data to help you track your fitness progress,
analyze your habits, and achieve your wellness goals.</p>
</div>
<h2>Data We Collect</h2>
<ul>
<li><strong>Activity Data:</strong> Steps, distance, calories burned</li>
<li><strong>Heart Rate:</strong> Average, resting, and workout heart rates</li>
<li><strong>Sleep:</strong> Duration and sleep stages</li>
<li><strong>Body Measurements:</strong> Weight, height, BMI</li>
<li><strong>Workouts:</strong> Exercise sessions and types</li>
<li><strong>Vitals:</strong> SpO2, respiratory rate</li>
</ul>
<h2>How We Use Your Data</h2>
<ul>
<li>Sync health metrics to your HabitForge account</li>
<li>Display progress charts and statistics</li>
<li>Track workout history and achievements</li>
<li>Calculate readiness and recovery scores</li>
</ul>
<h2>Data Security</h2>
<ul>
<li>Your data is encrypted during transmission (HTTPS)</li>
<li>Data is stored securely on your configured HabitForge server</li>
<li>You can delete your data at any time from the app</li>
<li>We never sell or share your health data with third parties</li>
</ul>
<h2>Your Rights</h2>
<ul>
<li>You can revoke permissions at any time</li>
<li>You can request deletion of your data</li>
<li>You control which data types are shared</li>
</ul>
<h2>Contact</h2>
<p>For questions about your data, contact your HabitForge server administrator.</p>
<p style="margin-top: 40px; color: #999; font-size: 12px;">
Last updated: January 2026
</p>
</body>
</html>
""".trimIndent(),
"text/html",
"UTF-8"
)
}
}
@@ -0,0 +1,221 @@
package com.habitforge.companion
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import java.net.SocketTimeoutException
/**
* Manages server connection tests and availability checks
*/
class ServerConnectionManager(private val context: Context) {
companion object {
private const val TAG = "ServerConnectionManager"
private const val CONNECTION_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 5000
}
/**
* Test connection to the server
* Returns a detailed result with status and response time
*/
suspend fun testConnection(): ConnectionTestResult = withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
val authToken = prefs.getString("auth_token", null)
Log.d(TAG, "Testing connection to: $serverUrl")
val startTime = System.currentTimeMillis()
try {
// First test basic connectivity
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
val healthUrl = URL("$baseUrl/api/health")
val connection = healthUrl.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = CONNECTION_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
val responseCode = connection.responseCode
val responseTime = System.currentTimeMillis() - startTime
connection.disconnect()
when (responseCode) {
200 -> {
// Now test authentication if token is provided
if (!authToken.isNullOrEmpty()) {
val authResult = testAuthentication(serverUrl, authToken)
if (authResult.authenticated) {
ConnectionTestResult(
status = ServerStatus.ONLINE,
responseTimeMs = responseTime,
message = "Server connected & authenticated",
authenticated = true
)
} else {
ConnectionTestResult(
status = ServerStatus.AUTH_ERROR,
responseTimeMs = responseTime,
message = "Server reachable but authentication failed: ${authResult.message}",
authenticated = false
)
}
} else {
ConnectionTestResult(
status = ServerStatus.ONLINE,
responseTimeMs = responseTime,
message = "Server reachable (no auth token configured)",
authenticated = false
)
}
}
401, 403 -> ConnectionTestResult(
status = ServerStatus.AUTH_ERROR,
responseTimeMs = responseTime,
message = "Authentication required (HTTP $responseCode)"
)
in 500..599 -> ConnectionTestResult(
status = ServerStatus.SERVER_ERROR,
responseTimeMs = responseTime,
message = "Server error (HTTP $responseCode)"
)
else -> ConnectionTestResult(
status = ServerStatus.UNKNOWN_ERROR,
responseTimeMs = responseTime,
message = "Unexpected response (HTTP $responseCode)"
)
}
} catch (e: SocketTimeoutException) {
Log.e(TAG, "Connection timeout", e)
ConnectionTestResult(
status = ServerStatus.TIMEOUT,
responseTimeMs = CONNECTION_TIMEOUT_MS.toLong(),
message = "Connection timeout after ${CONNECTION_TIMEOUT_MS}ms"
)
} catch (e: java.net.UnknownHostException) {
Log.e(TAG, "Unknown host", e)
ConnectionTestResult(
status = ServerStatus.UNREACHABLE,
responseTimeMs = 0,
message = "Server not found (check URL)"
)
} catch (e: java.net.ConnectException) {
Log.e(TAG, "Connection refused", e)
ConnectionTestResult(
status = ServerStatus.UNREACHABLE,
responseTimeMs = 0,
message = "Connection refused (server may be down)"
)
} catch (e: Exception) {
Log.e(TAG, "Connection test failed", e)
ConnectionTestResult(
status = ServerStatus.UNKNOWN_ERROR,
responseTimeMs = 0,
message = "Error: ${e.localizedMessage}"
)
}
}
/**
* Check if server is currently reachable (quick check)
*/
suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
try {
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
val url = URL("$baseUrl/api/health")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "HEAD"
connection.connectTimeout = 3000
connection.readTimeout = 3000
val reachable = connection.responseCode == 200
connection.disconnect()
reachable
} catch (e: Exception) {
false
}
}
/**
* Test authentication with the server
*/
private suspend fun testAuthentication(serverUrl: String, token: String): AuthTestResult = withContext(Dispatchers.IO) {
try {
val baseUrl = if (serverUrl.endsWith("/")) serverUrl else "$serverUrl/"
val api = HabitForgeApi.create(baseUrl)
val response = api.getCurrentUser("Bearer $token")
if (response.isSuccessful && response.body() != null) {
val user = response.body()!!
AuthTestResult(authenticated = true, message = "Logged in as ${user.username}")
} else {
AuthTestResult(authenticated = false, message = "HTTP ${response.code()}")
}
} catch (e: Exception) {
AuthTestResult(authenticated = false, message = e.localizedMessage ?: "Unknown error")
}
}
}
/**
* Server status enumeration
*/
enum class ServerStatus {
ONLINE,
OFFLINE,
UNREACHABLE,
TIMEOUT,
AUTH_ERROR,
SERVER_ERROR,
UNKNOWN_ERROR
}
/**
* Result of a connection test
*/
data class ConnectionTestResult(
val status: ServerStatus,
val responseTimeMs: Long = 0,
val message: String = "",
val authenticated: Boolean = false
) {
fun getStatusIcon(): String {
return when (status) {
ServerStatus.ONLINE -> if (authenticated) "✓" else "⚠"
ServerStatus.OFFLINE -> "○"
ServerStatus.UNREACHABLE -> "✗"
ServerStatus.TIMEOUT -> "⏱"
ServerStatus.AUTH_ERROR -> "🔒"
ServerStatus.SERVER_ERROR -> "⚠"
ServerStatus.UNKNOWN_ERROR -> "?"
}
}
fun getStatusColor(): Int {
return when (status) {
ServerStatus.ONLINE -> if (authenticated) android.R.color.holo_green_dark else android.R.color.holo_orange_dark
ServerStatus.OFFLINE -> android.R.color.darker_gray
ServerStatus.UNREACHABLE -> android.R.color.holo_red_dark
ServerStatus.TIMEOUT -> android.R.color.holo_orange_dark
ServerStatus.AUTH_ERROR -> android.R.color.holo_orange_dark
ServerStatus.SERVER_ERROR -> android.R.color.holo_red_dark
ServerStatus.UNKNOWN_ERROR -> android.R.color.holo_orange_dark
}
}
}
/**
* Result of authentication test
*/
data class AuthTestResult(
val authenticated: Boolean,
val message: String
)
@@ -0,0 +1,150 @@
package com.habitforge.companion
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.text.SimpleDateFormat
import java.util.*
/**
* Manages sync history for display in the UI
*/
class SyncHistoryManager(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("sync_history", Context.MODE_PRIVATE)
private val gson = Gson()
companion object {
private const val KEY_HISTORY = "sync_history_list"
private const val MAX_HISTORY_ITEMS = 50
}
/**
* Add a new sync record to history
*/
fun addSyncRecord(record: SyncRecord) {
val history = getHistory().toMutableList()
history.add(0, record) // Add to beginning
// Keep only last MAX_HISTORY_ITEMS
while (history.size > MAX_HISTORY_ITEMS) {
history.removeAt(history.size - 1)
}
saveHistory(history)
}
/**
* Get all sync history records
*/
fun getHistory(): List<SyncRecord> {
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<SyncRecord>>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
/**
* Get recent sync records (last N items)
*/
fun getRecentHistory(count: Int = 10): List<SyncRecord> {
return getHistory().take(count)
}
/**
* Clear all sync history
*/
fun clearHistory() {
prefs.edit().remove(KEY_HISTORY).apply()
}
/**
* Get sync statistics
*/
fun getStats(): SyncStats {
val history = getHistory()
val successful = history.count { it.status == SyncStatus.SUCCESS }
val failed = history.count { it.status == SyncStatus.FAILED }
val totalMetrics = history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.metricsSynced }
val totalExercises = history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.exercisesSynced }
return SyncStats(
totalSyncs = history.size,
successfulSyncs = successful,
failedSyncs = failed,
totalMetricsSynced = totalMetrics,
totalExercisesSynced = totalExercises,
lastSuccessfulSync = history.firstOrNull { it.status == SyncStatus.SUCCESS }?.timestamp
)
}
private fun saveHistory(history: List<SyncRecord>) {
val json = gson.toJson(history)
prefs.edit().putString(KEY_HISTORY, json).apply()
}
}
/**
* Represents a single sync operation record
*/
data class SyncRecord(
val timestamp: Long,
val status: SyncStatus,
val metricsSynced: Int = 0,
val exercisesSynced: Int = 0,
val errorMessage: String? = null,
val syncDurationMs: Long = 0
) {
fun getFormattedTimestamp(): String {
val dateFormat = SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault())
return dateFormat.format(Date(timestamp))
}
fun getStatusIcon(): String {
return when (status) {
SyncStatus.SUCCESS -> "✓"
SyncStatus.FAILED -> "✗"
SyncStatus.PARTIAL -> "⚠"
SyncStatus.IN_PROGRESS -> "⟳"
}
}
fun getStatusColor(): Int {
return when (status) {
SyncStatus.SUCCESS -> android.R.color.holo_green_dark
SyncStatus.FAILED -> android.R.color.holo_red_dark
SyncStatus.PARTIAL -> android.R.color.holo_orange_dark
SyncStatus.IN_PROGRESS -> android.R.color.holo_blue_dark
}
}
}
/**
* Sync status enumeration
*/
enum class SyncStatus {
SUCCESS,
FAILED,
PARTIAL,
IN_PROGRESS
}
/**
* Aggregated sync statistics
*/
data class SyncStats(
val totalSyncs: Int,
val successfulSyncs: Int,
val failedSyncs: Int,
val totalMetricsSynced: Int,
val totalExercisesSynced: Int,
val lastSuccessfulSync: Long?
) {
fun getSuccessRate(): Float {
return if (totalSyncs > 0) (successfulSyncs.toFloat() / totalSyncs) * 100 else 0f
}
}
@@ -0,0 +1,346 @@
package com.habitforge.companion
import android.content.Context
import android.provider.Settings
import android.util.Log
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.LocalDate
import java.util.concurrent.TimeUnit
/**
* Sync Service - Handles reading from Health Connect and pushing to backend
* Uses WorkManager for reliable background syncing
*/
class SyncService(
context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
companion object {
private const val TAG = "SyncService"
private const val WORK_NAME = "health_connect_sync"
private const val SYNC_DAYS = 365 // Sync last year of data to ensure history is complete
// Sync period options in minutes
const val SYNC_PERIOD_5_MINS = 5
const val SYNC_PERIOD_10_MINS = 10
const val SYNC_PERIOD_15_MINS = 15
const val SYNC_PERIOD_30_MINS = 30
const val SYNC_PERIOD_45_MINS = 45
const val SYNC_PERIOD_1_HOUR = 60
const val SYNC_PERIOD_3_HOURS = 180
const val SYNC_PERIOD_6_HOURS = 360
const val SYNC_PERIOD_12_HOURS = 720
const val SYNC_PERIOD_24_HOURS = 1440
// Preference keys
const val PREF_SYNC_PERIOD_MINUTES = "sync_period_minutes"
/**
* Schedule periodic sync with configurable interval
*/
fun schedulePeriodicSync(context: Context, periodMinutes: Int = getSyncPeriod(context)) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
// Note: WorkManager enforces a minimum interval of 15 minutes
val syncWork = PeriodicWorkRequestBuilder<SyncService>(
periodMinutes.toLong(), TimeUnit.MINUTES,
5, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE, // UPDATE to apply new period
syncWork
)
Log.i(TAG, "Periodic sync scheduled every $periodMinutes minutes")
// Save the period setting
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.edit()
.putInt(PREF_SYNC_PERIOD_MINUTES, periodMinutes)
.apply()
}
/**
* Trigger an immediate one-time sync
*/
fun syncNow(context: Context, days: Int = SYNC_DAYS) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncWork = OneTimeWorkRequestBuilder<SyncService>()
.setConstraints(constraints)
.setInputData(workDataOf("days" to days))
.build()
WorkManager.getInstance(context).enqueue(syncWork)
Log.i(TAG, "Immediate sync triggered for $days days")
}
/**
* Cancel all scheduled syncs
*/
fun cancelSync(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.i(TAG, "Sync cancelled")
}
/**
* Get the current sync period setting
*/
fun getSyncPeriod(context: Context): Int {
return context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.getInt(PREF_SYNC_PERIOD_MINUTES, SYNC_PERIOD_6_HOURS)
}
/**
* Get available sync period options
*/
fun getSyncPeriodOptions(): List<SyncPeriodOption> {
return listOf(
SyncPeriodOption(SYNC_PERIOD_5_MINS, "Every 5 minutes (clamped to 15)"),
SyncPeriodOption(SYNC_PERIOD_10_MINS, "Every 10 minutes (clamped to 15)"),
SyncPeriodOption(SYNC_PERIOD_15_MINS, "Every 15 minutes"),
SyncPeriodOption(SYNC_PERIOD_30_MINS, "Every 30 minutes"),
SyncPeriodOption(SYNC_PERIOD_45_MINS, "Every 45 minutes"),
SyncPeriodOption(SYNC_PERIOD_1_HOUR, "Every hour"),
SyncPeriodOption(SYNC_PERIOD_3_HOURS, "Every 3 hours"),
SyncPeriodOption(SYNC_PERIOD_6_HOURS, "Every 6 hours"),
SyncPeriodOption(SYNC_PERIOD_12_HOURS, "Every 12 hours"),
SyncPeriodOption(SYNC_PERIOD_24_HOURS, "Once a day")
)
}
/**
* Get formatted period string
*/
fun getFormattedPeriod(context: Context): String {
val minutes = getSyncPeriod(context)
return getSyncPeriodOptions().find { it.minutes == minutes }?.label ?: "Every $minutes minutes"
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
Log.i(TAG, "Starting Health Connect sync...")
val startTime = System.currentTimeMillis()
val syncHistoryManager = SyncHistoryManager(applicationContext)
try {
val healthConnectManager = HabitForgeApp.instance.healthConnectManager
// Check availability
if (healthConnectManager.isAvailable() != HealthConnectAvailability.AVAILABLE) {
Log.e(TAG, "Health Connect not available")
recordSyncFailure(syncHistoryManager, startTime, "Health Connect not available")
return@withContext Result.failure()
}
// Check permissions
if (!healthConnectManager.hasAllPermissions()) {
Log.e(TAG, "Missing Health Connect permissions")
recordSyncFailure(syncHistoryManager, startTime, "Missing permissions")
return@withContext Result.retry()
}
// Get auth token from preferences
val prefs = applicationContext.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
val authToken = prefs.getString("auth_token", null)
if (authToken.isNullOrEmpty()) {
Log.e(TAG, "No auth token configured")
recordSyncFailure(syncHistoryManager, startTime, "No auth token")
return@withContext Result.failure()
}
// Determine sync range
val days = inputData.getInt("days", SYNC_DAYS)
val endDate = LocalDate.now()
val startDate = endDate.minusDays(days.toLong())
Log.i(TAG, "Reading data from $startDate to $endDate ($days days)")
// Read daily metrics
val dailyMetrics = healthConnectManager.readDailyMetrics(startDate, endDate)
Log.i(TAG, "Read ${dailyMetrics.size} days of metrics")
// Log sample data for debugging
if (dailyMetrics.isNotEmpty()) {
val sample = dailyMetrics.first()
Log.d(TAG, "Sample metric: date=${sample.date}, steps=${sample.steps}, calories=${sample.caloriesBurned}")
} else {
Log.w(TAG, "No daily metrics returned from Health Connect!")
}
// Read exercise sessions
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
Log.i(TAG, "Read ${exercises.size} exercise sessions")
if (exercises.isNotEmpty()) {
val sample = exercises.first()
Log.d(TAG, "Sample exercise: type=${sample.exerciseTypeName}, duration=${sample.durationMinutes}min")
}
// Convert to API payload
val metricsPayload = dailyMetrics.map { metric ->
DailyMetricsPayload(
date = metric.date,
step_count = metric.steps.toInt(),
distance_meters = metric.distanceMeters,
calories_burned = metric.caloriesBurned,
total_calories = metric.caloriesBurned,
avg_heart_rate = metric.avgHeartRate,
min_heart_rate = metric.minHeartRate,
max_heart_rate = metric.maxHeartRate,
resting_heart_rate = metric.restingHeartRate,
sleep_duration_minutes = metric.sleepDurationMinutes,
deep_sleep_minutes = metric.deepSleepMinutes,
light_sleep_minutes = metric.lightSleepMinutes,
rem_sleep_minutes = metric.remSleepMinutes,
awake_duration_minutes = metric.awakeMinutes,
weight = metric.weight,
avg_spo2 = metric.spo2,
min_spo2 = metric.spo2,
max_spo2 = metric.spo2
)
}
val exercisesPayload = exercises.map { exercise ->
ExerciseSessionPayload(
start_time = exercise.startTime,
end_time = exercise.endTime,
exercise_type = exercise.exerciseType,
exercise_type_name = exercise.exerciseTypeName,
duration_seconds = exercise.durationMinutes * 60,
title = exercise.title,
notes = exercise.notes
)
}
// Get device ID
val deviceId = Settings.Secure.getString(
applicationContext.contentResolver,
Settings.Secure.ANDROID_ID
)
// Build request
val request = HealthConnectSyncRequest(
device_id = deviceId,
sync_timestamp = Instant.now().toString(),
daily_metrics = metricsPayload,
exercise_sessions = exercisesPayload
)
// Log request details for debugging
Log.i(TAG, "=== Preparing Sync Request ===")
Log.i(TAG, "Payload: ${metricsPayload.size} metrics, ${exercisesPayload.size} exercises")
if (metricsPayload.isNotEmpty()) {
val sample = metricsPayload.first()
Log.d(TAG, "Metric payload sample: date=${sample.date}, steps=${sample.step_count}, calories=${sample.calories_burned}")
}
if (exercisesPayload.isNotEmpty()) {
val sample = exercisesPayload.first()
Log.d(TAG, "Exercise payload sample: type=${sample.exercise_type_name}, duration=${sample.duration_seconds}s")
}
// Send to backend
val backendUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
Log.i(TAG, "Sending to: $backendUrl/api/health-connect/sync")
val api = HabitForgeApi.create(backendUrl)
val response = api.syncHealthData("Bearer $authToken", request)
if (response.isSuccessful) {
val body = response.body()
Log.i(TAG, "=== Sync Response ===")
Log.i(TAG, "Status: ${body?.status}")
Log.i(TAG, "Sync ID: ${body?.sync_id}")
Log.i(TAG, "Metrics synced: ${body?.metrics_synced}")
Log.i(TAG, "Exercises synced: ${body?.exercises_synced}")
Log.i(TAG, "Message: ${body?.message}")
// Save last sync time
prefs.edit().putLong("last_sync", System.currentTimeMillis()).apply()
// Update metrics sync tracker with best values from last few days
if (dailyMetrics.isNotEmpty()) {
val sorted = dailyMetrics.sortedByDescending { it.date }
val stepsMetric = sorted.firstOrNull { it.steps > 0 } ?: sorted.first()
val bestDistance = sorted.firstOrNull { it.distanceMeters > 0 }?.distanceMeters ?: 0.0
val bestCalories = sorted.firstOrNull { it.caloriesBurned > 0 }?.caloriesBurned ?: 0.0
val metricsTracker = MetricsSyncTracker(applicationContext)
metricsTracker.updateFromSyncData(
steps = stepsMetric.steps,
distance = bestDistance,
calories = bestCalories,
avgHeartRate = sorted.mapNotNull { it.avgHeartRate }.filter { it > 0 }.firstOrNull(),
restingHeartRate = sorted.mapNotNull { it.restingHeartRate }.filter { it > 0 }.firstOrNull(),
sleepMinutes = sorted.filter { it.sleepDurationMinutes > 0 }.firstOrNull()?.sleepDurationMinutes,
weight = sorted.mapNotNull { it.weight }.filter { it > 0 }.firstOrNull(),
spo2 = sorted.mapNotNull { it.spo2 }.filter { it > 0 }.firstOrNull(),
exerciseCount = exercises.size
)
}
// Record successful sync in history
val duration = System.currentTimeMillis() - startTime
syncHistoryManager.addSyncRecord(
SyncRecord(
timestamp = System.currentTimeMillis(),
status = SyncStatus.SUCCESS,
metricsSynced = body?.metrics_synced ?: dailyMetrics.size,
exercisesSynced = body?.exercises_synced ?: exercises.size,
syncDurationMs = duration
)
)
return@withContext Result.success()
} else {
Log.e(TAG, "Sync failed with code: ${response.code()}")
recordSyncFailure(syncHistoryManager, startTime, "Server returned ${response.code()}")
return@withContext Result.retry()
}
} catch (e: Exception) {
Log.e(TAG, "Sync error", e)
recordSyncFailure(syncHistoryManager, startTime, e.localizedMessage ?: "Unknown error")
return@withContext Result.retry()
}
}
private fun recordSyncFailure(manager: SyncHistoryManager, startTime: Long, errorMessage: String) {
val duration = System.currentTimeMillis() - startTime
manager.addSyncRecord(
SyncRecord(
timestamp = System.currentTimeMillis(),
status = SyncStatus.FAILED,
errorMessage = errorMessage,
syncDurationMs = duration
)
)
}
}
/**
* Sync period option for UI selection
*/
data class SyncPeriodOption(
val minutes: Int,
val label: String
)
@@ -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:fillColor="#0ea5e9"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
<path
android:fillColor="#0ea5e9"
android:pathData="M7,13l2,2l6,-6"
android:strokeWidth="2"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0ea5e9"
android:pathData="M0,0h108v108h-108z"/>
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Heart shape -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,78.35l-2.18,-1.98C41.1,67.04 35,61.42 35,54.25c0,-5.82 4.64,-10.5 10.5,-10.5c3.26,0 6.39,1.52 8.5,3.93c2.11,-2.41 5.24,-3.93 8.5,-3.93c5.86,0 10.5,4.68 10.5,10.5c0,7.17 -6.1,12.79 -16.82,22.15L54,78.35z"/>
<!-- Pulse line -->
<path
android:strokeColor="#0ea5e9"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M35,54 L45,54 L48,48 L51,60 L54,51 L57,57 L60,54 L73,54"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F3F4F6" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#EFF6FF" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#ECFDF5" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FEF2F2" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#9CA3AF" />
<size
android:width="10dp"
android:height="10dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#10b981" />
<size
android:width="10dp"
android:height="10dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#f59e0b" />
<size
android:width="10dp"
android:height="10dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#ef4444" />
<size
android:width="10dp"
android:height="10dp" />
</shape>
@@ -0,0 +1,787 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F5F5"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="24dp">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_heart_pulse"
app:tint="#0ea5e9" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginStart="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HabitForge"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="#1F2937" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Health Connect Companion"
android:textSize="14sp"
android:textColor="#6B7280" />
</LinearLayout>
</LinearLayout>
<!-- Health Connect Status Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Health Connect"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/statusHealthConnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Checking..."
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1F2937"
android:layout_marginTop="4dp" />
<!-- Available Layout -->
<LinearLayout
android:id="@+id/layoutAvailable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<Button
android:id="@+id/btnOpenHealthConnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Open Health Connect"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.TextButton"
android:layout_marginTop="8dp" />
</LinearLayout>
<!-- Unavailable Layout -->
<LinearLayout
android:id="@+id/layoutUnavailable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvUnavailableMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Health Connect needs to be installed to sync your health data."
android:textSize="14sp"
android:textColor="#6B7280"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/btnInstallHealthConnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Install Health Connect"
android:textAllCaps="false"
android:layout_marginTop="8dp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Permissions Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Permissions"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/statusPermissions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Checking..."
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1F2937"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnRequestPermissions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Grant Permissions"
android:textAllCaps="false"
android:backgroundTint="#0ea5e9"
android:layout_marginTop="12dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Server Configuration Card with Status Indicator -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Server"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<!-- Server Availability Indicator -->
<LinearLayout
android:id="@+id/layoutServerIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="4dp">
<View
android:id="@+id/serverStatusDot"
android:layout_width="10dp"
android:layout_height="10dp"
android:background="@drawable/status_dot_gray" />
<TextView
android:id="@+id/tvServerAvailability"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unknown"
android:textSize="11sp"
android:textColor="#6B7280"
android:layout_marginStart="6dp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/statusServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Checking..."
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1F2937"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/tvServerUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#6B7280"
android:layout_marginTop="2dp" />
<!-- Server response time -->
<TextView
android:id="@+id/tvServerResponseTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="11sp"
android:textColor="#9CA3AF"
android:visibility="gone"
android:layout_marginTop="2dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="12dp">
<Button
android:id="@+id/btnConfigureServer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Login"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_marginEnd="8dp" />
<Button
android:id="@+id/btnTestConnection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Test Connection"
android:textAllCaps="false"
android:backgroundTint="#6366f1" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Sync Settings Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sync Settings"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sync Frequency:"
android:textSize="14sp"
android:textColor="#1F2937" />
<Spinner
android:id="@+id/spinnerSyncPeriod"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp" />
</LinearLayout>
<TextView
android:id="@+id/tvLastSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Never synced"
android:textSize="14sp"
android:textColor="#6B7280"
android:layout_marginTop="8dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp">
<Button
android:id="@+id/btnSyncNow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync Now"
android:textAllCaps="false"
android:backgroundTint="#10b981"
android:enabled="false" />
<ProgressBar
android:id="@+id/progressSync"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:visibility="gone"
android:indeterminateTint="#FFFFFF" />
</FrameLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Metrics Overview Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Metrics Overview"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/tvMetricsLastUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="#9CA3AF" />
</LinearLayout>
<TextView
android:id="@+id/tvMetricsSummary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="View all available health metrics"
android:textSize="14sp"
android:textColor="#6B7280"
android:layout_marginTop="4dp" />
<!-- Metrics Grid -->
<LinearLayout
android:id="@+id/layoutMetricsGrid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="12dp">
<!-- Row 1: Steps, Distance, Calories -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<include layout="@layout/item_metric_status"
android:id="@+id/metricSteps"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricDistance"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricCalories"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp" />
</LinearLayout>
<!-- Row 2: Heart Rate, Resting HR, Sleep -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<include layout="@layout/item_metric_status"
android:id="@+id/metricHeartRate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricRestingHR"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricSleep"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp" />
</LinearLayout>
<!-- Row 3: Weight, SpO2, Exercise -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<include layout="@layout/item_metric_status"
android:id="@+id/metricWeight"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricSpO2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricExercise"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp" />
</LinearLayout>
</LinearLayout>
<Button
android:id="@+id/btnRefreshMetrics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Refresh Metrics"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.TextButton"
android:layout_marginTop="8dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Data Compatibility Validation Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Data Compatibility"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/statusDataCompatibility"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Not validated"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1F2937"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/tvCompatibilityDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Validate that your Health Connect data can be synced with the HabitForge schema."
android:textSize="12sp"
android:textColor="#6B7280"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnValidateData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Validate Data Schema"
android:textAllCaps="false"
android:backgroundTint="#f59e0b"
android:layout_marginTop="12dp" />
<!-- Validation Results (hidden by default) -->
<LinearLayout
android:id="@+id/layoutValidationResults"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
android:layout_marginTop="12dp"
android:padding="12dp"
android:background="@drawable/rounded_background">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Validation Results"
android:textSize="12sp"
android:textStyle="bold"
android:textColor="#374151" />
<TextView
android:id="@+id/tvValidationResultsList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="#6B7280"
android:layout_marginTop="8dp"
android:fontFamily="monospace" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Sync History Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Sync History"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/tvSyncStats"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="#6B7280" />
</LinearLayout>
<!-- Stats Summary -->
<LinearLayout
android:id="@+id/layoutSyncStats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="12dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp"
android:background="@drawable/rounded_background_green">
<TextView
android:id="@+id/tvSuccessCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="#10b981" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Successful"
android:textSize="10sp"
android:textColor="#6B7280" />
</LinearLayout>
<View
android:layout_width="8dp"
android:layout_height="1dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp"
android:background="@drawable/rounded_background_red">
<TextView
android:id="@+id/tvFailedCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="#ef4444" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Failed"
android:textSize="10sp"
android:textColor="#6B7280" />
</LinearLayout>
<View
android:layout_width="8dp"
android:layout_height="1dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp"
android:background="@drawable/rounded_background_blue">
<TextView
android:id="@+id/tvMetricsSynced"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="#0ea5e9" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Metrics"
android:textSize="10sp"
android:textColor="#6B7280" />
</LinearLayout>
</LinearLayout>
<!-- Recent Sync List -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Recent Syncs"
android:textSize="11sp"
android:textColor="#6B7280"
android:layout_marginTop="16dp" />
<LinearLayout
android:id="@+id/layoutRecentSyncs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp">
<TextView
android:id="@+id/tvNoSyncHistory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No sync history yet"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:gravity="center"
android:padding="16dp" />
</LinearLayout>
<Button
android:id="@+id/btnViewAllHistory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="View Full History"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.TextButton"
android:visibility="gone"
android:layout_marginTop="8dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Info Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="This app reads your health data from Health Connect and syncs it to your HabitForge dashboard. Keep it installed for automatic background syncing."
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAlignment="center"
android:layout_marginTop="16dp" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/tvHistoryIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📊"
android:textSize="28sp"
android:layout_marginEnd="12dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvHistoryTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Metric History"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#1F2937" />
<TextView
android:id="@+id/tvHistorySubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="All synced data from Health Connect"
android:textSize="12sp"
android:textColor="#6B7280" />
</LinearLayout>
<ImageButton
android:id="@+id/btnCloseHistory"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:contentDescription="Close" />
</LinearLayout>
<!-- Summary Stats -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp"
android:background="#F3F4F6"
android:padding="12dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/tvHistoryDataPoints"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="#10B981" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Data Points"
android:textSize="11sp"
android:textColor="#6B7280" />
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#D1D5DB" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/tvHistoryLatestValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#3B82F6" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Latest Value"
android:textSize="11sp"
android:textColor="#6B7280" />
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#D1D5DB" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/tvHistoryAverage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#8B5CF6" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Average"
android:textSize="11sp"
android:textColor="#6B7280" />
</LinearLayout>
</LinearLayout>
<!-- History List -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:id="@+id/layoutHistoryList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</ScrollView>
</LinearLayout>
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<!-- Server URL -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Server URL"
android:textSize="12sp"
android:textColor="#666666"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etServerUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="http://192.168.x.x:8000"
android:inputType="textUri"
android:layout_marginBottom="16dp" />
<!-- Username -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Username"
android:textSize="12sp"
android:textColor="#666666"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter your username"
android:inputType="text"
android:autofillHints="username"
android:layout_marginBottom="16dp" />
<!-- Password -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Password"
android:textSize="12sp"
android:textColor="#666666"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter your password"
android:inputType="textPassword"
android:autofillHints="password"
android:layout_marginBottom="8dp" />
<!-- Helper text -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Use the same credentials as the HabitForge web app"
android:textSize="11sp"
android:textColor="#888888"
android:layout_marginTop="8dp" />
<!-- Error message (hidden by default) -->
<TextView
android:id="@+id/tvError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#D32F2F"
android:textSize="12sp"
android:visibility="gone"
android:layout_marginTop="8dp" />
<!-- Loading indicator (hidden by default) -->
<ProgressBar
android:id="@+id/progressLogin"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:visibility="gone"
android:layout_marginTop="16dp" />
</LinearLayout>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/tvHistoryDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Jan 01, 2026"
android:textSize="14sp"
android:textColor="#374151" />
<TextView
android:id="@+id/tvHistoryValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="10,000"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="#1F2937" />
<TextView
android:id="@+id/tvHistoryUnit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" steps"
android:textSize="12sp"
android:textColor="#6B7280"
android:layout_marginStart="4dp" />
</LinearLayout>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp"
android:background="@drawable/rounded_background">
<!-- Status indicator and icon -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvMetricIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="👣"
android:textSize="16sp" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvMetricStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⚪"
android:textSize="10sp" />
</LinearLayout>
<!-- Metric name -->
<TextView
android:id="@+id/tvMetricName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Steps"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="#374151"
android:layout_marginTop="4dp"
android:maxLines="1"
android:ellipsize="end" />
<!-- Last value or status -->
<TextView
android:id="@+id/tvMetricValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Not synced"
android:textSize="10sp"
android:textColor="#6B7280"
android:maxLines="1"
android:ellipsize="end" />
<!-- Last sync time -->
<TextView
android:id="@+id/tvMetricSyncTime"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="8sp"
android:textColor="#9CA3AF"
android:maxLines="1"
android:ellipsize="end"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="brand_primary">#0ea5e9</color>
<color name="brand_secondary">#10b981</color>
<color name="brand_dark">#0c4a6e</color>
<color name="background_light">#F5F5F5</color>
<color name="background_dark">#1F2937</color>
<color name="text_primary">#1F2937</color>
<color name="text_secondary">#6B7280</color>
<color name="text_muted">#9CA3AF</color>
<color name="success">#10b981</color>
<color name="warning">#f59e0b</color>
<color name="error">#ef4444</color>
<!-- Status colors used in MainActivity -->
<color name="green">#10b981</color>
<color name="orange">#f59e0b</color>
<color name="red">#ef4444</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">HabitForge-companion</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HabitForgeCompanion" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/brand_primary</item>
<item name="colorPrimaryVariant">@color/brand_dark</item>
<item name="colorSecondary">@color/brand_secondary</item>
<item name="android:statusBarColor">@color/brand_primary</item>
</style>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
Allow cleartext HTTP traffic for development.
In production, you should use HTTPS only.
-->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+3
View File
@@ -0,0 +1,3 @@
#Auto-incremented Build Number
#Sat Jan 03 11:40:28 EST 2026
build.number=18
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
+54
View File
@@ -0,0 +1,54 @@
Initialized native services in: C:\Users\bruno\.gradle\native
Initialized jansi services in: C:\Users\bruno\.gradle\native
The client will now receive all logging from the daemon (pid: 40576). The daemon log file: C:\Users\bruno\.gradle\daemon\8.13\daemon-40576.out.log
Starting 7th build in daemon [uptime: 3 mins 13.508 secs, performance: 100%, GC rate: 0.00/s, heap usage: 0% of 2 GiB]
Using 16 worker leases.
Now considering [C:\dev\git\python\HabitForge\android-companion] as hierarchies to watch
Watching the file system is configured to be enabled if available
File system watching is active
Starting Build
Settings evaluated using settings file 'C:\dev\git\python\HabitForge\android-companion\settings.gradle.kts'.
Projects loaded. Root project using build file 'C:\dev\git\python\HabitForge\android-companion\build.gradle.kts'.
Included projects: [root project 'HabitForgeCompanion', project ':app']
> Configure project :
Evaluating root project 'HabitForgeCompanion' using build file 'C:\dev\git\python\HabitForge\android-companion\build.gradle.kts'.
Resolved plugin [id: 'com.android.application', version: '8.13.2', apply: false]
Resolved plugin [id: 'org.jetbrains.kotlin.android', version: '1.9.22', apply: false]
> Configure project :app
Evaluating project ':app' using build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts'.
Resolved plugin [id: 'com.android.application']
Resolved plugin [id: 'org.jetbrains.kotlin.android']
[Incubating] Problems report is available at: file:///C:/dev/git/python/HabitForge/android-companion/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* Where:
Build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts' line: 6
* What went wrong:
An exception occurred applying plugin request [id: 'com.android.application']
> Failed to apply plugin 'com.android.internal.application'.
> Android Gradle plugin requires Java 17 to run. You are currently using Java 11.
Your current JDK is located in C:\Users\bruno\scoop\apps\temurin11-jdk\current
You can try some of the following options:
- changing the IDE settings.
- changing the JAVA_HOME environment variable.
- changing `org.gradle.java.home` in `gradle.properties`.
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
For more on this, please refer to https://docs.gradle.org/8.13/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
BUILD FAILED in 3s
Watched directory hierarchies: []
+6
View File
@@ -0,0 +1,6 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=35
Binary file not shown.
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+207
View File
@@ -0,0 +1,207 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var)}», «## {, «## }», and «## arg-)};
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" style style style style style style style style style
# style functions, like:
# foo() {
# ...
# }
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# temporary variable (which is, arguably, whose alarm alarm
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# temporary variable arguments, e.g. --show-version=demo.
#
# * Print the arguments one to a line, arguments arguments that contain
# temporary variable spaces be quoted (don't work if arguments contain newlines).
# Optionally, prepend each a "++desktop ...".
#
# * Use "set" to temporary variable
#
# arguments in a shell-escaped form by://github.com/gradle/gradle/issues/22390 .
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xeli" execution mode is enabled and for the a a a a a a
# a a a a a a a loop loop the
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "HabitForgeCompanion"
include(":app")