Add initial project

This commit is contained in:
2026-07-30 23:25:20 -04:00
commit cff6aa8289
164 changed files with 26672 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# HabitForge Environment Configuration
# Copy this file to .env.development, .env.staging, or .env.production
# and fill in the actual values.
# Security
SECRET_KEY=your-secret-key-here-generate-with-python-secrets-token_urlsafe32
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# Database
# For development (SQLite):
DATABASE_URL=sqlite:///./data/habitforge.db
# For production (PostgreSQL):
# DATABASE_URL=postgresql://user:password@localhost:5432/habitforge
# CORS (Comma-separated list of allowed origins)
CORS_ORIGINS=http://localhost:8000,http://localhost:3000,https://yourdomain.com
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REDIRECT_URI=http://localhost:8000/api/google/callback
# Rate Limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_TIMES=60
RATE_LIMIT_SECONDS=60
# Application
APP_NAME=HabitForge API
APP_VERSION=1.0.0
DEBUG=true
+1
View File
@@ -0,0 +1 @@
frontend/static/*.gif filter=lfs diff=lfs merge=lfs -text
+93
View File
@@ -0,0 +1,93 @@
# Environment files
.env
.env.development
.env.staging
.env.production
.env.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
.hypothesis/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Database
data/*.db
data/*.db-shm
data/*.db-wal
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# Secrets
backend/client_secret*.json
backend/credentials.json
*.pem
*.key
# Node modules (if frontend uses npm)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Docker
docker-compose.override.yml
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.bak
*.swp
# Large data exports
export-zepp/
# Gradle build artifacts
android-companion/.gradle/
+19
View File
@@ -0,0 +1,19 @@
# Pre-commit configuration for HabitForge
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.9
hooks:
- id: ruff
name: Ruff Linter
args: [--fix]
- id: ruff-format
name: Ruff Formatter
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
name: MyPy type checker
additional_dependencies: [pydantic]
args: [--ignore-missing-imports]
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.10-slim
WORKDIR /app
# Install system dependencies for potential library compilation (bcrypt, cryptography)
RUN apt-get update && apt-get install -y \
build-essential \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Expose port
EXPOSE 8000
# Run commands
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
+54
View File
@@ -0,0 +1,54 @@
.PHONY: dev test lint format migrate help
# Development
dev:
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
# Testing
test:
pytest tests/ -v --cov=backend --cov-report=html
# Linting
lint:
ruff check backend/
mypy backend/
# Formatting
format:
ruff format backend/
ruff check --fix backend/
# Database migrations
migrate:
alembic upgrade head
# Docker
docker-up:
docker-compose -f docker-compose.dev.yml up -d
docker-down:
docker-compose -f docker-compose.dev.yml down
docker-logs:
docker-compose -f docker-compose.dev.yml logs -f
# Production
docker-prod-up:
docker-compose -f docker-compose.prod.yml up -d
docker-prod-down:
docker-compose -f docker-compose.prod.yml down
# Help
help:
@echo "Available commands:"
@echo " dev - Start development server"
@echo " test - Run tests"
@echo " lint - Run linter"
@echo " format - Format code"
@echo " migrate - Run database migrations"
@echo " docker-up - Start Docker containers (dev)"
@echo " docker-down - Stop Docker containers (dev)"
@echo " docker-logs - Show Docker logs (dev)"
@echo " docker-prod-up - Start Docker containers (prod)"
@echo " docker-prod-down - Stop Docker containers (prod)"
+416
View File
@@ -0,0 +1,416 @@
# HabitForge 🎯
HabitForge is a powerful, self-hosted habit tracking and gamification platform designed to help users build and maintain consistent routines. It combines a robust Python backend with a dynamic, modern frontend to provide a premium user experience.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.9+-blue.svg)
![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-009688.svg)
![TailwindCSS](https://img.shields.io/badge/TailwindCSS-CDN-38bdf8.svg)
## 📖 Overview
HabitForge allows users to create custom challenges (daily, weekly, monthly), track their progress with detailed analytics, and stay motivated through gamification elements like badges and streaks. The application is built with a **Mobile-First** design philosophy, ensuring a seamless experience across all devices.
### Key Features
* **🏆 Gamification:** Earn badges for streaks, total reps, and consistency.
* **📊 Analytics:** Visual charts (reps, completion rates) and consistency heatmaps.
* **🔁 Flexible Challenges:** Support for Daily, Weekly (specific days), and Monthly targets.
* **🧠 Smart Tracking:** Tracks reps, completion status, and optional notes per day.
* **🌗 Dark Mode:** Fully supported dark/light themes with persistence.
* **📱 Responsive UI:** Built with Tailwind CSS for a fluid experience on mobile and desktop.
* **🔐 Authentication:** Secure JWT-based user authentication system.
* **🖱️ Drag & Drop:** Reorder challenges easily to prioritize your focus.
---
## 🛠 Tech Stack
### Backend
* **Framework:** [FastAPI](https://fastapi.tiangolo.com/) (High performance, easy to learn)
* **Database ORM:** [SQLAlchemy](https://www.sqlalchemy.org/)
* **Database:** SQLite (Default, stored in `./data/habitforge.db`)
* **Authentication:** OAuth2 with Password (and hashing via Argon2/Passlib)
* **Schema Validation:** Pydantic Models
### Frontend
* **Core:** Vanilla JavaScript (ES6+)
* **Styling:** [Tailwind CSS](https://tailwindcss.com/) (via CDN for simplicity)
* **Icons:** FontAwesome 6
* **Animations:** Anime.js
* **Charts:** Chart.js
* **Drag & Drop:** SortableJS
### Infrastructure
* **Containerization:** Docker & Docker Compose
* **Server:** Uvicorn (ASGI)
---
## 🚀 Getting Started
### Prerequisites
Before you begin, ensure you have the following installed:
* **Docker** and **Docker Compose**
* **Python 3.9+** (if running locally without Docker)
* **Google OAuth Credentials** (optional, for health metrics sync)
#### Environment Setup
HabitForge uses environment variables for configuration.
1. **Copy the example file:**
```bash
cp .env.example .env.development
# Also create production if needed
cp .env.example .env.production
```
2. **Edit the files:** Fill in your `SECRET_KEY` and Google API credentials.
---
### 🐳 Running with Docker (Recommended)
HabitForge provides several Docker Compose configurations depending on your needs:
#### 1. Simple Mode (SQLite)
Perfect for a quick test or personal use with zero database configuration.
```bash
# Uses docker-compose.yml
docker-compose up -d --build
```
* **Database:** SQLite stored in `./data/habitforge.db`
* **Access:** `http://localhost:8000`
#### 2. Development Mode (PostgreSQL)
Ideal for development with a robust database and hot-reloading enabled.
```bash
# Uses docker-compose.dev.yml
docker-compose -f docker-compose.dev.yml up -d --build
```
* **Database:** PostgreSQL 15
* **Features:** Auto-reload enabled in the backend.
* **Access:** `http://localhost:8000`
#### 3. Production Mode (Full Stack)
The professional setup with security and performance in mind.
```bash
# Uses docker-compose.prod.yml
docker-compose -f docker-compose.prod.yml up -d --build
```
* **Components:**
* **Nginx:** Reverse proxy with SSL/TLS support.
* **Gunicorn:** Production-grade WSGI server with multiple workers.
* **PostgreSQL:** Dedicated database container.
* **Backup:** Automatic daily database backups to `./backups`.
* **Access:** `http://localhost` (or your configured domain)
---
### 🐍 Local Development (Manual)
If you prefer to run the services directly on your machine:
1. **Set up Python Environment:**
```bash
python -m venv venv
# Windows
.\venv\Scripts\activate
# Linux/Mac
source venv/bin/activate
```
2. **Install Dependencies:**
```bash
pip install -r requirements.txt
```
3. **Run the Server:**
```bash
# Ensure your .env file is present or env vars are set
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
```
---
## 📂 Project Structure
```plaintext
HabitForge/
├── backend/ # Python FastAPI Backend (Modular Architecture)
│ ├── api/v1/ # API Routes (Auth, Challenges, Health)
│ ├── core/ # Configuration, Security, Dependencies
│ ├── models/ # SQLAlchemy Database Models
│ ├── schemas/ # Pydantic Validation Schemas
│ ├── services/ # Business Logic Layer
│ ├── repositories/ # Data Access Layer
│ └── integrations/ # External APIs (Google Fit, Zepp)
├── frontend/ # Static Frontend Assets
│ ├── app.js # Main frontend logic
│ └── index.html # Dashboard
├── data/ # SQLite storage (if used)
├── nginx/ # Nginx configuration and SSL certificates
├── scripts/ # Database migrations and backup scripts
├── docker-compose.yml # Simple SQLite configuration
├── docker-compose.dev.yml # Development (Postgres) configuration
└── docker-compose.prod.yml # Production (Nginx + Postgres + Backup) configuration
```
---
## 🔌 API Documentation
Once the server is running, you can access the automatic interactive API documentation provided by Swagger UI:
* **Docs:** `http://localhost:8000/docs`
* **Redoc:** `http://localhost:8000/redoc`
### Core Endpoints
* **Auth:** `/api/auth/register`, `/api/auth/token`
* **Challenges:**
* `GET /api/challenges`: List all user challenges
* `POST /api/challenges`: Create new challenge
* `PUT /api/challenges/reorder`: Update display order
* **Tracking:**
* `POST /api/tracking`: Log reps/completion for a date
---
## 🔧 Configuration
All configuration is managed via Environment Variables or a `.env` file.
| Variable | Description | Default |
| :--- | :--- | :--- |
| `SECRET_KEY` | Key for JWT encryption | *Required* |
| `DATABASE_URL` | SQLAlchemy connection string | `sqlite:///./data/habitforge.db` |
| `CORS_ORIGINS` | Allowed origins for API | `http://localhost:8000` |
| `GOOGLE_CLIENT_ID` | Google OAuth Client ID | `""` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | `""` |
---
## 🔐 Google OAuth Setup (Health Metrics Integration)
To enable Google Fit integration for health metrics tracking, you need to create OAuth 2.0 credentials in the Google Cloud Console.
### Step 1: Create a Google Cloud Project
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Click **"Select a project"** → **"New Project"**
3. Enter a project name (e.g., "HabitForge")
4. Click **"Create"**
### Step 2: Enable Required APIs
1. In your project, navigate to **"APIs & Services"** → **"Library"**
2. Search for and enable the following APIs:
- **Google Fitness API**
- **Google OAuth2 API** (usually enabled by default)
### Step 3: Configure OAuth Consent Screen
1. Go to **"APIs & Services"** → **"OAuth consent screen"**
2. Select **"External"** (unless you have a Google Workspace)
3. Fill in the required information:
- **App name:** HabitForge
- **User support email:** Your email
- **Developer contact:** Your email
4. Click **"Save and Continue"**
5. **Scopes:** Click **"Add or Remove Scopes"** and add:
- `.../auth/fitness.activity.read`
- `.../auth/fitness.body.read`
- `.../auth/fitness.heart_rate.read`
- `.../auth/fitness.sleep.read`
6. Click **"Save and Continue"**
7. **Test users:** Add your Google account email (for testing)
8. Click **"Save and Continue"**
### Step 4: Create OAuth 2.0 Credentials
1. Go to **"APIs & Services"** → **"Credentials"**
2. Click **"+ Create Credentials"** → **"OAuth client ID"**
3. Select **"Web application"**
4. Configure:
- **Name:** HabitForge Web Client
- **Authorized JavaScript origins:**
```
http://localhost:8000
https://yourdomain.com
```
- **Authorized redirect URIs:**
```
http://localhost:8000/api/google/callback
https://yourdomain.com/api/google/callback
```
5. Click **"Create"**
6. **Copy your credentials:**
- `GOOGLE_CLIENT_ID`: The Client ID (looks like `xxxxx.apps.googleusercontent.com`)
- `GOOGLE_CLIENT_SECRET`: The Client Secret
### Step 5: Update Your .env File
Add the credentials to your `.env` file:
```bash
GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret-here
```
### Step 6: Test the Integration
1. Restart your application
2. Navigate to the Health Dashboard
3. Click the **"Sync"** button
4. Authorize HabitForge to access your Google Fit data
5. Data should sync automatically
---
## 🗄️ Database Configuration
HabitForge supports two database backends: **SQLite** (default, simple) and **PostgreSQL** (production-ready).
### Option 1: SQLite (Default - Recommended for Personal Use)
SQLite requires **no additional setup** and is perfect for single-user deployments.
**Configuration:**
```bash
# .env file
DATABASE_URL=sqlite:///./data/habitforge.db
```
**Characteristics:**
- ✅ Zero configuration required
- ✅ File-based (stored in `./data/habitforge.db`)
- ✅ Perfect for personal use
- ✅ Automatic backups via file copy
- ⚠️ Not recommended for high-concurrency scenarios
**Docker Compose:**
```bash
# Uses SQLite by default
docker-compose up -d --build
```
### Option 2: PostgreSQL (Recommended for Production)
PostgreSQL provides better performance, concurrency, and scalability.
#### A. Using Docker Compose (Easiest)
**Development Mode:**
```bash
# Uses docker-compose.dev.yml with PostgreSQL
docker-compose -f docker-compose.dev.yml up -d --build
```
**Production Mode:**
```bash
# Uses docker-compose.prod.yml with PostgreSQL + Nginx
docker-compose -f docker-compose.prod.yml up -d --build
```
The database credentials are configured in the respective `docker-compose` files.
#### B. Using External PostgreSQL Server
If you have an existing PostgreSQL server:
1. **Create a database:**
```sql
CREATE DATABASE habitforge;
CREATE USER habitforge_user WITH PASSWORD 'your_secure_password';
GRANT ALL PRIVILEGES ON DATABASE habitforge TO habitforge_user;
```
2. **Configure DATABASE_URL:**
```bash
# .env file
DATABASE_URL=postgresql://habitforge_user:your_secure_password@localhost:5432/habitforge
```
**Format:**
```
postgresql://[username]:[password]@[host]:[port]/[database_name]
```
**Examples:**
```bash
# Local PostgreSQL
DATABASE_URL=postgresql://postgres:password@localhost:5432/habitforge
# Remote PostgreSQL
DATABASE_URL=postgresql://user:[email protected]:5432/habitforge
# With SSL
DATABASE_URL=postgresql://user:[email protected]:5432/habitforge?sslmode=require
```
3. **Run migrations:**
```bash
alembic upgrade head
```
#### C. PostgreSQL on Cloud Providers
**Heroku Postgres:**
```bash
DATABASE_URL=postgresql://user:[email protected]:5432/dbname
```
**AWS RDS:**
```bash
DATABASE_URL=postgresql://username:[email protected]:5432/habitforge
```
**Google Cloud SQL:**
```bash
DATABASE_URL=postgresql://user:pass@/dbname?host=/cloudsql/project:region:instance
```
**DigitalOcean Managed Database:**
```bash
DATABASE_URL=postgresql://user:[email protected]:25060/habitforge?sslmode=require
```
### Switching Between Databases
To switch from SQLite to PostgreSQL (or vice versa):
1. **Update DATABASE_URL** in your `.env` file
2. **Run migrations:**
```bash
alembic upgrade head
```
3. **Restart the application:**
```bash
docker-compose down
docker-compose up -d --build
```
**Note:** Data is **not automatically migrated** between databases. You'll need to export/import data manually if switching with existing data.
---
### Database Migrations
The project includes Alembic for database version control.
```bash
alembic upgrade head
```
---
## 🤝 Contributing
Contributions are welcome!
1. Fork the project.
2. Create your feature branch (`git checkout -b feature/AmazingFeature`).
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`).
4. Push to the branch (`git push origin feature/AmazingFeature`).
5. Open a Pull Request.
---
## 📄 License
Distributed under the MIT License. See `LICENSE` for more information.
+135
View File
@@ -0,0 +1,135 @@
# Amélioration des Widgets Weight et BMI
## 📋 Résumé des Modifications
J'ai transformé les widgets Weight et BMI simples en widgets interactifs premium avec jauges semi-circulaires colorées, inspirés de l'image de référence fournie.
## ✨ Nouvelles Fonctionnalités
### 1. **Widgets Améliorés avec Jauges Semi-Circulaires**
#### Widget Weight (Poids)
- **Jauge semi-circulaire animée** avec gradient de couleurs (cyan → bleu → vert → ambre → rouge)
- Affichage en **livres (lbs)** avec conversion automatique
- **Statut dynamique** : BALANCED, UNDERWEIGHT, OVERWEIGHT, OBESE
- Design sombre premium avec effet hover
- **Cliquable** pour ouvrir la page de détails
#### Widget BMI
- **Jauge semi-circulaire animée** avec gradient de couleurs adapté au BMI
- Affichage de la valeur BMI avec précision
- **Statut dynamique** : NORMAL, UNDERWEIGHT, OVERWEIGHT, OBESE
- Design sombre premium avec effet hover
- **Cliquable** pour ouvrir la page de détails
### 2. **Page de Détails Modale**
Une modale complète s'ouvre en cliquant sur les widgets, offrant :
#### Navigation par Plage Temporelle
- **Recent** : 7 derniers jours
- **Week** : 7 derniers jours
- **Month** : 30 derniers jours
- **Year** : 365 derniers jours
#### Contenu de la Modale
**Pour Weight (Poids)** :
- Valeur actuelle en grand format
- Date et heure de la dernière mesure
- **Graphique d'historique** interactif avec Chart.js
- **Statistiques résumées** : Moyenne, Maximum, Minimum
- **Tendances** : Progression et comparaison avec dates antérieures
- Bouton "History data" pour export futur
- Bouton "Add Entry" pour saisie manuelle future
**Pour BMI** :
- Valeur actuelle en grand format
- Date et heure de la dernière mesure
- **Graphique d'historique** interactif
- **Statistiques résumées** : Moyenne, Maximum, Minimum
- **Informations BMI** : Formule, explications, poids idéal
- Bouton "History data" pour export futur
- Bouton "Add Entry" pour saisie manuelle future
## 🎨 Design
### Style Visuel
- **Fond sombre** : Gradient de gris-800 à gris-900/noir
- **Jauges colorées** : Gradients multi-couleurs avec indicateur triangulaire blanc
- **Typographie** : Texte blanc en gras, labels en gris clair
- **Animations** : Hover scale, transitions fluides
- **Responsive** : Adapté mobile et desktop
### Couleurs des Jauges
Les jauges utilisent un gradient de couleurs pour représenter visuellement la plage de valeurs :
- **Cyan** (#06b6d4) : Début de plage
- **Bleu** (#3b82f6) : Zone basse
- **Vert** (#10b981) : Zone optimale
- **Ambre** (#f59e0b) : Zone élevée
- **Rouge** (#ef4444) : Zone critique
## 🔧 Implémentation Technique
### Fichiers Modifiés
1. **`frontend/index.html`**
- Remplacement des widgets simples par des widgets avec canvas pour les jauges
- Ajout de la modale de détails complète
- Structure responsive avec Tailwind CSS
2. **`frontend/app.js`**
- Fonction `createGauge()` : Création de jauges semi-circulaires avec Canvas API
- Fonction `initializeMetricGauges()` : Initialisation des jauges avec données
- Fonction `openMetricDetails()` : Ouverture de la modale
- Fonction `loadMetricHistory()` : Chargement de l'historique par période
- Fonction `renderMetricChart()` : Rendu du graphique avec Chart.js
- Fonction `initMetricModal()` : Initialisation des événements de la modale
### Technologies Utilisées
- **Canvas API** : Pour le rendu des jauges semi-circulaires
- **Chart.js** : Pour les graphiques d'historique
- **Tailwind CSS** : Pour le styling responsive
- **JavaScript ES6+** : Pour la logique interactive
## 📊 Données Utilisées
Les widgets utilisent les données de santé existantes :
- `weight_kg` : Poids en kilogrammes (converti en livres pour affichage)
- `bmi` : Calculé à partir du poids et de la taille
- `date` : Date de la mesure
## 🚀 Utilisation
1. **Ouvrir le Health Dashboard** en cliquant sur le bouton "Health" dans la navigation
2. **Visualiser les widgets** Weight et BMI avec leurs jauges colorées
3. **Cliquer sur un widget** pour ouvrir la page de détails
4. **Naviguer entre les périodes** (Recent, Week, Month, Year) pour voir l'historique
5. **Consulter les statistiques** et le graphique d'évolution
## 🎯 Fonctionnalités Futures
Les boutons suivants sont préparés pour des fonctionnalités futures :
- **"Add Entry"** : Saisie manuelle de nouvelles mesures
- **"History data"** : Export des données historiques
## ✅ Compatibilité
- ✅ Mode sombre/clair
- ✅ Responsive (mobile, tablette, desktop)
- ✅ Navigateurs modernes (Chrome, Firefox, Safari, Edge)
- ✅ Intégration avec les données Google Fit existantes
## 🎨 Aperçu Visuel
Les widgets ressemblent maintenant à l'image de référence avec :
- Fond sombre élégant
- Jauges semi-circulaires colorées
- Indicateur triangulaire blanc
- Valeur en grand format au centre
- Statut et unité en dessous
- Effet hover avec légère mise à l'échelle
---
**Note** : Les widgets s'affichent uniquement lorsque des données de santé sont disponibles. Assurez-vous d'avoir synchronisé vos données Google Fit pour voir les jauges en action.
+98
View File
@@ -0,0 +1,98 @@
# HabitForge - Zepp Data Import
## Overview
This document explains how to import your Zepp/Mi Band/Amazfit health data into HabitForge.
## Prerequisites
- Zepp data export in `export-zepp/` directory (exported from Zepp app)
- HabitForge application running
- User account created in HabitForge
## Export Structure
Your Zepp export should be in the following structure:
```
export-zepp/
└── [ACCOUNT_ID_TIMESTAMP]/
├── ACTIVITY/ # Daily activity summaries (steps, distance, calories)
├── ACTIVITY_MINUTE/ # Minute-by-minute activity data
├── ACTIVITY_STAGE/ # Activity segments
├── BODY/ # Body measurements (weight, BMI, etc.)
├── HEALTH_DATA/ # Additional health metrics
├── HEARTRATE/ # Manual heart rate readings
├── HEARTRATE_AUTO/ # Automatic heart rate monitoring
├── SLEEP/ # Sleep summaries
├── SLEEP_MINUTE/ # Minute-by-minute sleep data
├── SPORT/ # Workout/sport sessions
└── USER/ # User profile data
```
## Import Process
### Option 1: Using Docker (Recommended)
```bash
docker-compose -f docker-compose.dev.yml exec web python scripts/import_zepp_data.py
```
### Option 2: Local Environment
```bash
python scripts/import_zepp_data.py
```
### Specify User ID
If you need to import data for a specific user:
```bash
python scripts/import_zepp_data.py --user-id 2
```
## Data Imported
### Daily Health Metrics
- **Activity**: Steps, distance (meters), calories burned
- **Sleep**: Total sleep time, deep sleep, light sleep, REM sleep, awake duration
- **Heart Rate**: Average, minimum, maximum, resting heart rate
- **Body**: Weight measurements
- **PAI Score**: Calculated from activity and heart rate data
### Workout Sessions
- **Running** (type 1)
- **Walking** (type 6)
- **Cycling** (type 8)
- **Elliptical** (type 10)
- **Free Training** (type 16)
- **HIIT** (type 23)
- **Stretching** (type 52)
- **Yoga** (type 60)
Each workout includes:
- Start/end time
- Duration
- Distance
- Calories burned
- Pace metrics (avg, min, max)
## API Endpoints
After importing, you can access the data via these endpoints:
### Health Stats
```
GET /api/health/stats?period=week
GET /api/health/stats?period=month
GET /api/health/stats?period=year
```
### Workout Sessions
```
GET /api/health/workouts # List workouts
GET /api/health/workouts/summary # Workout statistics
```
## Notes
- The import will merge data with existing records (taking the imported values for conflicts)
- Default user ID is 1
- The script automatically finds the latest export directory
- All timestamps are converted to local dates for daily aggregation
- PAI scores are calculated automatically based on activity and heart rate data
## Re-importing Data
If you export new data from Zepp, simply replace the contents of `export-zepp/` and run the import script again. It will upsert (update or insert) all records.
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
sqlalchemy.url = sqlite:///./data/habitforge.db
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+91
View File
@@ -0,0 +1,91 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
import os
import sys
sys.path.append(os.getcwd())
# Import Base and all models to ensure they're registered with metadata
from backend.database import Base
from backend import models # This imports all models via __init__.py
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Override URL from settings
from backend.core.config import settings
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = settings.DATABASE_URL
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,34 @@
"""Add icon and image_url
Revision ID: 4cc2e86d9794
Revises: 8ba5ee682bc1
Create Date: 2025-12-27 23:38:43.414279
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4cc2e86d9794'
down_revision: Union[str, Sequence[str], None] = '8ba5ee682bc1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('challenges', sa.Column('icon', sa.String(), nullable=True))
op.add_column('challenges', sa.Column('image_url', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('challenges', 'image_url')
op.drop_column('challenges', 'icon')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Add description
Revision ID: 5ea126309171
Revises: 4cc2e86d9794
Create Date: 2025-12-28 00:11:14.788582
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5ea126309171'
down_revision: Union[str, Sequence[str], None] = '4cc2e86d9794'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('challenges', sa.Column('description', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('challenges', 'description')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Add awake_duration_minutes to daily health metrics
Revision ID: 6c3fce1dd49a
Revises: d208fd79a572
Create Date: 2025-12-29 13:49:31.181979
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6c3fce1dd49a'
down_revision: Union[str, Sequence[str], None] = 'd208fd79a572'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('daily_health_metrics', sa.Column('awake_duration_minutes', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('daily_health_metrics', 'awake_duration_minutes')
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""Add unit_type and rest_days
Revision ID: 8ba5ee682bc1
Revises:
Create Date: 2025-12-27 22:29:05.909161
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8ba5ee682bc1'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('challenges', sa.Column('unit_type', sa.String(), nullable=True))
op.add_column('challenges', sa.Column('rest_days', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('challenges', 'rest_days')
op.drop_column('challenges', 'unit_type')
# ### end Alembic commands ###
@@ -0,0 +1,41 @@
"""Add data_source columns for provider tracking
Revision ID: a1b2c3d4e5f6
Revises: 6c3fce1dd49a
Create Date: 2026-01-01 14:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '6c3fce1dd49a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add data_source column to daily_health_metrics
op.add_column(
'daily_health_metrics',
sa.Column('data_source', sa.String(50), nullable=True, server_default='google_fit')
)
# Add data_source column to workout_sessions
op.add_column(
'workout_sessions',
sa.Column('data_source', sa.String(50), nullable=True, server_default='google_fit')
)
# Update existing rows to have default value
op.execute("UPDATE daily_health_metrics SET data_source = 'google_fit' WHERE data_source IS NULL")
op.execute("UPDATE workout_sessions SET data_source = 'google_fit' WHERE data_source IS NULL")
def downgrade() -> None:
op.drop_column('daily_health_metrics', 'data_source')
op.drop_column('workout_sessions', 'data_source')
@@ -0,0 +1,103 @@
"""Add Health Connect tables
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2026-01-01 14:35:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6g7'
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create health_connect_sync_logs table
op.create_table(
'health_connect_sync_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('device_id', sa.String(100), nullable=True),
sa.Column('sync_timestamp', sa.DateTime(), nullable=True),
sa.Column('records_pushed', sa.Integer(), nullable=True),
sa.Column('status', sa.String(50), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_health_connect_sync_logs_id', 'health_connect_sync_logs', ['id'])
op.create_index('ix_health_connect_sync_logs_user_id', 'health_connect_sync_logs', ['user_id'])
# Create health_connect_daily_metrics table
op.create_table(
'health_connect_daily_metrics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('date', sa.Date(), nullable=True),
sa.Column('step_count', sa.Integer(), nullable=True),
sa.Column('distance_meters', sa.Float(), nullable=True),
sa.Column('calories_burned', sa.Float(), nullable=True),
sa.Column('total_calories', sa.Float(), nullable=True),
sa.Column('sleep_duration_minutes', sa.Integer(), nullable=True),
sa.Column('deep_sleep_minutes', sa.Integer(), nullable=True),
sa.Column('light_sleep_minutes', sa.Integer(), nullable=True),
sa.Column('rem_sleep_minutes', sa.Integer(), nullable=True),
sa.Column('awake_duration_minutes', sa.Integer(), nullable=True),
sa.Column('avg_heart_rate', sa.Integer(), nullable=True),
sa.Column('min_heart_rate', sa.Integer(), nullable=True),
sa.Column('max_heart_rate', sa.Integer(), nullable=True),
sa.Column('resting_heart_rate', sa.Integer(), nullable=True),
sa.Column('avg_spo2', sa.Float(), nullable=True),
sa.Column('min_spo2', sa.Float(), nullable=True),
sa.Column('max_spo2', sa.Float(), nullable=True),
sa.Column('weight', sa.Float(), nullable=True),
sa.Column('last_synced', sa.DateTime(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_health_connect_daily_metrics_id', 'health_connect_daily_metrics', ['id'])
op.create_index('ix_health_connect_daily_metrics_user_id', 'health_connect_daily_metrics', ['user_id'])
op.create_index('ix_health_connect_daily_metrics_date', 'health_connect_daily_metrics', ['date'])
# Create health_connect_exercise_sessions table
op.create_table(
'health_connect_exercise_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('exercise_type', sa.Integer(), nullable=True),
sa.Column('exercise_type_name', sa.String(100), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.Column('distance_meters', sa.Float(), nullable=True),
sa.Column('calories_burned', sa.Float(), nullable=True),
sa.Column('avg_heart_rate', sa.Integer(), nullable=True),
sa.Column('max_heart_rate', sa.Integer(), nullable=True),
sa.Column('min_heart_rate', sa.Integer(), nullable=True),
sa.Column('steps', sa.Integer(), nullable=True),
sa.Column('elevation_gained', sa.Float(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('last_synced', sa.DateTime(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_health_connect_exercise_sessions_id', 'health_connect_exercise_sessions', ['id'])
op.create_index('ix_health_connect_exercise_sessions_user_id', 'health_connect_exercise_sessions', ['user_id'])
op.create_index('ix_health_connect_exercise_sessions_start_time', 'health_connect_exercise_sessions', ['start_time'])
def downgrade() -> None:
op.drop_table('health_connect_exercise_sessions')
op.drop_table('health_connect_daily_metrics')
op.drop_table('health_connect_sync_logs')
@@ -0,0 +1,36 @@
"""Add Zepp fields
Revision ID: c3049b0cefc4
Revises: 6c3fce1dd49a
Create Date: 2025-12-29 17:26:48.144904
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3049b0cefc4'
down_revision: Union[str, Sequence[str], None] = '6c3fce1dd49a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('daily_health_metrics', sa.Column('weight', sa.Float(), nullable=True))
op.add_column('daily_health_metrics', sa.Column('resting_heart_rate', sa.Integer(), nullable=True))
op.add_column('daily_health_metrics', sa.Column('hrv', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('daily_health_metrics', 'hrv')
op.drop_column('daily_health_metrics', 'resting_heart_rate')
op.drop_column('daily_health_metrics', 'weight')
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""Add_frequency_fields
Revision ID: d208fd79a572
Revises: 5ea126309171
Create Date: 2025-12-28 12:59:55.198335
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd208fd79a572'
down_revision: Union[str, Sequence[str], None] = '5ea126309171'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('challenges', sa.Column('frequency_count', sa.Integer(), nullable=True))
op.add_column('challenges', sa.Column('selected_days', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('challenges', 'selected_days')
op.drop_column('challenges', 'frequency_count')
# ### end Alembic commands ###
@@ -0,0 +1,51 @@
"""Add data_source columns to health_connect_daily_metrics
Revision ID: e4f5g6h7i8j9
Revises: b2c3d4e5f6g7
Create Date: 2026-01-07 15:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e4f5g6h7i8j9'
down_revision: Union[str, None] = 'b2c3d4e5f6g7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add data_source column to health_connect_daily_metrics
op.add_column(
'health_connect_daily_metrics',
sa.Column('data_source', sa.String(200), nullable=True)
)
# Add data_source_name column to health_connect_daily_metrics
op.add_column(
'health_connect_daily_metrics',
sa.Column('data_source_name', sa.String(100), nullable=True)
)
# Add data_source column to health_connect_exercise_sessions
op.add_column(
'health_connect_exercise_sessions',
sa.Column('data_source', sa.String(200), nullable=True)
)
# Add data_source_name column to health_connect_exercise_sessions
op.add_column(
'health_connect_exercise_sessions',
sa.Column('data_source_name', sa.String(100), nullable=True)
)
def downgrade() -> None:
op.drop_column('health_connect_daily_metrics', 'data_source')
op.drop_column('health_connect_daily_metrics', 'data_source_name')
op.drop_column('health_connect_exercise_sessions', 'data_source')
op.drop_column('health_connect_exercise_sessions', 'data_source_name')
@@ -0,0 +1,391 @@
"""Add Health Connect raw data tables
Revision ID: f5g6h7i8j9k0
Revises: e4f5g6h7i8j9
Create Date: 2026-01-08 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f5g6h7i8j9k0'
down_revision = 'e4f5g6h7i8j9'
branch_labels = None
depends_on = None
def upgrade() -> None:
# === Steps ===
op.create_table(
'hc_raw_steps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('count', sa.Integer(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_steps_record')
)
op.create_index('ix_hc_raw_steps_id', 'hc_raw_steps', ['id'])
op.create_index('ix_hc_raw_steps_user_id', 'hc_raw_steps', ['user_id'])
op.create_index('ix_hc_raw_steps_record_id', 'hc_raw_steps', ['record_id'])
op.create_index('ix_hc_raw_steps_start_time', 'hc_raw_steps', ['start_time'])
op.create_index('ix_steps_user_date', 'hc_raw_steps', ['user_id', 'start_time'])
# === Heart Rate ===
op.create_table(
'hc_raw_heart_rate',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hr_record')
)
op.create_index('ix_hc_raw_heart_rate_id', 'hc_raw_heart_rate', ['id'])
op.create_index('ix_hc_raw_heart_rate_user_id', 'hc_raw_heart_rate', ['user_id'])
op.create_index('ix_hc_raw_heart_rate_record_id', 'hc_raw_heart_rate', ['record_id'])
op.create_index('ix_hc_raw_heart_rate_start_time', 'hc_raw_heart_rate', ['start_time'])
op.create_index('ix_hr_user_date', 'hc_raw_heart_rate', ['user_id', 'start_time'])
# === Heart Rate Samples ===
op.create_table(
'hc_raw_heart_rate_samples',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('record_id', sa.Integer(), nullable=True),
sa.Column('time', sa.DateTime(), nullable=True),
sa.Column('bpm', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['record_id'], ['hc_raw_heart_rate.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_hc_raw_heart_rate_samples_id', 'hc_raw_heart_rate_samples', ['id'])
op.create_index('ix_hc_raw_heart_rate_samples_record_id', 'hc_raw_heart_rate_samples', ['record_id'])
op.create_index('ix_hc_raw_heart_rate_samples_time', 'hc_raw_heart_rate_samples', ['time'])
# === Resting Heart Rate ===
op.create_table(
'hc_raw_resting_hr',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('bpm', sa.Integer(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_resting_hr_record')
)
op.create_index('ix_hc_raw_resting_hr_id', 'hc_raw_resting_hr', ['id'])
op.create_index('ix_hc_raw_resting_hr_user_id', 'hc_raw_resting_hr', ['user_id'])
op.create_index('ix_hc_raw_resting_hr_record_id', 'hc_raw_resting_hr', ['record_id'])
op.create_index('ix_hc_raw_resting_hr_time', 'hc_raw_resting_hr', ['time'])
op.create_index('ix_resting_hr_user_date', 'hc_raw_resting_hr', ['user_id', 'time'])
# === Sleep Sessions ===
op.create_table(
'hc_raw_sleep_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('title', sa.String(200), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_sleep_record')
)
op.create_index('ix_hc_raw_sleep_sessions_id', 'hc_raw_sleep_sessions', ['id'])
op.create_index('ix_hc_raw_sleep_sessions_user_id', 'hc_raw_sleep_sessions', ['user_id'])
op.create_index('ix_hc_raw_sleep_sessions_record_id', 'hc_raw_sleep_sessions', ['record_id'])
op.create_index('ix_hc_raw_sleep_sessions_start_time', 'hc_raw_sleep_sessions', ['start_time'])
op.create_index('ix_sleep_user_date', 'hc_raw_sleep_sessions', ['user_id', 'start_time'])
# === Sleep Stages ===
op.create_table(
'hc_raw_sleep_stages',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('stage_type', sa.Integer(), nullable=True),
sa.Column('stage_name', sa.String(50), nullable=True),
sa.ForeignKeyConstraint(['session_id'], ['hc_raw_sleep_sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_hc_raw_sleep_stages_id', 'hc_raw_sleep_stages', ['id'])
op.create_index('ix_hc_raw_sleep_stages_session_id', 'hc_raw_sleep_stages', ['session_id'])
# === Distance ===
op.create_table(
'hc_raw_distance',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('distance_meters', sa.Float(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_distance_record')
)
op.create_index('ix_hc_raw_distance_id', 'hc_raw_distance', ['id'])
op.create_index('ix_hc_raw_distance_user_id', 'hc_raw_distance', ['user_id'])
op.create_index('ix_hc_raw_distance_record_id', 'hc_raw_distance', ['record_id'])
op.create_index('ix_hc_raw_distance_start_time', 'hc_raw_distance', ['start_time'])
op.create_index('ix_distance_user_date', 'hc_raw_distance', ['user_id', 'start_time'])
# === Calories ===
op.create_table(
'hc_raw_calories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('calories', sa.Float(), nullable=True),
sa.Column('record_type', sa.String(20), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_calories_record')
)
op.create_index('ix_hc_raw_calories_id', 'hc_raw_calories', ['id'])
op.create_index('ix_hc_raw_calories_user_id', 'hc_raw_calories', ['user_id'])
op.create_index('ix_hc_raw_calories_record_id', 'hc_raw_calories', ['record_id'])
op.create_index('ix_hc_raw_calories_start_time', 'hc_raw_calories', ['start_time'])
op.create_index('ix_calories_user_date', 'hc_raw_calories', ['user_id', 'start_time'])
# === Oxygen Saturation (SpO2) ===
op.create_table(
'hc_raw_spo2',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('percentage', sa.Float(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_spo2_record')
)
op.create_index('ix_hc_raw_spo2_id', 'hc_raw_spo2', ['id'])
op.create_index('ix_hc_raw_spo2_user_id', 'hc_raw_spo2', ['user_id'])
op.create_index('ix_hc_raw_spo2_record_id', 'hc_raw_spo2', ['record_id'])
op.create_index('ix_hc_raw_spo2_time', 'hc_raw_spo2', ['time'])
op.create_index('ix_spo2_user_date', 'hc_raw_spo2', ['user_id', 'time'])
# === Weight ===
op.create_table(
'hc_raw_weight',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('weight_kg', sa.Float(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_weight_record')
)
op.create_index('ix_hc_raw_weight_id', 'hc_raw_weight', ['id'])
op.create_index('ix_hc_raw_weight_user_id', 'hc_raw_weight', ['user_id'])
op.create_index('ix_hc_raw_weight_record_id', 'hc_raw_weight', ['record_id'])
op.create_index('ix_hc_raw_weight_time', 'hc_raw_weight', ['time'])
op.create_index('ix_weight_user_date', 'hc_raw_weight', ['user_id', 'time'])
# === Height ===
op.create_table(
'hc_raw_height',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('height_meters', sa.Float(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_height_record')
)
op.create_index('ix_hc_raw_height_id', 'hc_raw_height', ['id'])
op.create_index('ix_hc_raw_height_user_id', 'hc_raw_height', ['user_id'])
op.create_index('ix_hc_raw_height_record_id', 'hc_raw_height', ['record_id'])
op.create_index('ix_hc_raw_height_time', 'hc_raw_height', ['time'])
op.create_index('ix_height_user_date', 'hc_raw_height', ['user_id', 'time'])
# === Body Fat ===
op.create_table(
'hc_raw_body_fat',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('percentage', sa.Float(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_body_fat_record')
)
op.create_index('ix_hc_raw_body_fat_id', 'hc_raw_body_fat', ['id'])
op.create_index('ix_hc_raw_body_fat_user_id', 'hc_raw_body_fat', ['user_id'])
op.create_index('ix_hc_raw_body_fat_record_id', 'hc_raw_body_fat', ['record_id'])
op.create_index('ix_hc_raw_body_fat_time', 'hc_raw_body_fat', ['time'])
op.create_index('ix_body_fat_user_date', 'hc_raw_body_fat', ['user_id', 'time'])
# === Exercise Sessions ===
op.create_table(
'hc_raw_exercise_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('exercise_type', sa.Integer(), nullable=True),
sa.Column('exercise_type_name', sa.String(100), nullable=True),
sa.Column('title', sa.String(200), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('distance_meters', sa.Float(), nullable=True),
sa.Column('calories', sa.Float(), nullable=True),
sa.Column('avg_heart_rate', sa.Integer(), nullable=True),
sa.Column('max_heart_rate', sa.Integer(), nullable=True),
sa.Column('min_heart_rate', sa.Integer(), nullable=True),
sa.Column('steps', sa.Integer(), nullable=True),
sa.Column('elevation_gained_meters', sa.Float(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_exercise_record')
)
op.create_index('ix_hc_raw_exercise_sessions_id', 'hc_raw_exercise_sessions', ['id'])
op.create_index('ix_hc_raw_exercise_sessions_user_id', 'hc_raw_exercise_sessions', ['user_id'])
op.create_index('ix_hc_raw_exercise_sessions_record_id', 'hc_raw_exercise_sessions', ['record_id'])
op.create_index('ix_hc_raw_exercise_sessions_start_time', 'hc_raw_exercise_sessions', ['start_time'])
op.create_index('ix_exercise_user_date', 'hc_raw_exercise_sessions', ['user_id', 'start_time'])
# === Nutrition ===
op.create_table(
'hc_raw_nutrition',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('name', sa.String(200), nullable=True),
sa.Column('meal_type', sa.Integer(), nullable=True),
sa.Column('calories', sa.Float(), nullable=True),
sa.Column('protein_grams', sa.Float(), nullable=True),
sa.Column('carbohydrates_grams', sa.Float(), nullable=True),
sa.Column('fat_grams', sa.Float(), nullable=True),
sa.Column('fiber_grams', sa.Float(), nullable=True),
sa.Column('sugar_grams', sa.Float(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_nutrition_record')
)
op.create_index('ix_hc_raw_nutrition_id', 'hc_raw_nutrition', ['id'])
op.create_index('ix_hc_raw_nutrition_user_id', 'hc_raw_nutrition', ['user_id'])
op.create_index('ix_hc_raw_nutrition_record_id', 'hc_raw_nutrition', ['record_id'])
op.create_index('ix_hc_raw_nutrition_start_time', 'hc_raw_nutrition', ['start_time'])
op.create_index('ix_nutrition_user_date', 'hc_raw_nutrition', ['user_id', 'start_time'])
# === Hydration ===
op.create_table(
'hc_raw_hydration',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('record_id', sa.String(100), nullable=True),
sa.Column('data_source', sa.String(200), nullable=True),
sa.Column('data_source_name', sa.String(100), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('synced_at', sa.DateTime(), nullable=True),
sa.Column('volume_liters', sa.Float(), nullable=True),
sa.Column('sync_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hydration_record')
)
op.create_index('ix_hc_raw_hydration_id', 'hc_raw_hydration', ['id'])
op.create_index('ix_hc_raw_hydration_user_id', 'hc_raw_hydration', ['user_id'])
op.create_index('ix_hc_raw_hydration_record_id', 'hc_raw_hydration', ['record_id'])
op.create_index('ix_hc_raw_hydration_start_time', 'hc_raw_hydration', ['start_time'])
op.create_index('ix_hydration_user_date', 'hc_raw_hydration', ['user_id', 'start_time'])
def downgrade() -> None:
# Drop all raw data tables
op.drop_table('hc_raw_hydration')
op.drop_table('hc_raw_nutrition')
op.drop_table('hc_raw_exercise_sessions')
op.drop_table('hc_raw_body_fat')
op.drop_table('hc_raw_height')
op.drop_table('hc_raw_weight')
op.drop_table('hc_raw_spo2')
op.drop_table('hc_raw_calories')
op.drop_table('hc_raw_distance')
op.drop_table('hc_raw_sleep_stages')
op.drop_table('hc_raw_sleep_sessions')
op.drop_table('hc_raw_resting_hr')
op.drop_table('hc_raw_heart_rate_samples')
op.drop_table('hc_raw_heart_rate')
op.drop_table('hc_raw_steps')
+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,119 @@
<?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>
<!-- Raw Data Viewer Activity -->
<activity
android:name=".RawDataViewerActivity"
android:exported="false"
android:theme="@style/Theme.HabitForgeCompanion" />
<!-- Sync Configuration Activity -->
<activity
android:name=".SyncConfigActivity"
android:exported="false"
android:theme="@style/Theme.HabitForgeCompanion" />
<!-- 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,282 @@
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_REFRESH_TOKEN = "refresh_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 raw refresh token
*/
fun getRefreshToken(): String? {
return prefs.getString(KEY_REFRESH_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,
refreshToken = loginResponse.refresh_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, refreshToken: String, username: String, userId: Int) {
prefs.edit()
.putString(KEY_SERVER_URL, serverUrl)
.putString(KEY_ACCESS_TOKEN, token)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.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_REFRESH_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)
*/
/**
* Refresh token if needed
*/
suspend fun refreshTokenIfNeeded(): Boolean {
val token = getAuthorizationHeader() ?: return false
val serverUrl = getServerUrl()
return withContext(Dispatchers.IO) {
val api = HabitForgeApi.create(serverUrl)
// 1. Try to use current token
try {
val response = api.getCurrentUser(token)
if (response.isSuccessful) {
return@withContext true
}
} catch (e: Exception) {
// If it's a network error unrelated to auth, we might want to fail?
// But let's try refresh anyway if we have a refresh token
}
// 2. Token invalid, try to refresh
val refreshToken = getRefreshToken() ?: return@withContext false
try {
val refreshResponse = api.refreshToken(RefreshTokenRequest(refreshToken))
if (refreshResponse.isSuccessful && refreshResponse.body() != null) {
val newTokens = refreshResponse.body()!!
updateTokens(newTokens.access_token, newTokens.refresh_token)
return@withContext true
}
} catch (e: Exception) {
// Refresh failed
}
// 3. Keep logged in locally? No, if we can't refresh, caller handles it.
return@withContext false
}
}
private fun updateTokens(accessToken: String, refreshToken: String) {
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.apply()
// Update legacy preference for sync service compatibility if needed
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.edit()
.putString("auth_token", accessToken)
.apply()
}
/**
* 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,316 @@
package com.habitforge.companion
import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
/** 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>
/** Refresh access token */
@POST("api/auth/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<LoginResponse>
/** Get current user information */
@GET("api/auth/me")
suspend fun getCurrentUser(@Header("Authorization") token: String): Response<UserResponse>
/** Sync aggregated health data (legacy endpoint) */
@POST("api/health-connect/sync")
suspend fun syncHealthData(
@Header("Authorization") token: String,
@Body request: HealthConnectSyncRequest
): Response<SyncResponse>
/** Sync raw health data (new endpoint for individual records) */
@POST("api/health-connect/sync-raw")
suspend fun syncRawHealthData(
@Header("Authorization") token: String,
@Body request: RawDataSyncRequest
): Response<RawSyncResponse>
@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(
120,
TimeUnit.SECONDS
) // Increased for large raw data
.writeTimeout(120, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(HabitForgeApi::class.java)
}
}
}
// ==================== LEGACY 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 source tracking
val data_source: String? = null, // Package name (e.g., "com.huami.watch.hmwatchmanager")
val data_source_name: String? = null // Friendly name (e.g., "Zepp")
)
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
)
// ==================== RAW DATA SYNC MODELS ====================
/** Complete raw data sync request */
data class RawDataSyncRequest(
val device_id: String,
val sync_timestamp: String,
val steps_records: List<RawStepsPayload> = emptyList(),
val heart_rate_records: List<RawHeartRatePayload> = emptyList(),
val resting_hr_records: List<RawRestingHRPayload> = emptyList(),
val sleep_sessions: List<RawSleepSessionPayload> = emptyList(),
val distance_records: List<RawDistancePayload> = emptyList(),
val calories_records: List<RawCaloriesPayload> = emptyList(),
val spo2_records: List<RawSpo2Payload> = emptyList(),
val weight_records: List<RawWeightPayload> = emptyList(),
val height_records: List<RawHeightPayload> = emptyList(),
val body_fat_records: List<RawBodyFatPayload> = emptyList(),
val exercise_sessions: List<RawExercisePayload> = emptyList(),
val nutrition_records: List<RawNutritionPayload> = emptyList(),
val hydration_records: List<RawHydrationPayload> = emptyList()
)
data class RawStepsPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val start_time: String,
val end_time: String,
val count: Long
)
data class HeartRateSamplePayload(val time: String, val bpm: Long)
data class RawHeartRatePayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val start_time: String,
val end_time: String,
val samples: List<HeartRateSamplePayload>
)
data class RawRestingHRPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val time: String,
val bpm: Long
)
data class SleepStagePayload(
val start_time: String,
val end_time: String,
val stage_type: Int,
val stage_name: String?
)
data class RawSleepSessionPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val start_time: String,
val end_time: String,
val title: String?,
val notes: String?,
val stages: List<SleepStagePayload>
)
data class RawDistancePayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val start_time: String,
val end_time: String,
val distance_meters: Double
)
data class RawCaloriesPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val start_time: String,
val end_time: String,
val calories: Double,
val record_type: String
)
data class RawSpo2Payload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val time: String,
val percentage: Double
)
data class RawWeightPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val time: String,
val weight_kg: Double
)
data class RawHeightPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val time: String,
val height_meters: Double
)
data class RawBodyFatPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val time: String,
val percentage: Double
)
data class RawExercisePayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val start_time: String,
val end_time: String,
val exercise_type: Int,
val exercise_type_name: String?,
val title: String?,
val notes: String?,
val distance_meters: Double?,
val calories: Double?,
val avg_heart_rate: Int?,
val max_heart_rate: Int?,
val min_heart_rate: Int?,
val steps: Int?,
val elevation_gained_meters: Double?
)
data class RawNutritionPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val start_time: String,
val end_time: String,
val name: String?,
val meal_type: Int?,
val calories: Double?,
val protein_grams: Double?,
val carbohydrates_grams: Double?,
val fat_grams: Double?,
val fiber_grams: Double?,
val sugar_grams: Double?
)
data class RawHydrationPayload(
val record_id: String,
val data_source: String,
val data_source_name: String?,
val start_time: String,
val end_time: String,
val volume_liters: Double
)
data class RawSyncResponse(
val status: String,
val message: String,
val sync_id: Int,
val records_synced: Map<String, Int>
)
// ==================== AUTH MODELS ====================
data class RefreshTokenRequest(val refresh_token: String)
data class LoginResponse(val access_token: String, val refresh_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
class HabitForgeApp : Application(), Configuration.Provider {
lateinit var healthConnectManager: HealthConnectManager
private set
override fun onCreate() {
super.onCreate()
instance = this
healthConnectManager = HealthConnectManager(this)
// Initialize periodic sync if user is logged in (ensures sync persists after app restart)
RawDataSyncService.initializeIfLoggedIn(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,941 @@
package com.habitforge.companion
import android.content.Context
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 java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Health Connect Manager - Handles all interactions with Health Connect API Implements: Permission
* requests, data reading, and source filtering
*/
class HealthConnectManager(private val context: Context) {
private var healthConnectClient: HealthConnectClient? = null
// Data source preferences
private val prefs by lazy {
context.getSharedPreferences("habitforge_sources", Context.MODE_PRIVATE)
}
companion object {
private const val TAG = "HealthConnectManager"
private const val PREF_PREFERRED_SOURCE = "preferred_data_source"
private const val PREF_SOURCE_FILTER_ENABLED = "source_filter_enabled"
// 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"
)
// Known data sources with friendly names
val KNOWN_SOURCES =
mapOf(
"com.huami.watch.hmwatchmanager" to "Zepp",
"com.google.android.apps.fitness" to "Google Fit",
"com.sec.android.app.shealth" to "Samsung Health",
"com.xiaomi.wearable" to "Mi Fitness",
"com.oneplus.health" to "OnePlus Health"
// Add more as discovered
)
}
// ==================== DATA SOURCE MANAGEMENT ====================
/** Get all available data sources from Health Connect */
suspend fun getAvailableDataSources(): List<DataSourceInfo> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val sources = mutableSetOf<String>()
try {
// Check sources from recent steps records
val timeRange =
TimeRangeFilter.between(
LocalDate.now()
.minusDays(30)
.atStartOfDay(ZoneId.systemDefault())
.toInstant(),
LocalDate.now()
.plusDays(1)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
)
val stepsRecords =
readAllRecordsPaginated<StepsRecord>(client, timeRange)
stepsRecords.forEach { record ->
sources.add(record.metadata.dataOrigin.packageName)
}
Log.d(TAG, "Found ${sources.size} data sources: $sources")
} catch (e: Exception) {
Log.e(TAG, "Error getting data sources", e)
}
sources
.map { packageName ->
DataSourceInfo(
packageName = packageName,
displayName = KNOWN_SOURCES[packageName]
?: packageName.substringAfterLast(
"."
)
.replaceFirstChar {
it.uppercase()
},
isPreferred = packageName == getPreferredSource()
)
}
.sortedBy { it.displayName }
}
/** Set the preferred data source */
fun setPreferredSource(packageName: String?) {
prefs.edit().putString(PREF_PREFERRED_SOURCE, packageName).apply()
Log.d(TAG, "Preferred source set to: $packageName")
}
/** Get the preferred data source */
fun getPreferredSource(): String? {
return prefs.getString(PREF_PREFERRED_SOURCE, null)
}
/** Enable/disable source filtering */
fun setSourceFilterEnabled(enabled: Boolean) {
prefs.edit().putBoolean(PREF_SOURCE_FILTER_ENABLED, enabled).apply()
}
/** Check if source filtering is enabled */
fun isSourceFilterEnabled(): Boolean {
return prefs.getBoolean(PREF_SOURCE_FILTER_ENABLED, false)
}
/** Get friendly name for a package */
fun getSourceDisplayName(packageName: String): String {
return KNOWN_SOURCES[packageName]
?: packageName.substringAfterLast(".").replaceFirstChar { it.uppercase() }
}
// ==================== AVAILABILITY & PERMISSIONS ====================
/** 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 */
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,
"=== Starting readDailyMetrics from $actualStartDate to $actualEndDate ==="
)
val preferredSource =
if (isSourceFilterEnabled()) getPreferredSource() else null
Log.d(
TAG,
"Source filter: ${if (preferredSource != null) "filtering by $preferredSource" else "disabled"}"
)
val metrics = mutableListOf<DailyMetrics>()
var chunkStartDate = actualStartDate
var chunkNumber = 0
// Process in 30-day chunks to avoid OOM and partial failures
while (!chunkStartDate.isAfter(actualEndDate)) {
chunkNumber++
val chunkEndDate =
if (chunkStartDate.plusDays(29).isAfter(actualEndDate)) {
actualEndDate
} else {
chunkStartDate.plusDays(29)
}
try {
Log.d(
TAG,
"=== Chunk $chunkNumber: $chunkStartDate to $chunkEndDate ==="
)
val chunkStartInstant =
chunkStartDate
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
val chunkEndInstant =
chunkEndDate
.plusDays(1)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
val timeRange =
TimeRangeFilter.between(
chunkStartInstant,
chunkEndInstant
)
// Fetch all metrics for this chunk using pagination
val chunkSteps =
try {
val allSteps =
readAllRecordsPaginated<
StepsRecord>(
client,
timeRange
)
// Filter by source if enabled, also filter
// out invalid records
allSteps.filter { record ->
val validTime =
record.startTime.isBefore(
record.endTime
)
val matchesSource =
preferredSource == null ||
record.metadata
.dataOrigin
.packageName ==
preferredSource
if (!validTime) {
Log.w(
TAG,
"Skipping invalid StepsRecord: startTime >= endTime"
)
}
validTime && matchesSource
}
} catch (e: Exception) {
Log.e(
TAG,
"Chunk $chunkNumber: Error reading steps",
e
)
emptyList()
}
val chunkDistance =
try {
val all =
readAllRecordsPaginated<
DistanceRecord>(
client,
timeRange
)
all.filter { record ->
val validTime =
record.startTime.isBefore(
record.endTime
)
val matchesSource =
preferredSource == null ||
record.metadata
.dataOrigin
.packageName ==
preferredSource
validTime && matchesSource
}
} catch (e: Exception) {
Log.w(
TAG,
"Chunk $chunkNumber: Error reading distance",
e
)
emptyList()
}
val chunkCalories =
try {
val all =
readAllRecordsPaginated<
TotalCaloriesBurnedRecord>(
client,
timeRange
)
all.filter { record ->
val validTime =
record.startTime.isBefore(
record.endTime
)
val matchesSource =
preferredSource == null ||
record.metadata
.dataOrigin
.packageName ==
preferredSource
validTime && matchesSource
}
} catch (e: Exception) {
Log.w(
TAG,
"Chunk $chunkNumber: Error reading calories",
e
)
emptyList()
}
val chunkHeartRate =
try {
val all =
readAllRecordsPaginated<
HeartRateRecord>(
client,
timeRange
)
all.filter { record ->
val validTime =
record.startTime.isBefore(
record.endTime
)
val matchesSource =
preferredSource == null ||
record.metadata
.dataOrigin
.packageName ==
preferredSource
validTime && matchesSource
}
} catch (e: Exception) {
Log.w(
TAG,
"Chunk $chunkNumber: Error reading heart rate",
e
)
emptyList()
}
val chunkRestingHR =
try {
val all =
readAllRecordsPaginated<
RestingHeartRateRecord>(
client,
timeRange
)
all.filter {
preferredSource == null ||
it.metadata
.dataOrigin
.packageName ==
preferredSource
}
} catch (e: Exception) {
Log.w(
TAG,
"Chunk $chunkNumber: Error reading resting HR",
e
)
emptyList()
}
val chunkSleep =
try {
val all =
readAllRecordsPaginated<
SleepSessionRecord>(
client,
timeRange
)
all.filter { record ->
val validTime =
record.startTime.isBefore(
record.endTime
)
val matchesSource =
preferredSource == null ||
record.metadata
.dataOrigin
.packageName ==
preferredSource
validTime && matchesSource
}
} catch (e: Exception) {
Log.w(
TAG,
"Chunk $chunkNumber: Error reading sleep",
e
)
emptyList()
}
val chunkWeight =
try {
val all =
readAllRecordsPaginated<
WeightRecord>(
client,
timeRange
)
all.filter {
preferredSource == null ||
it.metadata
.dataOrigin
.packageName ==
preferredSource
}
} catch (e: Exception) {
Log.w(
TAG,
"Chunk $chunkNumber: Error reading weight",
e
)
emptyList()
}
val chunkSpo2 =
try {
val all =
readAllRecordsPaginated<
OxygenSaturationRecord>(
client,
timeRange
)
all.filter {
preferredSource == null ||
it.metadata
.dataOrigin
.packageName ==
preferredSource
}
} catch (e: Exception) {
Log.w(
TAG,
"Chunk $chunkNumber: Error reading spo2",
e
)
emptyList()
}
Log.d(
TAG,
"Chunk $chunkNumber filtered data: Steps=${chunkSteps.size}, Distance=${chunkDistance.size}, Calories=${chunkCalories.size}, HR=${chunkHeartRate.size}, Sleep=${chunkSleep.size}"
)
// Get primary source for this chunk
val primarySource =
chunkSteps.firstOrNull()
?.metadata
?.dataOrigin
?.packageName
?: ""
// Process each day in this chunk
var currentDate = chunkStartDate
var daysWithSteps = 0
while (!currentDate.isAfter(chunkEndDate)) {
try {
val dayStart =
currentDate
.atStartOfDay(
ZoneId.systemDefault()
)
.toInstant()
val dayEnd =
currentDate
.plusDays(1)
.atStartOfDay(
ZoneId.systemDefault()
)
.toInstant()
// Filter records for this specific day
val daySteps =
chunkSteps
.filter {
!it.startTime.isBefore(dayStart) &&
it.startTime.isBefore(dayEnd)
}
.sumOf { it.count }
if (daySteps > 0) {
daysWithSteps++
}
val dayDistance =
chunkDistance
.filter {
!it.startTime.isBefore(dayStart) &&
it.startTime.isBefore(dayEnd)
}
.sumOf {
it.distance.inMeters
}
val dayCalories =
chunkCalories
.filter {
!it.startTime.isBefore(dayStart) &&
it.startTime.isBefore(dayEnd)
}
.sumOf {
it.energy
.inKilocalories
}
val dayHRRecords =
chunkHeartRate.filter {
!it.startTime.isBefore(dayStart) &&
it.startTime.isBefore(dayEnd)
}
val hrSamples =
dayHRRecords.flatMap { it.samples }
val dayRestingHR =
chunkRestingHR
.filter {
!it.time.isBefore(dayStart) &&
it.time.isBefore(dayEnd)
}
.lastOrNull()
?.beatsPerMinute
?.toInt()
val dayWeight =
chunkWeight
.filter {
!it.time.isBefore(dayStart) &&
it.time.isBefore(dayEnd)
}
.lastOrNull()
?.weight
?.inKilograms
val daySpo2 =
chunkSpo2
.filter {
!it.time.isBefore(dayStart) &&
it.time.isBefore(dayEnd)
}
.lastOrNull()
?.percentage
?.value
// Sleep logic - attribute to waking day
val daySleep =
chunkSleep.filter {
!it.endTime.isBefore(dayStart) &&
!it.endTime.isAfter(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,
dataSource = primarySource
)
)
} catch (e: Exception) {
Log.e(
TAG,
"Error processing day $currentDate in chunk $chunkNumber",
e
)
}
currentDate = currentDate.plusDays(1)
}
Log.d(
TAG,
"Chunk $chunkNumber complete: $daysWithSteps days with steps data"
)
} catch (e: Exception) {
Log.e(
TAG,
"=== Chunk $chunkNumber FAILED with exception ===",
e
)
}
// Move to next chunk
chunkStartDate = chunkEndDate.plusDays(1)
}
Log.d(
TAG,
"=== readDailyMetrics complete: ${metrics.size} total days, ${metrics.count { it.steps > 0 }} with steps ==="
)
metrics
}
/** Read exercise sessions for a date range with pagination support */
suspend fun readExerciseSessions(
startDate: LocalDate,
endDate: LocalDate
): List<ExerciseData> =
withContext(Dispatchers.IO) {
val client =
healthConnectClient
?: run {
if (isAvailable() ==
HealthConnectAvailability.AVAILABLE
)
healthConnectClient
else null
}
?: return@withContext emptyList()
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 preferredSource =
if (isSourceFilterEnabled()) getPreferredSource() else null
val exercises = mutableListOf<ExerciseData>()
try {
val timeRange =
TimeRangeFilter.between(
actualStartDate
.atStartOfDay(ZoneId.systemDefault())
.toInstant(),
actualEndDate
.plusDays(1)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
)
val allRecords =
readAllRecordsPaginated<ExerciseSessionRecord>(
client,
timeRange
)
.filter { record ->
val validTime =
record.startTime.isBefore(
record.endTime
)
val matchesSource =
preferredSource == null ||
record.metadata
.dataOrigin
.packageName ==
preferredSource
validTime && matchesSource
}
for (record in allRecords) {
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,
dataSource =
record.metadata
.dataOrigin
.packageName
)
)
}
} 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.
*/
private suspend inline fun <reified T : Record> readAllRecordsPaginated(
client: HealthConnectClient,
timeRange: TimeRangeFilter
): List<T> {
val allRecords = mutableListOf<T>()
var pageToken: String? = null
var pageCount = 0
do {
pageCount++
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
if (response.records.isNotEmpty()) {
Log.d(
TAG,
" Page $pageCount: ${response.records.size} ${T::class.simpleName} records (total: ${allRecords.size}, hasMore: ${pageToken != null})"
)
}
} while (pageToken != null)
return allRecords
}
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 DataSourceInfo(
val packageName: String,
val displayName: String,
val isPreferred: Boolean
)
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?,
val dataSource: String = "" // Package name of the data source
)
data class ExerciseData(
val startTime: String,
val endTime: String,
val exerciseType: Int,
val exerciseTypeName: String,
val durationMinutes: Int,
val title: String?,
val notes: String?,
val dataSource: String = "" // Package name of the data source
)
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,898 @@
package com.habitforge.companion
import android.content.Context
import android.util.Log
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.records.*
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Health Connect Raw Data Reader
*
* Reads individual health records from Health Connect without aggregation. Each record includes its
* full metadata (record ID, timestamps, source).
*/
class HealthConnectRawReader(private val context: Context) {
private var healthConnectClient: HealthConnectClient? = null
companion object {
private const val TAG = "HCRawReader"
// Sleep stage type names
val SLEEP_STAGE_NAMES =
mapOf(
SleepSessionRecord.STAGE_TYPE_UNKNOWN to "unknown",
SleepSessionRecord.STAGE_TYPE_AWAKE to "awake",
SleepSessionRecord.STAGE_TYPE_SLEEPING to "sleeping",
SleepSessionRecord.STAGE_TYPE_OUT_OF_BED to "out_of_bed",
SleepSessionRecord.STAGE_TYPE_LIGHT to "light",
SleepSessionRecord.STAGE_TYPE_DEEP to "deep",
SleepSessionRecord.STAGE_TYPE_REM to "rem",
SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED to "awake_in_bed"
)
// Known data sources with friendly names
val KNOWN_SOURCES =
mapOf(
"com.huami.watch.hmwatchmanager" to "Zepp",
"com.google.android.apps.fitness" to "Google Fit",
"com.sec.android.app.shealth" to "Samsung Health",
"com.xiaomi.wearable" to "Mi Fitness",
"com.oneplus.health" to "OnePlus Health"
)
}
init {
if (HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
) {
healthConnectClient = HealthConnectClient.getOrCreate(context)
}
}
private fun getSourceDisplayName(packageName: String): String {
return KNOWN_SOURCES[packageName]
?: packageName.substringAfterLast(".").replaceFirstChar { it.uppercase() }
}
private fun Instant.toIsoString(): String {
return DateTimeFormatter.ISO_INSTANT.format(this)
}
// ==================== STEPS ====================
suspend fun readRawStepsRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawStepsRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawStepsRecord>()
try {
val allRecords =
readRangeRecords<StepsRecord>(client, startDate, endDate)
for (record in allRecords) {
// Skip invalid records
if (!record.startTime.isBefore(record.endTime)) continue
records.add(
RawStepsRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
count = record.count
)
)
}
Log.d(TAG, "Read ${records.size} raw step records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw steps", e)
}
records
}
// ==================== HEART RATE ====================
suspend fun readRawHeartRateRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawHeartRateRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawHeartRateRecord>()
try {
val allRecords =
readRangeRecords<HeartRateRecord>(
client,
startDate,
endDate
)
for (record in allRecords) {
if (!record.startTime.isBefore(record.endTime)) continue
val samples =
record.samples.map { sample ->
HeartRateSample(
time = sample.time.toIsoString(),
bpm = sample.beatsPerMinute
)
}
records.add(
RawHeartRateRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
samples = samples
)
)
}
Log.d(
TAG,
"Read ${records.size} raw heart rate records with ${records.sumOf { it.samples.size }} samples"
)
} catch (e: Exception) {
Log.e(TAG, "Error reading raw heart rate", e)
}
records
}
// ==================== RESTING HEART RATE ====================
suspend fun readRawRestingHeartRateRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawRestingHeartRateRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawRestingHeartRateRecord>()
try {
val allRecords =
readRangeRecords<RestingHeartRateRecord>(
client,
startDate,
endDate
)
for (record in allRecords) {
records.add(
RawRestingHeartRateRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
time = record.time.toIsoString(),
bpm = record.beatsPerMinute
)
)
}
Log.d(TAG, "Read ${records.size} raw resting HR records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw resting HR", e)
}
records
}
// ==================== SLEEP ====================
suspend fun readRawSleepSessions(
startDate: LocalDate,
endDate: LocalDate
): List<RawSleepSession> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawSleepSession>()
try {
val allRecords =
readRangeRecords<SleepSessionRecord>(
client,
startDate,
endDate
)
for (record in allRecords) {
if (!record.startTime.isBefore(record.endTime)) continue
val stages =
record.stages.mapNotNull { stage ->
if (stage.startTime.isBefore(stage.endTime)
) {
SleepStageData(
startTime =
stage.startTime
.toIsoString(),
endTime =
stage.endTime
.toIsoString(),
stageType = stage.stage,
stageName =
SLEEP_STAGE_NAMES[
stage.stage]
?: "unknown"
)
} else null
}
records.add(
RawSleepSession(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
title = record.title,
notes = record.notes,
stages = stages
)
)
}
Log.d(TAG, "Read ${records.size} raw sleep sessions")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw sleep", e)
}
records
}
// ==================== DISTANCE ====================
suspend fun readRawDistanceRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawDistanceRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawDistanceRecord>()
try {
val allRecords =
readRangeRecords<DistanceRecord>(client, startDate, endDate)
for (record in allRecords) {
if (!record.startTime.isBefore(record.endTime)) continue
records.add(
RawDistanceRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
distanceMeters = record.distance.inMeters
)
)
}
Log.d(TAG, "Read ${records.size} raw distance records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw distance", e)
}
records
}
// ==================== CALORIES ====================
suspend fun readRawCaloriesRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawCaloriesRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawCaloriesRecord>()
try {
// Active calories
val activeRecords =
readRangeRecords<ActiveCaloriesBurnedRecord>(
client,
startDate,
endDate
)
for (record in activeRecords) {
if (!record.startTime.isBefore(record.endTime)) continue
records.add(
RawCaloriesRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
calories = record.energy.inKilocalories,
recordType = "active"
)
)
}
// Total calories
val totalRecords =
readRangeRecords<TotalCaloriesBurnedRecord>(
client,
startDate,
endDate
)
for (record in totalRecords) {
if (!record.startTime.isBefore(record.endTime)) continue
records.add(
RawCaloriesRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
calories = record.energy.inKilocalories,
recordType = "total"
)
)
}
Log.d(TAG, "Read ${records.size} raw calories records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw calories", e)
}
records
}
// ==================== OXYGEN SATURATION ====================
suspend fun readRawOxygenSaturationRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawOxygenSaturationRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawOxygenSaturationRecord>()
try {
val allRecords =
readRangeRecords<OxygenSaturationRecord>(
client,
startDate,
endDate
)
for (record in allRecords) {
records.add(
RawOxygenSaturationRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
time = record.time.toIsoString(),
percentage = record.percentage.value
)
)
}
Log.d(TAG, "Read ${records.size} raw SpO2 records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw SpO2", e)
}
records
}
// ==================== WEIGHT ====================
suspend fun readRawWeightRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawWeightRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawWeightRecord>()
try {
val allRecords =
readRangeRecords<WeightRecord>(client, startDate, endDate)
for (record in allRecords) {
records.add(
RawWeightRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
time = record.time.toIsoString(),
weightKg = record.weight.inKilograms
)
)
}
Log.d(TAG, "Read ${records.size} raw weight records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw weight", e)
}
records
}
// ==================== HEIGHT ====================
suspend fun readRawHeightRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawHeightRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawHeightRecord>()
try {
val allRecords =
readRangeRecords<HeightRecord>(client, startDate, endDate)
for (record in allRecords) {
records.add(
RawHeightRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
time = record.time.toIsoString(),
heightMeters = record.height.inMeters
)
)
}
Log.d(TAG, "Read ${records.size} raw height records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw height", e)
}
records
}
// ==================== BODY FAT ====================
suspend fun readRawBodyFatRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawBodyFatRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawBodyFatRecord>()
try {
val allRecords =
readRangeRecords<BodyFatRecord>(client, startDate, endDate)
for (record in allRecords) {
records.add(
RawBodyFatRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
time = record.time.toIsoString(),
percentage = record.percentage.value
)
)
}
Log.d(TAG, "Read ${records.size} raw body fat records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw body fat", e)
}
records
}
// ==================== EXERCISE ====================
suspend fun readRawExerciseSessions(
startDate: LocalDate,
endDate: LocalDate
): List<RawExerciseSession> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawExerciseSession>()
try {
val allRecords =
readRangeRecords<ExerciseSessionRecord>(
client,
startDate,
endDate
)
for (record in allRecords) {
if (!record.startTime.isBefore(record.endTime)) continue
records.add(
RawExerciseSession(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
exerciseType = record.exerciseType,
exerciseTypeName =
mapExerciseType(
record.exerciseType
),
title = record.title,
notes = record.notes,
distanceMeters =
null, // Would need additional
// segment queries
calories = null,
avgHeartRate = null,
maxHeartRate = null,
minHeartRate = null,
steps = null,
elevationGainedMeters = null
)
)
}
Log.d(TAG, "Read ${records.size} raw exercise sessions")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw exercise", e)
}
records
}
// ==================== NUTRITION ====================
suspend fun readRawNutritionRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawNutritionRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawNutritionRecord>()
try {
val allRecords =
readRangeRecords<NutritionRecord>(
client,
startDate,
endDate
)
for (record in allRecords) {
if (!record.startTime.isBefore(record.endTime)) continue
records.add(
RawNutritionRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
name = record.name,
mealType = record.mealType,
calories = record.energy?.inKilocalories,
proteinGrams = record.protein?.inGrams,
carbohydratesGrams =
record.totalCarbohydrate?.inGrams,
fatGrams = record.totalFat?.inGrams,
fiberGrams = record.dietaryFiber?.inGrams,
sugarGrams = record.sugar?.inGrams
)
)
}
Log.d(TAG, "Read ${records.size} raw nutrition records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw nutrition", e)
}
records
}
// ==================== HYDRATION ====================
suspend fun readRawHydrationRecords(
startDate: LocalDate,
endDate: LocalDate
): List<RawHydrationRecord> =
withContext(Dispatchers.IO) {
val client = healthConnectClient ?: return@withContext emptyList()
val records = mutableListOf<RawHydrationRecord>()
try {
val allRecords =
readRangeRecords<HydrationRecord>(
client,
startDate,
endDate
)
for (record in allRecords) {
if (!record.startTime.isBefore(record.endTime)) continue
records.add(
RawHydrationRecord(
recordId = record.metadata.id,
dataSource =
record.metadata
.dataOrigin
.packageName,
dataSourceName =
getSourceDisplayName(
record.metadata
.dataOrigin
.packageName
),
startTime = record.startTime.toIsoString(),
endTime = record.endTime.toIsoString(),
volumeLiters = record.volume.inLiters
)
)
}
Log.d(TAG, "Read ${records.size} raw hydration records")
} catch (e: Exception) {
Log.e(TAG, "Error reading raw hydration", e)
}
records
}
// ==================== HELPER METHODS ====================
/**
* OPTIMIZED: Reads records for the entire date range in a single query. Falls back to
* day-by-day reading if the range query fails (e.g., corrupt records).
*
* Performance gain: ~99% fewer Health Connect queries (1 query instead of N days)
*/
private suspend inline fun <reified T : Record> readRangeRecords(
client: HealthConnectClient,
startDate: LocalDate,
endDate: LocalDate
): List<T> {
val typeName = T::class.simpleName ?: "Unknown"
return try {
// Try reading the entire range in one query (optimal)
val instantStart =
startDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
val instantEnd =
endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
val timeRange = TimeRangeFilter.between(instantStart, instantEnd)
val records = readAllPaginated<T>(client, timeRange)
Log.d(
TAG,
"[$typeName] Range read success: ${records.size} records from $startDate to $endDate"
)
records
} catch (e: Exception) {
// Fallback to day-by-day reading to isolate problematic records
Log.w(
TAG,
"[$typeName] Range read failed, falling back to daily: ${e.message}"
)
readDailyRecords(client, startDate, endDate)
}
}
/**
* Reads records day-by-day to isolate failures (e.g. corrupt records) to a single day
* instead of failing the entire range. Used as fallback when range reading fails.
*/
private suspend inline fun <reified T : Record> readDailyRecords(
client: HealthConnectClient,
startDate: LocalDate,
endDate: LocalDate
): List<T> {
val allRecords = mutableListOf<T>()
var current = startDate
val typeName = T::class.simpleName ?: "Unknown"
// Ensure we don't loop forever
val safetyLimit = 366 * 2 // Max 2 years
var loopCount = 0
var failedDays = 0
while (!current.isAfter(endDate) && loopCount < safetyLimit) {
try {
val instantStart =
current.atStartOfDay(ZoneId.systemDefault()).toInstant()
val instantEnd =
current.plusDays(1)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
val timeRange = TimeRangeFilter.between(instantStart, instantEnd)
val dayRecords = readAllPaginated<T>(client, timeRange)
allRecords.addAll(dayRecords)
} catch (e: Exception) {
Log.e(TAG, "[$typeName] Failed to read for $current: ${e.message}")
failedDays++
// Continue to next day
}
current = current.plusDays(1)
loopCount++
}
if (failedDays > 0) {
Log.w(
TAG,
"[$typeName] Daily read completed with $failedDays failed days, ${allRecords.size} records recovered"
)
} else {
Log.d(TAG, "[$typeName] Daily read success: ${allRecords.size} records")
}
return allRecords
}
private suspend inline fun <reified T : Record> readAllPaginated(
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
} while (pageToken != null)
return allRecords
}
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"
ExerciseSessionRecord.EXERCISE_TYPE_DANCING -> "Dancing"
ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL -> "Elliptical"
ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING -> "Stair Climbing"
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "Rowing"
else -> "Exercise"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
package com.habitforge.companion
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
/**
* Tracks the count of records available in Health Connect vs synced to server. This helps users
* identify what data is available and what has been synchronized.
*/
class MetricsCountTracker(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("metrics_count", Context.MODE_PRIVATE)
private val gson = Gson()
companion object {
private const val TAG = "MetricsCountTracker"
private const val KEY_AVAILABLE_COUNTS = "available_counts"
private const val KEY_SYNCED_COUNTS = "synced_counts"
private const val KEY_LAST_COUNT_TIME = "last_count_time"
// All trackable metric types with their display info
val TRACKED_METRICS =
listOf(
TrackedMetricType("steps", "Steps", "👣"),
TrackedMetricType("distance", "Distance", "📏"),
TrackedMetricType("calories", "Calories", "🔥"),
TrackedMetricType("heart_rate", "Heart Rate", "❤️"),
TrackedMetricType("resting_hr", "Resting HR", "💗"),
TrackedMetricType("sleep", "Sleep", "😴"),
TrackedMetricType("weight", "Weight", "⚖️"),
TrackedMetricType("height", "Height", "📐"),
TrackedMetricType("body_fat", "Body Fat", "📊"),
TrackedMetricType("spo2", "SpO2", "🫁"),
TrackedMetricType("exercise", "Exercise", "🏃"),
TrackedMetricType("nutrition", "Nutrition", "🍎"),
TrackedMetricType("hydration", "Hydration", "💧")
)
}
/** Update the count of available records from Health Connect */
fun updateAvailableCounts(counts: Map<String, Int>) {
val json = gson.toJson(counts)
prefs.edit()
.putString(KEY_AVAILABLE_COUNTS, json)
.putLong(KEY_LAST_COUNT_TIME, System.currentTimeMillis())
.apply()
Log.d(TAG, "Updated available counts: $counts")
}
/** Update the count of records synced to server */
fun updateSyncedCounts(counts: Map<String, Int>) {
val currentCounts = getSyncedCounts().toMutableMap()
// Add new synced counts to existing totals
for ((key, value) in counts) {
currentCounts[key] = value // Replace with server's reported count
}
val json = gson.toJson(currentCounts)
prefs.edit().putString(KEY_SYNCED_COUNTS, json).apply()
Log.d(TAG, "Updated synced counts: $currentCounts")
}
/** Set exact synced counts (used when receiving totals from server) */
fun setSyncedCounts(counts: Map<String, Int>) {
val json = gson.toJson(counts)
prefs.edit().putString(KEY_SYNCED_COUNTS, json).apply()
Log.d(TAG, "Set synced counts: $counts")
}
/** Get all metric count statuses */
fun getAllMetricCounts(): List<MetricCountStatus> {
val availableCounts = getAvailableCounts()
val syncedCounts = getSyncedCounts()
return TRACKED_METRICS.map { metricType ->
val available = availableCounts[metricType.id] ?: 0
val synced = syncedCounts[metricType.id] ?: 0
MetricCountStatus(
metricType = metricType,
availableCount = available,
syncedCount = synced
)
}
}
/** Get counts summary text for a specific metric */
fun getMetricCountSummary(metricId: String): String {
val availableCounts = getAvailableCounts()
val syncedCounts = getSyncedCounts()
val available = availableCounts[metricId] ?: 0
val synced = syncedCounts[metricId] ?: 0
return "$synced / $available"
}
/** Get the last time counts were updated */
fun getLastCountTime(): Long {
return prefs.getLong(KEY_LAST_COUNT_TIME, 0)
}
/** Clear all count data */
fun clearAll() {
prefs.edit()
.remove(KEY_AVAILABLE_COUNTS)
.remove(KEY_SYNCED_COUNTS)
.remove(KEY_LAST_COUNT_TIME)
.apply()
}
private fun getAvailableCounts(): Map<String, Int> {
val json = prefs.getString(KEY_AVAILABLE_COUNTS, null) ?: return emptyMap()
return try {
val type = object : TypeToken<Map<String, Int>>() {}.type
gson.fromJson(json, type) ?: emptyMap()
} catch (e: Exception) {
Log.e(TAG, "Error parsing available counts", e)
emptyMap()
}
}
private fun getSyncedCounts(): Map<String, Int> {
val json = prefs.getString(KEY_SYNCED_COUNTS, null) ?: return emptyMap()
return try {
val type = object : TypeToken<Map<String, Int>>() {}.type
gson.fromJson(json, type) ?: emptyMap()
} catch (e: Exception) {
Log.e(TAG, "Error parsing synced counts", e)
emptyMap()
}
}
}
/** Represents a trackable metric type */
data class TrackedMetricType(val id: String, val displayName: String, val icon: String)
/** Status of metric counts for available vs synced */
data class MetricCountStatus(
val metricType: TrackedMetricType,
val availableCount: Int,
val syncedCount: Int
) {
fun getSyncPercentage(): Int {
return if (availableCount > 0) {
((syncedCount.toDouble() / availableCount) * 100).toInt()
} else {
0
}
}
fun isSynced(): Boolean = availableCount > 0 && syncedCount >= availableCount
fun getSyncStatus(): String {
return when {
availableCount == 0 -> "No data"
syncedCount >= availableCount -> "✓ Complete"
syncedCount > 0 -> "⚠ Partial (${getSyncPercentage()}%)"
else -> "✗ Not synced"
}
}
fun getStatusColor(): Int {
return when {
availableCount == 0 -> android.R.color.darker_gray
syncedCount >= availableCount -> android.R.color.holo_green_dark
syncedCount > 0 -> android.R.color.holo_orange_dark
else -> android.R.color.holo_red_dark
}
}
}
@@ -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,174 @@
package com.habitforge.companion
/**
* Raw Data Models - Data classes for individual Health Connect records. These represent raw,
* unaggregated data from Health Connect.
*/
// ==================== STEPS ====================
data class RawStepsRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val startTime: String, // ISO8601 format
val endTime: String,
val count: Long
)
// ==================== HEART RATE ====================
data class HeartRateSample(
val time: String, // ISO8601
val bpm: Long
)
data class RawHeartRateRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val startTime: String,
val endTime: String,
val samples: List<HeartRateSample>
)
data class RawRestingHeartRateRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val time: String,
val bpm: Long
)
// ==================== SLEEP ====================
data class SleepStageData(
val startTime: String,
val endTime: String,
val stageType: Int,
val stageName: String
)
data class RawSleepSession(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val startTime: String,
val endTime: String,
val title: String?,
val notes: String?,
val stages: List<SleepStageData>
)
// ==================== DISTANCE ====================
data class RawDistanceRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val startTime: String,
val endTime: String,
val distanceMeters: Double
)
// ==================== CALORIES ====================
data class RawCaloriesRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val startTime: String,
val endTime: String,
val calories: Double,
val recordType: String // "active" or "total"
)
// ==================== OXYGEN SATURATION ====================
data class RawOxygenSaturationRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val time: String,
val percentage: Double
)
// ==================== WEIGHT ====================
data class RawWeightRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val time: String,
val weightKg: Double
)
// ==================== HEIGHT ====================
data class RawHeightRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val time: String,
val heightMeters: Double
)
// ==================== BODY FAT ====================
data class RawBodyFatRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val time: String,
val percentage: Double
)
// ==================== EXERCISE ====================
data class RawExerciseSession(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val startTime: String,
val endTime: String,
val exerciseType: Int,
val exerciseTypeName: String,
val title: String?,
val notes: String?,
val distanceMeters: Double?,
val calories: Double?,
val avgHeartRate: Int?,
val maxHeartRate: Int?,
val minHeartRate: Int?,
val steps: Int?,
val elevationGainedMeters: Double?
)
// ==================== NUTRITION ====================
data class RawNutritionRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val startTime: String,
val endTime: String,
val name: String?,
val mealType: Int?,
val calories: Double?,
val proteinGrams: Double?,
val carbohydratesGrams: Double?,
val fatGrams: Double?,
val fiberGrams: Double?,
val sugarGrams: Double?
)
// ==================== HYDRATION ====================
data class RawHydrationRecord(
val recordId: String,
val dataSource: String,
val dataSourceName: String,
val startTime: String,
val endTime: String,
val volumeLiters: Double
)
@@ -0,0 +1,842 @@
package com.habitforge.companion
import android.content.Context
import android.provider.Settings
import android.util.Log
import androidx.work.*
import java.time.Instant
import java.time.LocalDate
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
/**
* Raw Data Sync Service - Syncs individual health records to backend.
*
* This service reads raw (unaggregated) health data from Health Connect and sends each record
* individually to the backend for storage.
*
* OPTIMIZED: Uses 30-day batches when syncing long ranges to improve performance and throughput.
* Uses 7-day default sync period for scheduled jobs.
*/
class RawDataSyncService(context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
companion object {
private const val TAG = "RawDataSyncService"
private const val WORK_NAME = "health_connect_raw_sync"
// Default: 7 days for regular syncs (manageable volume)
private const val SYNC_DAYS = 7
// Full sync: 30 days (use only for initial/recovery)
private const val FULL_SYNC_DAYS = 30
// Max HR samples per record to limit payload size
private const val MAX_HR_SAMPLES_PER_RECORD = 100
/**
* Initialize periodic sync on app startup if user is logged in. This ensures sync
* continues after app restart.
*/
fun initializeIfLoggedIn(context: Context) {
val authPrefs =
context.getSharedPreferences(
"habitforge_auth",
Context.MODE_PRIVATE
)
val legacyPrefs =
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
val authToken =
authPrefs.getString("access_token", null)
?: legacyPrefs.getString("auth_token", null)
if (!authToken.isNullOrEmpty()) {
val periodMinutes = legacyPrefs.getInt("sync_period_minutes", 360)
if (periodMinutes > 0) {
Log.i(
TAG,
"[INIT] User is logged in, restoring periodic sync (every ${periodMinutes}min)"
)
schedulePeriodicSync(context, periodMinutes)
} else {
Log.i(
TAG,
"[INIT] User is logged in but periodic sync is disabled"
)
}
} else {
Log.i(
TAG,
"[INIT] No auth token found, skipping periodic sync initialization"
)
}
}
/** Schedule periodic raw data sync */
fun schedulePeriodicSync(context: Context, periodMinutes: Int = 360) {
// Remove strict battery constraints to ensure it runs
val constraints =
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncWork =
PeriodicWorkRequestBuilder<RawDataSyncService>(
periodMinutes.toLong(),
TimeUnit.MINUTES,
15,
TimeUnit.MINUTES
)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.addTag("periodic_raw_sync")
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
syncWork
)
Log.i(
TAG,
"[SCHEDULE] Periodic sync scheduled every $periodMinutes minutes (Network required)"
)
// Also log to shared prefs for debugging
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.edit()
.putLong("last_sync_scheduled", System.currentTimeMillis())
.putInt("sync_period_minutes", periodMinutes)
.apply()
}
/** Trigger an immediate raw data sync */
fun syncNow(context: Context, days: Int? = null) {
val actualDays =
days
?: context.getSharedPreferences(
"habitforge",
Context.MODE_PRIVATE
)
.getInt("sync_days", SYNC_DAYS)
val constraints =
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncWork =
OneTimeWorkRequestBuilder<RawDataSyncService>()
.setConstraints(constraints)
.setInputData(
workDataOf(
"days" to actualDays,
"is_manual" to true
)
)
.addTag("manual_raw_sync")
.build()
WorkManager.getInstance(context).enqueue(syncWork)
Log.i(
TAG,
"[MANUAL] Immediate raw data sync triggered for $actualDays days"
)
}
/** Full sync for initial setup or recovery (30 days) */
fun syncFull(context: Context) {
syncNow(context, FULL_SYNC_DAYS)
}
/** Cancel raw data sync */
fun cancelSync(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.i(TAG, "[CANCEL] Raw data sync cancelled")
}
}
override suspend fun doWork(): Result =
withContext(Dispatchers.IO) {
val isManual = inputData.getBoolean("is_manual", false)
val syncTypeLabel = if (isManual) "MANUAL" else "SCHEDULED"
Log.i(TAG, "========================================")
Log.i(TAG, "[$syncTypeLabel] Starting raw data sync...")
Log.i(TAG, "========================================")
val overallStartTime = System.currentTimeMillis()
try {
// Get auth token
val authPrefs =
applicationContext.getSharedPreferences(
"habitforge_auth",
Context.MODE_PRIVATE
)
var authToken = authPrefs.getString("access_token", null)
val legacyPrefs =
applicationContext.getSharedPreferences(
"habitforge",
Context.MODE_PRIVATE
)
if (authToken == null) {
authToken = legacyPrefs.getString("auth_token", null)
}
if (authToken.isNullOrEmpty()) {
Log.e(TAG, "No auth token configured")
return@withContext Result.failure()
}
// Initialize raw reader
val rawReader = HealthConnectRawReader(applicationContext)
// Determine sync range
val configuredDays = legacyPrefs.getInt("sync_days", SYNC_DAYS)
val days = inputData.getInt("days", configuredDays)
// Ensure we have a valid range
val finalEndDate = LocalDate.now()
val overallStartDate = finalEndDate.minusDays(days.toLong())
Log.i(
TAG,
"Syncing raw data from $overallStartDate to $finalEndDate ($days days)"
)
// Configure batching
// Use 30-day batches to improve throughput while staying within
// safe limits
val BATCH_DAYS = 30
var currentStartDate = overallStartDate
var totalRecordsSynced = 0
var totalBatches = 0
var failedBatches = 0
// Track available counts per metric type
val availableCounts = mutableMapOf<String, Int>()
val syncedCounts = mutableMapOf<String, Int>()
val historyManager = SyncHistoryManager(applicationContext)
val metricsCountTracker = MetricsCountTracker(applicationContext)
var backendUrl = authPrefs.getString("server_url", null)
if (backendUrl == null) {
backendUrl =
legacyPrefs.getString(
"server_url",
BuildConfig.BACKEND_URL
)
}
backendUrl = backendUrl ?: BuildConfig.BACKEND_URL
val api = HabitForgeApi.create(backendUrl)
// Get device ID once
val deviceId =
Settings.Secure.getString(
applicationContext.contentResolver,
Settings.Secure.ANDROID_ID
)
// Iterate in batches
while (!currentStartDate.isAfter(finalEndDate)) {
val currentEndDate =
currentStartDate.plusDays((BATCH_DAYS - 1).toLong())
.let {
if (it.isAfter(finalEndDate))
finalEndDate
else it
}
Log.i(
TAG,
"Processing batch: $currentStartDate to $currentEndDate"
)
try {
// OPTIMIZED: Read all records for this batch IN
// PARALLEL
// Performance gain: ~70% faster by reading all 13
// metric types concurrently
val readStartTime = System.currentTimeMillis()
val (
stepsRecords,
heartRateRecords,
restingHRRecords,
sleepSessions,
distanceRecords,
caloriesRecords,
spo2Records,
weightRecords,
heightRecords,
bodyFatRecords,
exerciseSessions,
nutritionRecords,
hydrationRecords) =
coroutineScope {
val stepsDeferred = async {
rawReader
.readRawStepsRecords(
currentStartDate,
currentEndDate
)
}
val heartRateDeferred = async {
rawReader
.readRawHeartRateRecords(
currentStartDate,
currentEndDate
)
}
val restingHRDeferred = async {
rawReader
.readRawRestingHeartRateRecords(
currentStartDate,
currentEndDate
)
}
val sleepDeferred = async {
rawReader
.readRawSleepSessions(
currentStartDate,
currentEndDate
)
}
val distanceDeferred = async {
rawReader
.readRawDistanceRecords(
currentStartDate,
currentEndDate
)
}
val caloriesDeferred = async {
rawReader
.readRawCaloriesRecords(
currentStartDate,
currentEndDate
)
}
val spo2Deferred = async {
rawReader
.readRawOxygenSaturationRecords(
currentStartDate,
currentEndDate
)
}
val weightDeferred = async {
rawReader
.readRawWeightRecords(
currentStartDate,
currentEndDate
)
}
val heightDeferred = async {
rawReader
.readRawHeightRecords(
currentStartDate,
currentEndDate
)
}
val bodyFatDeferred = async {
rawReader
.readRawBodyFatRecords(
currentStartDate,
currentEndDate
)
}
val exerciseDeferred = async {
rawReader
.readRawExerciseSessions(
currentStartDate,
currentEndDate
)
}
val nutritionDeferred = async {
rawReader
.readRawNutritionRecords(
currentStartDate,
currentEndDate
)
}
val hydrationDeferred = async {
rawReader
.readRawHydrationRecords(
currentStartDate,
currentEndDate
)
}
// Await all results (this is where
// parallelism happens)
Tuple13(
stepsDeferred.await(),
heartRateDeferred.await(),
restingHRDeferred.await(),
sleepDeferred.await(),
distanceDeferred.await(),
caloriesDeferred.await(),
spo2Deferred.await(),
weightDeferred.await(),
heightDeferred.await(),
bodyFatDeferred.await(),
exerciseDeferred.await(),
nutritionDeferred.await(),
hydrationDeferred.await()
)
}
val readDuration =
System.currentTimeMillis() - readStartTime
Log.d(
TAG,
"[$syncTypeLabel] Parallel read completed in ${readDuration}ms for batch $currentStartDate to $currentEndDate"
)
// Convert to API payloads
val stepsPayload =
stepsRecords.map { r ->
RawStepsPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.startTime,
r.endTime,
r.count
)
}
val heartRatePayload =
heartRateRecords.map { r ->
RawHeartRatePayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.startTime,
r.endTime,
r.samples.take(
MAX_HR_SAMPLES_PER_RECORD
)
.map { s ->
HeartRateSamplePayload(
s.time,
s.bpm
)
}
)
}
val restingHRPayload =
restingHRRecords.map { r ->
RawRestingHRPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.time,
r.bpm
)
}
val sleepPayload =
sleepSessions.map { r ->
RawSleepSessionPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.startTime,
r.endTime,
r.title,
r.notes,
r.stages.map { s ->
SleepStagePayload(
s.startTime,
s.endTime,
s.stageType,
s.stageName
)
}
)
}
val distancePayload =
distanceRecords.map { r ->
RawDistancePayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.startTime,
r.endTime,
r.distanceMeters
)
}
val caloriesPayload =
caloriesRecords.map { r: RawCaloriesRecord
->
RawCaloriesPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.startTime,
r.endTime,
r.calories,
r.recordType
)
}
val spo2Payload =
spo2Records.map {
r: RawOxygenSaturationRecord ->
RawSpo2Payload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.time,
r.percentage
)
}
val weightPayload =
weightRecords.map { r: RawWeightRecord ->
RawWeightPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.time,
r.weightKg
)
}
val heightPayload =
heightRecords.map { r: RawHeightRecord ->
RawHeightPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.time,
r.heightMeters
)
}
val bodyFatPayload =
bodyFatRecords.map { r: RawBodyFatRecord ->
RawBodyFatPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.time,
r.percentage
)
}
val exercisePayload =
exerciseSessions.map { r: RawExerciseSession
->
RawExercisePayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.startTime,
r.endTime,
r.exerciseType,
r.exerciseTypeName,
r.title,
r.notes,
r.distanceMeters,
r.calories,
r.avgHeartRate,
r.maxHeartRate,
r.minHeartRate,
r.steps,
r.elevationGainedMeters
)
}
val nutritionPayload =
nutritionRecords.map { r: RawNutritionRecord
->
RawNutritionPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.startTime,
r.endTime,
r.name,
r.mealType,
r.calories,
r.proteinGrams,
r.carbohydratesGrams,
r.fatGrams,
r.fiberGrams,
r.sugarGrams
)
}
val hydrationPayload =
hydrationRecords.map { r: RawHydrationRecord
->
RawHydrationPayload(
r.recordId,
r.dataSource,
r.dataSourceName,
r.startTime,
r.endTime,
r.volumeLiters
)
}
val batchRecordsCount =
stepsPayload.size +
heartRatePayload.size +
restingHRPayload.size +
sleepPayload.size +
distancePayload.size +
caloriesPayload.size +
spo2Payload.size +
weightPayload.size +
heightPayload.size +
bodyFatPayload.size +
exercisePayload.size +
nutritionPayload.size +
hydrationPayload.size
// Accumulate available counts for this batch
availableCounts["steps"] =
(availableCounts["steps"]
?: 0) + stepsPayload.size
availableCounts["heart_rate"] =
(availableCounts["heart_rate"]
?: 0) + heartRatePayload.size
availableCounts["resting_hr"] =
(availableCounts["resting_hr"]
?: 0) + restingHRPayload.size
availableCounts["sleep"] =
(availableCounts["sleep"]
?: 0) + sleepPayload.size
availableCounts["distance"] =
(availableCounts["distance"]
?: 0) + distancePayload.size
availableCounts["calories"] =
(availableCounts["calories"]
?: 0) + caloriesPayload.size
availableCounts["spo2"] =
(availableCounts["spo2"]
?: 0) + spo2Payload.size
availableCounts["weight"] =
(availableCounts["weight"]
?: 0) + weightPayload.size
availableCounts["height"] =
(availableCounts["height"]
?: 0) + heightPayload.size
availableCounts["body_fat"] =
(availableCounts["body_fat"]
?: 0) + bodyFatPayload.size
availableCounts["exercise"] =
(availableCounts["exercise"]
?: 0) + exercisePayload.size
availableCounts["nutrition"] =
(availableCounts["nutrition"]
?: 0) + nutritionPayload.size
availableCounts["hydration"] =
(availableCounts["hydration"]
?: 0) + hydrationPayload.size
if (batchRecordsCount > 0) {
// Build request
val request =
RawDataSyncRequest(
device_id = deviceId,
sync_timestamp =
Instant.now()
.toString(),
steps_records =
stepsPayload,
heart_rate_records =
heartRatePayload,
resting_hr_records =
restingHRPayload,
sleep_sessions =
sleepPayload,
distance_records =
distancePayload,
calories_records =
caloriesPayload,
spo2_records = spo2Payload,
weight_records =
weightPayload,
height_records =
heightPayload,
body_fat_records =
bodyFatPayload,
exercise_sessions =
exercisePayload,
nutrition_records =
nutritionPayload,
hydration_records =
hydrationPayload
)
Log.i(
TAG,
"Sending batch $currentStartDate to $currentEndDate with $batchRecordsCount records..."
)
val response =
api.syncRawHealthData(
"Bearer $authToken",
request
)
if (response.isSuccessful) {
val body = response.body()
val syncedInBatch =
body?.records_synced?.values
?.sum()
?: batchRecordsCount
totalRecordsSynced += syncedInBatch
// Track synced counts from server
// response
body?.records_synced?.forEach {
(key, count) ->
syncedCounts[key] =
(syncedCounts[key]
?: 0) +
count
}
Log.i(
TAG,
"[$syncTypeLabel] Batch synced successfully: $syncedInBatch records"
)
} else {
Log.e(
TAG,
"Batch sync failed: ${response.code()} ${response.message()}"
)
failedBatches++
}
} else {
Log.i(
TAG,
"Empty batch for $currentStartDate to $currentEndDate, skipping upload"
)
}
totalBatches++
} catch (e: Exception) {
Log.e(
TAG,
"Error processing batch $currentStartDate",
e
)
failedBatches++
}
// Move to next batch
currentStartDate = currentEndDate.plusDays(1)
}
// Update last sync time
legacyPrefs
.edit()
.putLong("last_raw_sync", System.currentTimeMillis())
.putLong("last_sync", System.currentTimeMillis())
.apply()
// Update metric counts tracker
metricsCountTracker.updateAvailableCounts(availableCounts)
if (syncedCounts.isNotEmpty()) {
metricsCountTracker.updateSyncedCounts(syncedCounts)
}
val totalDuration = System.currentTimeMillis() - overallStartTime
Log.i(TAG, "========================================")
Log.i(
TAG,
"[$syncTypeLabel] Sync finished. Total records: $totalRecordsSynced, Batches: $totalBatches, Failed: $failedBatches"
)
Log.i(TAG, "[$syncTypeLabel] Duration: ${totalDuration}ms")
Log.i(TAG, "[$syncTypeLabel] Available counts: $availableCounts")
Log.i(TAG, "[$syncTypeLabel] Synced counts: $syncedCounts")
Log.i(TAG, "========================================")
// Record result
val syncType =
if (inputData.getBoolean("is_manual", false))
SyncType.MANUAL
else SyncType.SCHEDULED
val status =
if (failedBatches == totalBatches && totalBatches > 0)
SyncStatus.FAILED
else if (failedBatches > 0) SyncStatus.PARTIAL
else SyncStatus.SUCCESS
// Only log to history if:
// 1. It was a manual sync (user feedback required)
// 2. OR Data was actually synced (avoid noise)
// 3. OR The sync encountered errors (visibility required)
if (syncType == SyncType.MANUAL ||
totalRecordsSynced > 0 ||
status != SyncStatus.SUCCESS
) {
historyManager.addSyncRecord(
SyncRecord(
timestamp = System.currentTimeMillis(),
status = status,
metricsSynced = totalRecordsSynced,
exercisesSynced = syncedCounts["exercise"]
?: 0,
syncDurationMs = totalDuration,
syncType = syncType,
errorMessage =
if (failedBatches > 0)
"$failedBatches/$totalBatches batches failed"
else null,
details = syncedCounts
)
)
}
if (status == SyncStatus.FAILED) {
return@withContext Result.retry()
} else {
return@withContext Result.success()
}
} catch (e: Exception) {
Log.e(TAG, "Raw sync fatal error: ${e.message}", e)
return@withContext Result.failure()
}
}
}
/**
* Tuple class for holding 13 values to support parallel reading of all metric types. Used for
* destructuring assignment in coroutineScope parallel reads.
*
* Note: Kotlin data classes auto-generate component1() through component5() for the first 5
* properties. We manually add component6() through component13() to support full destructuring.
*/
data class Tuple13<A, B, C, D, E, F, G, H, I, J, K, L, M>(
val first: A,
val second: B,
val third: C,
val fourth: D,
val fifth: E,
val sixth: F,
val seventh: G,
val eighth: H,
val ninth: I,
val tenth: J,
val eleventh: K,
val twelfth: L,
val thirteenth: M
)
@@ -0,0 +1,710 @@
package com.habitforge.companion
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.records.*
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.tabs.TabLayout
import com.habitforge.companion.databinding.ActivityRawDataViewerBinding
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Raw Data Viewer Activity Displays raw Health Connect records for debugging and data inspection
* Supports filtering by data source
*/
class RawDataViewerActivity : AppCompatActivity() {
private lateinit var binding: ActivityRawDataViewerBinding
private lateinit var adapter: RawDataAdapter
private lateinit var healthConnectManager: HealthConnectManager
private var healthConnectClient: HealthConnectClient? = null
// Source filtering
private var availableSources: List<DataSourceInfo> = emptyList()
private var selectedSourcePackage: String? = null // null = all sources
private val dateRangeOptions =
listOf(
DateRangeOption("Last 7 days", 7),
DateRangeOption("Last 14 days", 14),
DateRangeOption("Last 30 days", 30),
DateRangeOption("Last 60 days", 60),
DateRangeOption("Last 90 days", 90),
DateRangeOption("Last 180 days", 180),
DateRangeOption("Last 365 days", 365)
)
private val dataTypes =
listOf(
DataTypeTab("Steps", "steps", "👣"),
DataTypeTab("Heart Rate", "heart_rate", "❤️"),
DataTypeTab("Distance", "distance", "📏"),
DataTypeTab("Calories", "calories", "🔥"),
DataTypeTab("Sleep", "sleep", "😴"),
DataTypeTab("SpO2", "spo2", "💉"),
DataTypeTab("Weight", "weight", "⚖️"),
DataTypeTab("Resting HR", "resting_hr", "💗")
)
private var selectedDays = 30
private var selectedDataType = "steps"
companion object {
private const val TAG = "RawDataViewer"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityRawDataViewerBinding.inflate(layoutInflater)
setContentView(binding.root)
healthConnectManager = HealthConnectManager(this)
// Initialize Health Connect client
if (HealthConnectClient.getSdkStatus(this) == HealthConnectClient.SDK_AVAILABLE) {
healthConnectClient = HealthConnectClient.getOrCreate(this)
}
setupToolbar()
setupTabs()
setupDateRangeSpinner()
setupRecyclerView()
loadAvailableSources()
binding.btnLoadData.setOnClickListener { loadData() }
}
private fun setupToolbar() {
binding.toolbar.setNavigationOnClickListener { finish() }
// Add menu for source selection
binding.toolbar.inflateMenu(R.menu.menu_raw_data_viewer)
binding.toolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_select_source -> {
showSourceSelectionDialog()
true
}
else -> false
}
}
}
private fun setupTabs() {
dataTypes.forEach { type ->
val tab = binding.tabLayout.newTab()
tab.text = "${type.icon} ${type.name}"
tab.tag = type.id
binding.tabLayout.addTab(tab)
}
binding.tabLayout.addOnTabSelectedListener(
object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
selectedDataType = tab?.tag as? String ?: "steps"
loadData()
}
override fun onTabUnselected(tab: TabLayout.Tab?) {}
override fun onTabReselected(tab: TabLayout.Tab?) {}
}
)
}
private fun setupDateRangeSpinner() {
val spinnerAdapter =
ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
dateRangeOptions.map { it.label }
)
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerDateRange.adapter = spinnerAdapter
binding.spinnerDateRange.setSelection(2) // Default to 30 days
binding.spinnerDateRange.onItemSelectedListener =
object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
selectedDays = dateRangeOptions[position].days
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun setupRecyclerView() {
adapter = RawDataAdapter(healthConnectManager)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
}
private fun loadAvailableSources() {
lifecycleScope.launch {
try {
availableSources = healthConnectManager.getAvailableDataSources()
Log.d(TAG, "Found ${availableSources.size} data sources")
// Update toolbar subtitle with current source
updateSourceIndicator()
} catch (e: Exception) {
Log.e(TAG, "Error loading sources", e)
}
}
}
private fun updateSourceIndicator() {
val sourceText =
if (selectedSourcePackage != null) {
val source = availableSources.find { it.packageName == selectedSourcePackage }
"Source: ${source?.displayName ?: "Unknown"}"
} else {
"Source: All"
}
binding.toolbar.subtitle = sourceText
}
private fun showSourceSelectionDialog() {
if (availableSources.isEmpty()) {
Toast.makeText(this, "Loading data sources...", Toast.LENGTH_SHORT).show()
loadAvailableSources()
return
}
val options = mutableListOf("All Sources (may have conflicts)")
options.addAll(availableSources.map { it.displayName })
val currentSelection =
if (selectedSourcePackage == null) 0
else availableSources.indexOfFirst { it.packageName == selectedSourcePackage } + 1
AlertDialog.Builder(this)
.setTitle("Select Data Source")
.setSingleChoiceItems(options.toTypedArray(), currentSelection) { dialog, which ->
selectedSourcePackage =
if (which == 0) null else availableSources[which - 1].packageName
updateSourceIndicator()
dialog.dismiss()
// Reload data with new source filter
loadData()
}
.setNegativeButton("Cancel", null)
.show()
}
private fun loadData() {
val client =
healthConnectClient
?: run {
showEmpty("Health Connect not available")
return
}
val sourceLabel =
if (selectedSourcePackage != null) {
availableSources.find { it.packageName == selectedSourcePackage }?.displayName
?: "filtered source"
} else "all sources"
showLoading("Loading $selectedDataType from $sourceLabel for last $selectedDays days...")
lifecycleScope.launch {
try {
val endDate = LocalDate.now()
val startDate = endDate.minusDays(selectedDays.toLong())
Log.d(TAG, "Date range: $startDate to $endDate")
Log.d(TAG, "Source filter: ${selectedSourcePackage ?: "All"}")
val startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
val endInstant =
endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
Log.d(TAG, "Instant range: $startInstant to $endInstant")
// Validate time range
if (!startInstant.isBefore(endInstant)) {
showEmpty("Invalid date range: start must be before end")
return@launch
}
val timeRange = TimeRangeFilter.between(startInstant, endInstant)
val records =
withContext(Dispatchers.IO) {
readRawRecords(
client,
timeRange,
selectedDataType,
selectedSourcePackage
)
}
if (records.isEmpty()) {
showEmpty("No $selectedDataType data found for this date range and source")
} else {
showData(records, startDate, endDate)
}
} catch (e: IllegalArgumentException) {
Log.e(TAG, "IllegalArgumentException loading data: ${e.message}", e)
showEmpty(
"Time range error: ${e.message}\n\nThis may be caused by corrupted Health Connect data."
)
} catch (e: Exception) {
Log.e(TAG, "Error loading data: ${e::class.simpleName} - ${e.message}", e)
showEmpty("Error: ${e.message}")
}
}
}
private suspend fun readRawRecords(
client: HealthConnectClient,
timeRange: TimeRangeFilter,
dataType: String,
sourceFilter: String?
): List<RawDataRecord> {
val records = mutableListOf<RawDataRecord>()
when (dataType) {
"steps" -> {
try {
val response = readAllPaginated<StepsRecord>(client, timeRange)
Log.d(TAG, "Found ${response.size} StepsRecord objects (before filtering)")
val filtered =
response.filter { record ->
val validTime = record.startTime.isBefore(record.endTime)
val matchesSource =
sourceFilter == null ||
record.metadata.dataOrigin.packageName ==
sourceFilter
if (!validTime) {
Log.w(
TAG,
"Skipping invalid: startTime=${record.startTime}, endTime=${record.endTime}"
)
}
validTime && matchesSource
}
Log.d(TAG, "After filtering: ${filtered.size} records")
filtered.forEach { record ->
records.add(
RawDataRecord(
timestamp = record.startTime,
endTime = record.endTime,
value = "${record.count} steps",
numericValue = record.count.toDouble(),
dataSource = record.metadata.dataOrigin.packageName,
recordType = "StepsRecord",
details = "ID: ${record.metadata.id}"
)
)
}
} catch (e: Exception) {
Log.e(TAG, "Error reading StepsRecord", e)
throw e
}
}
"heart_rate" -> {
val response = readAllPaginated<HeartRateRecord>(client, timeRange)
Log.d(TAG, "Found ${response.size} HeartRateRecord objects")
val filtered =
response.filter { record ->
val validTime = record.startTime.isBefore(record.endTime)
val matchesSource =
sourceFilter == null ||
record.metadata.dataOrigin.packageName == sourceFilter
validTime && matchesSource
}
filtered.forEach { record ->
val avgBpm =
if (record.samples.isNotEmpty()) {
record.samples.map { it.beatsPerMinute }.average().toInt()
} else 0
val sampleCount = record.samples.size
records.add(
RawDataRecord(
timestamp = record.startTime,
endTime = record.endTime,
value = "$avgBpm bpm avg ($sampleCount samples)",
numericValue = avgBpm.toDouble(),
dataSource = record.metadata.dataOrigin.packageName,
recordType = "HeartRateRecord",
details =
"Samples: ${record.samples.take(5).map { it.beatsPerMinute }.joinToString(", ")}${if (sampleCount > 5) "..." else ""}"
)
)
}
}
"distance" -> {
val response = readAllPaginated<DistanceRecord>(client, timeRange)
Log.d(TAG, "Found ${response.size} DistanceRecord objects")
val filtered =
response.filter { record ->
val validTime = record.startTime.isBefore(record.endTime)
val matchesSource =
sourceFilter == null ||
record.metadata.dataOrigin.packageName == sourceFilter
validTime && matchesSource
}
filtered.forEach { record ->
records.add(
RawDataRecord(
timestamp = record.startTime,
endTime = record.endTime,
value = "%.2f m".format(record.distance.inMeters),
numericValue = record.distance.inMeters,
dataSource = record.metadata.dataOrigin.packageName,
recordType = "DistanceRecord",
details = "ID: ${record.metadata.id}"
)
)
}
}
"calories" -> {
val response = readAllPaginated<TotalCaloriesBurnedRecord>(client, timeRange)
Log.d(TAG, "Found ${response.size} TotalCaloriesBurnedRecord objects")
val filtered =
response.filter { record ->
val validTime = record.startTime.isBefore(record.endTime)
val matchesSource =
sourceFilter == null ||
record.metadata.dataOrigin.packageName == sourceFilter
validTime && matchesSource
}
filtered.forEach { record ->
records.add(
RawDataRecord(
timestamp = record.startTime,
endTime = record.endTime,
value = "%.1f kcal".format(record.energy.inKilocalories),
numericValue = record.energy.inKilocalories,
dataSource = record.metadata.dataOrigin.packageName,
recordType = "TotalCaloriesBurnedRecord",
details = "ID: ${record.metadata.id}"
)
)
}
}
"sleep" -> {
val response = readAllPaginated<SleepSessionRecord>(client, timeRange)
Log.d(TAG, "Found ${response.size} SleepSessionRecord objects")
val filtered =
response.filter { record ->
val validTime = record.startTime.isBefore(record.endTime)
val matchesSource =
sourceFilter == null ||
record.metadata.dataOrigin.packageName == sourceFilter
validTime && matchesSource
}
filtered.forEach { record ->
val durationMinutes =
java.time.temporal.ChronoUnit.MINUTES.between(
record.startTime,
record.endTime
)
val hours = durationMinutes / 60
val mins = durationMinutes % 60
records.add(
RawDataRecord(
timestamp = record.startTime,
endTime = record.endTime,
value = "${hours}h ${mins}m",
numericValue = durationMinutes.toDouble(),
dataSource = record.metadata.dataOrigin.packageName,
recordType = "SleepSessionRecord",
details = "Stages: ${record.stages.size}"
)
)
}
}
"spo2" -> {
val response = readAllPaginated<OxygenSaturationRecord>(client, timeRange)
Log.d(TAG, "Found ${response.size} OxygenSaturationRecord objects")
val filtered =
response.filter { record ->
sourceFilter == null ||
record.metadata.dataOrigin.packageName == sourceFilter
}
filtered.forEach { record ->
records.add(
RawDataRecord(
timestamp = record.time,
endTime = null,
value = "%.1f%%".format(record.percentage.value),
numericValue = record.percentage.value,
dataSource = record.metadata.dataOrigin.packageName,
recordType = "OxygenSaturationRecord",
details = "ID: ${record.metadata.id}"
)
)
}
}
"weight" -> {
val response = readAllPaginated<WeightRecord>(client, timeRange)
Log.d(TAG, "Found ${response.size} WeightRecord objects")
val filtered =
response.filter { record ->
sourceFilter == null ||
record.metadata.dataOrigin.packageName == sourceFilter
}
filtered.forEach { record ->
records.add(
RawDataRecord(
timestamp = record.time,
endTime = null,
value = "%.2f kg".format(record.weight.inKilograms),
numericValue = record.weight.inKilograms,
dataSource = record.metadata.dataOrigin.packageName,
recordType = "WeightRecord",
details = "ID: ${record.metadata.id}"
)
)
}
}
"resting_hr" -> {
val response = readAllPaginated<RestingHeartRateRecord>(client, timeRange)
Log.d(TAG, "Found ${response.size} RestingHeartRateRecord objects")
val filtered =
response.filter { record ->
sourceFilter == null ||
record.metadata.dataOrigin.packageName == sourceFilter
}
filtered.forEach { record ->
records.add(
RawDataRecord(
timestamp = record.time,
endTime = null,
value = "${record.beatsPerMinute} bpm",
numericValue = record.beatsPerMinute.toDouble(),
dataSource = record.metadata.dataOrigin.packageName,
recordType = "RestingHeartRateRecord",
details = "ID: ${record.metadata.id}"
)
)
}
}
}
// Sort by timestamp descending (most recent first)
return records.sortedByDescending { it.timestamp }
}
private suspend inline fun <reified T : Record> readAllPaginated(
client: HealthConnectClient,
timeRange: TimeRangeFilter
): List<T> {
val allRecords = mutableListOf<T>()
var pageToken: String? = null
var pageCount = 0
try {
do {
pageCount++
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,
"Page $pageCount: ${response.records.size} ${T::class.simpleName} records, total: ${allRecords.size}, hasMore: ${pageToken != null}"
)
} while (pageToken != null)
} catch (e: IllegalArgumentException) {
// This can happen if Health Connect returns invalid time ranges
Log.e(TAG, "IllegalArgumentException reading ${T::class.simpleName}: ${e.message}", e)
} catch (e: Exception) {
Log.e(TAG, "Error reading ${T::class.simpleName}: ${e.message}", e)
throw e
}
return allRecords
}
private fun showLoading(message: String) {
binding.layoutLoading.visibility = View.VISIBLE
binding.tvLoadingMessage.text = message
binding.recyclerView.visibility = View.GONE
binding.layoutEmpty.visibility = View.GONE
binding.layoutStats.visibility = View.GONE
}
private fun showEmpty(message: String) {
binding.layoutLoading.visibility = View.GONE
binding.recyclerView.visibility = View.GONE
binding.layoutEmpty.visibility = View.VISIBLE
binding.tvEmptyMessage.text = message
binding.layoutStats.visibility = View.GONE
}
private fun showData(records: List<RawDataRecord>, startDate: LocalDate, endDate: LocalDate) {
binding.layoutLoading.visibility = View.GONE
binding.layoutEmpty.visibility = View.GONE
binding.recyclerView.visibility = View.VISIBLE
binding.layoutStats.visibility = View.VISIBLE
binding.tvTotalRecords.text = "Records: ${records.size}"
binding.tvDateRange.text = "$startDate → $endDate"
adapter.submitList(records)
}
}
// Data classes
data class DateRangeOption(val label: String, val days: Int)
data class DataTypeTab(val name: String, val id: String, val icon: String)
data class RawDataRecord(
val timestamp: Instant,
val endTime: Instant?,
val value: String,
val numericValue: Double,
val dataSource: String,
val recordType: String,
val details: String,
val isSynced: Boolean = false,
val syncTimestamp: Instant? = null
)
// RecyclerView Adapter
class RawDataAdapter(private val healthConnectManager: HealthConnectManager) :
RecyclerView.Adapter<RawDataAdapter.ViewHolder>() {
private var items: List<RawDataRecord> = emptyList()
private val dateTimeFormatter =
DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm:ss").withZone(ZoneId.systemDefault())
private val timeFormatter =
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault())
fun submitList(newItems: List<RawDataRecord>) {
items = newItems
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view =
LayoutInflater.from(parent.context)
.inflate(R.layout.item_raw_data_record, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.size
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvDateTime: TextView = itemView.findViewById(R.id.tvRecordDateTime)
private val tvValue: TextView = itemView.findViewById(R.id.tvRecordValue)
private val tvDataSource: TextView = itemView.findViewById(R.id.tvDataSource)
private val tvDetails: TextView = itemView.findViewById(R.id.tvRecordDetails)
private val tvDuration: TextView = itemView.findViewById(R.id.tvRecordDuration)
private val tvSyncInfo: TextView = itemView.findViewById(R.id.tvSyncInfo)
private val viewSyncStatus: View = itemView.findViewById(R.id.viewSyncStatus)
fun bind(record: RawDataRecord) {
// Date/Time
tvDateTime.text = dateTimeFormatter.format(record.timestamp)
// Value
tvValue.text = record.value
// Data Source (use friendly name)
tvDataSource.text = healthConnectManager.getSourceDisplayName(record.dataSource)
// Duration (if applicable)
if (record.endTime != null) {
val durationSeconds =
java.time.Duration.between(record.timestamp, record.endTime).seconds
val hours = durationSeconds / 3600
val minutes = (durationSeconds % 3600) / 60
val secs = durationSeconds % 60
tvDuration.visibility = View.VISIBLE
tvDuration.text =
when {
hours > 0 -> "Duration: ${hours}h ${minutes}m"
minutes > 0 -> "Duration: ${minutes}m ${secs}s"
else -> "Duration: ${secs}s"
}
// Show time range in details
tvDetails.visibility = View.VISIBLE
tvDetails.text =
"Start: ${timeFormatter.format(record.timestamp)} → End: ${timeFormatter.format(record.endTime)}"
} else {
tvDuration.visibility = View.GONE
tvDetails.visibility = if (record.details.isNotEmpty()) View.VISIBLE else View.GONE
tvDetails.text = record.details
}
// Sync status
if (record.isSynced && record.syncTimestamp != null) {
viewSyncStatus.setBackgroundResource(R.drawable.status_dot_green)
tvSyncInfo.visibility = View.VISIBLE
tvSyncInfo.text = "✓ Synced: ${dateTimeFormatter.format(record.syncTimestamp)}"
tvSyncInfo.setTextColor(Color.parseColor("#4CAF50"))
} else {
viewSyncStatus.setBackgroundResource(R.drawable.status_dot_gray)
tvSyncInfo.visibility = View.GONE
}
}
}
}
@@ -0,0 +1,241 @@
package com.habitforge.companion
import android.content.Context
import android.util.Log
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** 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) {
// Try reading from AuthManager's prefs first (source of truth)
val authPrefs =
context.getSharedPreferences("habitforge_auth", Context.MODE_PRIVATE)
var serverUrl = authPrefs.getString("server_url", null)
var authToken = authPrefs.getString("access_token", null)
// Fallback to legacy prefs if not found
if (serverUrl == null) {
val legacyPrefs =
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
serverUrl = legacyPrefs.getString("server_url", BuildConfig.BACKEND_URL)
authToken = authToken ?: legacyPrefs.getString("auth_token", null)
}
serverUrl = serverUrl ?: BuildConfig.BACKEND_URL
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) {
// Try reading from AuthManager's prefs first
val authPrefs =
context.getSharedPreferences("habitforge_auth", Context.MODE_PRIVATE)
var serverUrl = authPrefs.getString("server_url", null)
// Fallback to legacy prefs
if (serverUrl == null) {
val legacyPrefs =
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
serverUrl = legacyPrefs.getString("server_url", BuildConfig.BACKEND_URL)
}
serverUrl = serverUrl ?: 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,59 @@
package com.habitforge.companion
import android.content.Context
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.textfield.TextInputEditText
class SyncConfigActivity : AppCompatActivity() {
private lateinit var etSyncDays: TextInputEditText
private lateinit var tvCurrentConfig: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sync_config)
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { finish() }
etSyncDays = findViewById(R.id.etSyncDays)
tvCurrentConfig = findViewById(R.id.tvCurrentConfig)
val btnSave = findViewById<Button>(R.id.btnSave)
loadConfig()
btnSave.setOnClickListener { saveConfig() }
}
private fun loadConfig() {
val prefs = getSharedPreferences("habitforge", Context.MODE_PRIVATE)
val syncDays = prefs.getInt("sync_days", 7) // Default to 7
etSyncDays.setText(syncDays.toString())
tvCurrentConfig.text = "Currently syncing: $syncDays days"
}
private fun saveConfig() {
val input = etSyncDays.text.toString()
val days = input.toIntOrNull()
if (days == null || days <= 0 || days > 365) {
Toast.makeText(this, "Please enter a valid number of days (1-365)", Toast.LENGTH_SHORT)
.show()
return
}
val prefs = getSharedPreferences("habitforge", Context.MODE_PRIVATE)
prefs.edit().putInt("sync_days", days).apply()
tvCurrentConfig.text = "Currently syncing: $days days"
Toast.makeText(this, "Configuration saved", Toast.LENGTH_SHORT).show()
// Optionally trigger a new sync schedule if needed, but the service reads prefs on run.
}
}
@@ -0,0 +1,148 @@
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,
val syncType: SyncType? = SyncType.MANUAL,
val details: Map<String, Int> = emptyMap()
) {
fun getSyncTypeOrDefault(): SyncType {
return syncType ?: SyncType.MANUAL
}
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
}
/** Sync type enumeration */
enum class SyncType {
MANUAL,
SCHEDULED
}
/** 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,500 @@
package com.habitforge.companion
import android.content.Context
import android.provider.Settings
import android.util.Log
import androidx.work.*
import java.time.Instant
import java.time.LocalDate
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 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, "is_manual" to true)
)
.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",
inputData.getBoolean("is_manual", false)
)
return@withContext Result.failure()
}
// Check permissions
if (!healthConnectManager.hasAllPermissions()) {
Log.e(TAG, "Missing Health Connect permissions")
recordSyncFailure(
syncHistoryManager,
startTime,
"Missing permissions",
inputData.getBoolean("is_manual", false)
)
return@withContext Result.retry()
}
// Get auth token from preferences
// Get auth token from preferences (Prioritize AuthManager's prefs)
val authPrefs =
applicationContext.getSharedPreferences(
"habitforge_auth",
Context.MODE_PRIVATE
)
var authToken = authPrefs.getString("access_token", null)
val legacyPrefs =
applicationContext.getSharedPreferences(
"habitforge",
Context.MODE_PRIVATE
)
if (authToken == null) {
authToken = legacyPrefs.getString("auth_token", null)
}
if (authToken.isNullOrEmpty()) {
Log.e(TAG, "No auth token configured")
recordSyncFailure(
syncHistoryManager,
startTime,
"No auth token",
inputData.getBoolean("is_manual", false)
)
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,
data_source = metric.dataSource,
data_source_name =
healthConnectManager
.getSourceDisplayName(
metric.dataSource
)
)
}
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
// Send to backend
var backendUrl = authPrefs.getString("server_url", null)
if (backendUrl == null) {
backendUrl =
legacyPrefs.getString(
"server_url",
BuildConfig.BACKEND_URL
)
}
backendUrl = backendUrl ?: 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
legacyPrefs
.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
val syncType =
if (inputData.getBoolean("is_manual", false))
SyncType.MANUAL
else SyncType.SCHEDULED
syncHistoryManager.addSyncRecord(
SyncRecord(
timestamp = System.currentTimeMillis(),
status = SyncStatus.SUCCESS,
metricsSynced = body?.metrics_synced
?: dailyMetrics.size,
exercisesSynced = body?.exercises_synced
?: exercises.size,
syncDurationMs = duration,
syncType = syncType
)
)
return@withContext Result.success()
} else {
Log.e(TAG, "Sync failed with code: ${response.code()}")
recordSyncFailure(
syncHistoryManager,
startTime,
"Server returned ${response.code()}",
inputData.getBoolean("is_manual", false)
)
return@withContext Result.retry()
}
} catch (e: Exception) {
Log.e(TAG, "Sync error", e)
recordSyncFailure(
syncHistoryManager,
startTime,
e.localizedMessage ?: "Unknown error",
inputData.getBoolean("is_manual", false)
)
return@withContext Result.retry()
}
}
private fun recordSyncFailure(
manager: SyncHistoryManager,
startTime: Long,
errorMessage: String,
isManual: Boolean = false
) {
val duration = System.currentTimeMillis() - startTime
val syncType = if (isManual) SyncType.MANUAL else SyncType.SCHEDULED
manager.addSyncRecord(
SyncRecord(
timestamp = System.currentTimeMillis(),
status = SyncStatus.FAILED,
errorMessage = errorMessage,
syncDurationMs = duration,
syncType = syncType
)
)
}
}
/** Sync period option for UI selection */
data class SyncPeriodOption(val minutes: Int, val label: String)
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<!-- Filter/source icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M4.25,5.61C6.27,8.2 10,13 10,13v6c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1v-6c0,0 3.72,-4.8 5.74,-7.39C20.25,4.95 19.78,4 18.95,4H5.04C4.21,4 3.74,4.95 4.25,5.61z"/>
</vector>
@@ -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,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF" />
<stroke android:width="1dp" android:color="#E0E0E0" />
<corners android:radius="4dp" />
</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,874 @@
<?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>
<Button
android:id="@+id/btnSyncConfig"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Configure Sync Duration"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_marginTop="8dp" />
</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" />
<Button
android:id="@+id/btnViewRawData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔍 View Raw Health Connect Data"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_marginTop="8dp" />
<!-- 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"
android:layout_marginEnd="8dp"/>
<ImageButton
android:id="@+id/btnRefreshHistory"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_popup_sync"
android:background="?attr/selectableItemBackgroundBorderless"
app:tint="#6B7280"
android:contentDescription="Refresh History" />
</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" />
<!-- Metrics Sync Counts Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="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 Sync Status"
android:textSize="11sp"
android:textColor="#6B7280" />
<TextView
android:id="@+id/tvMetricsCountLastUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="9sp"
android:textColor="#9CA3AF" />
</LinearLayout>
<TextView
android:id="@+id/tvMetricsCountSummary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Synced / Available records per metric"
android:textSize="10sp"
android:textColor="#9CA3AF"
android:layout_marginTop="4dp" />
<LinearLayout
android:id="@+id/layoutMetricsCounts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp"
android:padding="8dp"
android:background="@drawable/rounded_background">
<TextView
android:id="@+id/tvNoMetricsCounts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Run a sync to see available vs synced counts"
android:textSize="11sp"
android:textColor="#9CA3AF"
android:gravity="center"
android:padding="12dp" />
</LinearLayout>
</LinearLayout>
</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,184 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
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="match_parent"
android:background="#F5F5F5">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/teal_dark"
app:title="Raw Health Data"
app:titleTextColor="@android:color/white"
app:navigationIcon="@drawable/ic_arrow_back" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_dark"
app:tabMode="scrollable"
app:tabTextColor="#80FFFFFF"
app:tabSelectedTextColor="@android:color/white"
app:tabIndicatorColor="@android:color/white" />
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- Date Range Selector -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📅 Date Range"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="#333333" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<Spinner
android:id="@+id/spinnerDateRange"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="@drawable/spinner_background"
android:padding="8dp" />
<Button
android:id="@+id/btnLoadData"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:text="Load"
android:backgroundTint="@color/teal_primary" />
</LinearLayout>
<!-- Stats Summary -->
<LinearLayout
android:id="@+id/layoutStats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp"
android:visibility="gone">
<TextView
android:id="@+id/tvTotalRecords"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Records: 0"
android:textSize="12sp"
android:textColor="#666666" />
<TextView
android:id="@+id/tvDateRange"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text=""
android:textSize="12sp"
android:textColor="#666666"
android:gravity="end" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Loading Indicator -->
<LinearLayout
android:id="@+id/layoutLoading"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:padding="16dp"
android:visibility="gone">
<ProgressBar
android:layout_width="24dp"
android:layout_height="24dp"
android:indeterminateTint="@color/teal_primary" />
<TextView
android:id="@+id/tvLoadingMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="Loading data..."
android:textColor="#666666" />
</LinearLayout>
<!-- Data List -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:padding="8dp" />
<!-- Empty State -->
<LinearLayout
android:id="@+id/layoutEmpty"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📭"
android:textSize="48sp" />
<TextView
android:id="@+id/tvEmptyMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="No data found for this date range"
android:textColor="#999999"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,104 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
tools:context=".SyncConfigActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
app:navigationIcon="@drawable/ic_arrow_back"
app:title="Sync Configuration"
app:titleTextColor="@android:color/white" />
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Raw Data Synchronization"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Configure how many days of history to include in each raw data sync. Larger values may increase sync time and data usage."
android:layout_marginBottom="16dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Sync Duration (Days)"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSyncDays"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="7" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Default: 7 days. Recommended: 1-14 days."
android:textSize="12sp"
android:textColor="#757575"
android:layout_marginTop="4dp"
android:layout_marginBottom="24dp" />
<Button
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save Configuration"
android:layout_marginBottom="16dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#E0E0E0"
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Current Status"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvCurrentConfig"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Currently syncing: 7 days"
android:layout_marginBottom="16dp"/>
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -0,0 +1,233 @@
<?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>
<!-- Filter Buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="4"
android:layout_marginBottom="12dp">
<Button
android:id="@+id/btnFilterRecent"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:text="Recent"
android:textSize="10sp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:padding="0dp"
android:layout_marginEnd="4dp"/>
<Button
android:id="@+id/btnFilterWeek"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:text="Week"
android:textSize="10sp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:padding="0dp"
android:layout_marginEnd="4dp"/>
<Button
android:id="@+id/btnFilterMonth"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:text="Month"
android:textSize="10sp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:padding="0dp"
android:layout_marginEnd="4dp"/>
<Button
android:id="@+id/btnFilterYear"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:text="Year"
android:textSize="10sp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:padding="0dp"/>
</LinearLayout>
<!-- Chart Placeholder -->
<ImageView
android:id="@+id/chartHistory"
android:layout_width="match_parent"
android:layout_height="180dp"
android:background="#F9FAFB"
android:layout_marginBottom="16dp"
android:contentDescription="History Chart" />
<!-- 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,110 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
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:layout_marginBottom="6dp"
app:cardCornerRadius="8dp"
app:cardElevation="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<!-- Header Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- Sync Status Indicator -->
<View
android:id="@+id/viewSyncStatus"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/status_dot_gray" />
<!-- Date/Time -->
<TextView
android:id="@+id/tvRecordDateTime"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="Jan 07, 2026 14:30:00"
android:textSize="13sp"
android:textColor="#333333"
android:textStyle="bold" />
<!-- Data Source App -->
<TextView
android:id="@+id/tvDataSource"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Zepp"
android:textSize="11sp"
android:textColor="#999999"
android:background="#F0F0F0"
android:paddingHorizontal="6dp"
android:paddingVertical="2dp" />
</LinearLayout>
<!-- Value Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvRecordValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="12,345 steps"
android:textSize="16sp"
android:textColor="@color/teal_dark"
android:textStyle="bold" />
<TextView
android:id="@+id/tvRecordDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Duration: 1h 30m"
android:textSize="11sp"
android:textColor="#666666"
android:visibility="gone" />
</LinearLayout>
<!-- Details Row (optional) -->
<TextView
android:id="@+id/tvRecordDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Start: 2026-01-07T08:00:00 → End: 2026-01-07T09:30:00"
android:textSize="10sp"
android:textColor="#999999"
android:visibility="gone" />
<!-- Sync Info -->
<TextView
android:id="@+id/tvSyncInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="✓ Synced: Jan 07, 2026 15:00"
android:textSize="10sp"
android:textColor="#4CAF50"
android:visibility="gone" />
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_select_source"
android:icon="@drawable/ic_filter_source"
android:title="Select Source"
app:showAsAction="ifRoom" />
</menu>
@@ -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,26 @@
<?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>
<!-- Teal colors for Raw Data Viewer -->
<color name="teal_primary">#14b8a6</color>
<color name="teal_dark">#0d9488</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
#Sun Jan 11 20:56:28 EST 2026
build.number=56
+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
}
+31
View File
@@ -0,0 +1,31 @@
[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 --info or --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 2s
+31
View File
@@ -0,0 +1,31 @@
[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 --info or --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 2s
+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
+8
View File
@@ -0,0 +1,8 @@
## This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Thu Jan 01 22:08:58 EST 2026
sdk.dir=C\:\\Users\\bruno\\scoop\\apps\\android-clt\\13114758
+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")
+26
View File
@@ -0,0 +1,26 @@
Haut du corps (Bras, Épaules, Pectoraux, Dos)
faire l'image pour "Dips" en conservant la fille et l'environnement
faire l'image pour "Diamond Push-ups" en conservant la fille et l'environnement
faire l'image pour "Pike Push-ups" en conservant la fille et l'environnement
faire l'image pour "Plank to Push-up" en conservant la fille et l'environnement
faire l'image pour "Chin-ups" en conservant la fille et l'environnement
faire une image pour "Inverted Rows" en conservant la fille et l'environnement
faire une image pour "Superman" en conservant la fille et l'environnement
Bas du corps (Jambes, Fessiers)
faire une image pour "Lunges (Fentes)" en conservant la fille et l'environnement
faire une image pour "Glute Bridges" en conservant la fille et l'environnement
faire une image pour "Wall Sit" en conservant la fille et l'environnement
faire une image pour "Calf Raises" en conservant la fille et l'environnement
faire une image pour "Jump Squats" en conservant la fille et l'environnement
Abdominaux et Gainage
faire une image pour "Plank" en conservant la fille et l'environnement
faire une image pour "Russian Twists" en conservant la fille et l'environnement
faire une image pour "Leg Raises" en conservant la fille et l'environnement
faire une image pour "Mountain Climbers" en conservant la fille et l'environnement
basé sur une image créée par un AI, faire une image pour une exercise de "Bicycle Crunches" en conservant la fille et l'environnement
Cardio et Corps complet
faire une image pour "Burpees" en conservant la fille et l'environnement
faire une image pour "Jumping Jacks" en conservant la fille et l'environnement
+23
View File
@@ -0,0 +1,23 @@
"""Backend module for HabitForge."""
from backend.models import User, Challenge, Tracking, DailyHealthMetrics
from backend.schemas import (
UserCreate, UserResponse,
ChallengeBase, ChallengeCreate, ChallengeResponse,
TrackingBase, TrackingCreate, TrackingResponse,
DailyHealthMetricsBase, DailyHealthMetricsResponse,
Token
)
from backend import auth, database
__all__ = [
# Models
"User", "Challenge", "Tracking", "DailyHealthMetrics",
# Schemas
"UserCreate", "UserResponse",
"ChallengeBase", "ChallengeCreate", "ChallengeResponse",
"TrackingBase", "TrackingCreate", "TrackingResponse",
"DailyHealthMetricsBase", "DailyHealthMetricsResponse",
"Token",
# Modules
"auth", "database"
]
+1
View File
@@ -0,0 +1 @@
"""API module for HabitForge."""
+1
View File
@@ -0,0 +1 @@
"""API v1 module for HabitForge."""
+107
View File
@@ -0,0 +1,107 @@
"""Authentication endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status, Request, Body
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from typing import List
from backend.models.user import User
from backend import auth
from backend.schemas.user import UserCreate, UserResponse
from backend.schemas.token import Token
from backend.database import get_db
from backend.core.config import settings
from slowapi import Limiter
from slowapi.util import get_remote_address
router = APIRouter(prefix="/api/auth", tags=["authentication"])
limiter = Limiter(key_func=get_remote_address)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def register(request: Request, user: UserCreate, db: Session = Depends(get_db)):
"""Register a new user."""
db_user = db.query(User).filter(User.username == user.username).first()
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
hashed_password = auth.get_password_hash(user.password)
new_user = User(username=user.username, hashed_password=hashed_password)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.post("/token", response_model=Token)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def login_for_access_token(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""Login and get access token."""
user = db.query(User).filter(User.username == form_data.username).first()
if not user or not auth.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
from datetime import timedelta
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
refresh_token_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
refresh_token = auth.create_refresh_token(
data={"sub": user.username}, expires_delta=refresh_token_expires
)
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
@router.post("/refresh", response_model=Token)
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
def refresh_token(
request: Request,
refresh_token: str = Body(..., embed=True),
db: Session = Depends(get_db)
):
"""Refresh access token using refresh token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
from datetime import timedelta
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
refresh_token_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
new_refresh_token = auth.create_refresh_token(
data={"sub": user.username}, expires_delta=refresh_token_expires
)
return {"access_token": access_token, "refresh_token": new_refresh_token, "token_type": "bearer"}
@router.get("/me", response_model=UserResponse)
def read_users_me(current_user: User = Depends(auth.get_current_user)):
"""Get current user information."""
return current_user
+118
View File
@@ -0,0 +1,118 @@
"""Challenge endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
import datetime
from backend.models.challenge import Challenge
from backend.models.tracking import Tracking
from backend.models.user import User
from backend import auth
from backend.schemas.challenge import ChallengeCreate, ChallengeResponse
from backend.schemas.tracking import TrackingCreate, TrackingResponse
from backend.database import get_db
router = APIRouter(prefix="/api/challenges", tags=["challenges"])
@router.post("", response_model=ChallengeResponse, status_code=status.HTTP_201_CREATED)
def create_challenge(
challenge: ChallengeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Create a new challenge."""
# Calculate next display_order for THIS user
max_order = db.query(func.max(Challenge.display_order)).filter(
Challenge.user_id == current_user.id
).scalar()
new_order = (max_order if max_order is not None else -1) + 1
db_challenge = Challenge(**challenge.dict(), display_order=new_order, user_id=current_user.id)
db.add(db_challenge)
db.commit()
db.refresh(db_challenge)
return db_challenge
@router.get("", response_model=List[ChallengeResponse])
def read_challenges(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get all challenges for the current user."""
challenges = db.query(Challenge).filter(
Challenge.user_id == current_user.id
).order_by(Challenge.display_order.asc()).all()
return challenges
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_challenges(
ordered_ids: List[int],
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Reorder challenges."""
# Sanity check: ensure these challenges belong to user
for index, challenge_id in enumerate(ordered_ids):
db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).update({"display_order": index})
db.commit()
return None
@router.delete("/{challenge_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_challenge(
challenge_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Delete a challenge."""
db_challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).first()
if not db_challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
db.delete(db_challenge)
db.commit()
return None
@router.post("/{challenge_id}/tracking", response_model=TrackingResponse)
def log_tracking(
challenge_id: int,
tracking: TrackingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Log tracking for a challenge."""
# Verify challenge belongs to user
challenge = db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == current_user.id
).first()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
# Check if tracking exists for this date and challenge
db_tracking = db.query(Tracking).filter(
Tracking.challenge_id == challenge_id,
Tracking.date == tracking.date
).first()
if db_tracking:
db_tracking.reps = tracking.reps
db_tracking.completed = tracking.completed
db_tracking.notes = tracking.notes
else:
db_tracking = Tracking(**tracking.dict())
db.add(db_tracking)
db.commit()
db.refresh(db_tracking)
return db_tracking
+384
View File
@@ -0,0 +1,384 @@
"""Health check and metric endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
import os
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
import datetime
from backend.database import get_db
from backend.models.daily_health_metrics import DailyHealthMetrics
from backend.models.user import User
from backend.models.workout_session import WorkoutSession
from backend.schemas.daily_health_metrics import DailyHealthMetricsResponse
from backend import auth
router = APIRouter(prefix="/api/health", tags=["health"])
@router.get("", status_code=status.HTTP_200_OK)
def health_check(db: Session = Depends(get_db)):
"""Check if the application and database are healthy."""
try:
# Check database connection
db.execute(text("SELECT 1"))
return {
"status": "healthy",
"database": "connected"
}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
# Valid endpoints start here
@router.get("/stats", response_model=List[DailyHealthMetricsResponse])
def get_health_stats(
period: str = "week",
ref_date: Optional[datetime.date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Retrieve health statistics for various periods."""
# Imports for Raw Data
from backend.models.health_connect_raw import HCRawWeight
from sqlalchemy import func
# Helper to fetch raw weights
def get_raw_weights(start, end):
return db.query(HCRawWeight).filter(
HCRawWeight.user_id == current_user.id,
HCRawWeight.time >= start,
HCRawWeight.time <= end
).order_by(HCRawWeight.time.asc()).all()
if ref_date is None:
ref_date = datetime.date.today()
start_date = None
end_date = datetime.datetime.combine(ref_date, datetime.time.max) # End of ref day
if period == "day":
start_date = datetime.datetime.combine(ref_date, datetime.time.min)
elif period == "week":
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=6), datetime.time.min)
elif period == "month":
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=29), datetime.time.min)
elif period == "year":
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=365), datetime.time.min)
# Base Metrics Query
metrics_query = db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id
)
if period == "day":
metrics_query = metrics_query.filter(DailyHealthMetrics.date == ref_date)
else:
metrics_query = metrics_query.filter(DailyHealthMetrics.date.between(start_date.date(), ref_date))
metrics_data = metrics_query.order_by(DailyHealthMetrics.date.asc()).all()
# If Year, handle aggregation specially
if period == "year":
# Raw weights for the year
raw_weights = get_raw_weights(start_date, end_date)
# Month-based aggregation for metrics
aggregated = []
import itertools
from statistics import mean
# Merge raw weights into metrics_data conceptually?
# Actually easier to aggregate metrics_data first, then overlay weight.
metrics_by_month = {}
for m in metrics_data:
key = (m.date.year, m.date.month)
if key not in metrics_by_month: metrics_by_month[key] = []
metrics_by_month[key].append(m)
weights_by_month = {}
for w in raw_weights:
key = (w.time.year, w.time.month)
if key not in weights_by_month: weights_by_month[key] = []
weights_by_month[key].append(w.weight_kg)
# distinct months from both sources
all_months = set(metrics_by_month.keys()) | set(weights_by_month.keys())
for key in sorted(all_months):
year, month = key
g_metrics = metrics_by_month.get(key, [])
g_weights = weights_by_month.get(key, [])
# Use first metric date or construct one
if g_metrics:
base_date = g_metrics[0].date.replace(day=1)
else:
base_date = datetime.date(year, month, 1)
agg = {
"id": int(f"{year}{month:02d}"), # Fake ID
"user_id": current_user.id,
"date": base_date,
# Defaults if no metrics
"step_count": (sum(x.step_count for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
"step_count_total": sum(x.step_count for x in g_metrics) if g_metrics else 0,
"calories_burned": (sum(x.calories_burned for x in g_metrics) / len(g_metrics)) if g_metrics else 0,
"distance_meters": (sum(x.distance_meters for x in g_metrics) / len(g_metrics)) if g_metrics else 0,
"sleep_duration_minutes": (sum(x.sleep_duration_minutes for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
"avg_heart_rate": (sum(x.avg_heart_rate or 0 for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
# ... skip other fields for brevity unless critical ...
"min_heart_rate": min((x.min_heart_rate for x in g_metrics if x.min_heart_rate), default=0) if g_metrics else 0,
"max_heart_rate": max((x.max_heart_rate for x in g_metrics if x.max_heart_rate), default=0) if g_metrics else 0,
# WEIGHT from RAW (schema uses 'weight')
"weight": mean(g_weights) if g_weights else 0
}
# If weight missing in raw but present in aggregated (unlikely but possible)
if agg["weight"] == 0 and g_metrics:
# Fallback to avg of metrics weight if available
w_vals = [x.weight for x in g_metrics if x.weight]
if w_vals: agg["weight"] = mean(w_vals)
aggregated.append(agg)
return aggregated
# For Day/Week/Month
# Fetch Raw Weights
raw_weights = get_raw_weights(start_date, end_date)
# Map raw weights to date (take latest per date)
weights_by_date = {}
for w in raw_weights:
d = w.time.date()
weights_by_date[d] = w.weight_kg # overwrites with latest
# Merge into metrics_data
# 1. Update existing
processed_dates = set()
final_list = []
for m in metrics_data:
processed_dates.add(m.date)
# Overlay raw weight if available
if m.date in weights_by_date:
m.weight = weights_by_date[m.date] # set attribute dynamically
final_list.append(m)
# 2. Add missing dates (if we have weight but no daily metric)
for d, w in weights_by_date.items():
if d not in processed_dates:
# Create dummy object
dummy = DailyHealthMetricsResponse(
id=0,
user_id=current_user.id,
date=d,
step_count=0,
calories_burned=0,
distance_meters=0,
sleep_duration_minutes=0,
weight=w, # Our raw weight
# defaults
deep_sleep_minutes=0, light_sleep_minutes=0, rem_sleep_minutes=0, awake_duration_minutes=0,
pai_score=0, avg_heart_rate=0, min_heart_rate=0, max_heart_rate=0, avg_spo2=0, resting_heart_rate=0, hrv=0
)
final_list.append(dummy)
# Sort
final_list.sort(key=lambda x: x.date)
return final_list
@router.get("/workouts")
def get_workouts(
limit: int = 100,
offset: int = 0,
activity_type: Optional[str] = None,
start_date: Optional[datetime.date] = None,
end_date: Optional[datetime.date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Retrieve workout sessions for the current user (merges manual and raw data)."""
from backend.models.workout_session import WorkoutSession
from backend.models.health_connect_raw import HCRawExerciseSession
start_dt = datetime.datetime.combine(start_date, datetime.time.min) if start_date else None
end_dt = datetime.datetime.combine(end_date, datetime.time.max) if end_date else None
# 1. Fetch from WorkoutSession
query_ws = db.query(WorkoutSession).filter(WorkoutSession.user_id == current_user.id)
if activity_type:
query_ws = query_ws.filter(WorkoutSession.activity_type == activity_type)
if start_dt:
query_ws = query_ws.filter(WorkoutSession.start_time >= start_dt)
if end_dt:
query_ws = query_ws.filter(WorkoutSession.start_time <= end_dt)
workouts_ws = query_ws.order_by(WorkoutSession.start_time.desc()).offset(offset).limit(limit).all()
# 2. Fetch from HCRawExerciseSession
query_raw = db.query(HCRawExerciseSession).filter(HCRawExerciseSession.user_id == current_user.id)
if activity_type:
query_raw = query_raw.filter(HCRawExerciseSession.exercise_type_name == activity_type)
if start_dt:
query_raw = query_raw.filter(HCRawExerciseSession.start_time >= start_dt)
if end_dt:
query_raw = query_raw.filter(HCRawExerciseSession.start_time <= end_dt)
workouts_raw = query_raw.order_by(HCRawExerciseSession.start_time.desc()).offset(offset).limit(limit).all()
# Combine and Map
combined = []
for w in workouts_ws:
combined.append({
"id": f"ws_{w.id}",
"user_id": w.user_id,
"start_time": w.start_time.isoformat() if w.start_time else None,
"end_time": w.end_time.isoformat() if w.end_time else None,
"activity_type": w.activity_type,
"activity_type_id": w.activity_type_id,
"duration_seconds": w.duration_seconds or 0,
"distance_meters": w.distance_meters or 0.0,
"calories": w.calories or 0.0,
"avg_pace": w.avg_pace,
"max_pace": w.max_pace,
"min_pace": w.min_pace,
"avg_hr": w.avg_hr,
"max_hr": w.max_hr,
"min_hr": w.min_hr,
"notes": w.notes,
"source": w.data_source
})
for w in workouts_raw:
duration = 0
if w.start_time and w.end_time:
duration = int((w.end_time - w.start_time).total_seconds())
combined.append({
"id": f"raw_{w.id}",
"user_id": w.user_id,
"start_time": w.start_time.isoformat() if w.start_time else None,
"end_time": w.end_time.isoformat() if w.end_time else None,
"activity_type": w.exercise_type_name or f"Activity {w.exercise_type}",
"activity_type_id": w.exercise_type,
"duration_seconds": duration,
"distance_meters": w.distance_meters or 0.0,
"calories": w.calories or 0.0,
"avg_pace": None,
"max_pace": None,
"min_pace": None,
"avg_hr": w.avg_heart_rate,
"max_hr": w.max_heart_rate,
"min_hr": w.min_heart_rate,
"notes": w.notes,
"source": "health_connect"
})
# Sort combined list by start_time DESC
combined.sort(key=lambda x: x["start_time"] if x["start_time"] else "", reverse=True)
return combined[:limit]
@router.get("/workouts/summary")
def get_workout_summary(
start_date: Optional[datetime.date] = None,
end_date: Optional[datetime.date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get summary statistics for workouts (combines manual and raw data)."""
from backend.models.workout_session import WorkoutSession
from backend.models.health_connect_raw import HCRawExerciseSession
from sqlalchemy import func
start_dt = datetime.datetime.combine(start_date, datetime.time.min) if start_date else None
end_dt = datetime.datetime.combine(end_date, datetime.time.max) if end_date else None
# 1. Stats from WorkoutSession
query_ws = db.query(
WorkoutSession.activity_type,
func.count(WorkoutSession.id).label('count'),
func.sum(WorkoutSession.duration_seconds).label('total_duration'),
func.sum(WorkoutSession.calories).label('total_calories'),
func.sum(WorkoutSession.distance_meters).label('total_distance')
).filter(WorkoutSession.user_id == current_user.id)
if start_dt:
query_ws = query_ws.filter(WorkoutSession.start_time >= start_dt)
if end_dt:
query_ws = query_ws.filter(WorkoutSession.start_time <= end_dt)
by_type_ws = query_ws.group_by(WorkoutSession.activity_type).all()
# 2. Stats from HCRawExerciseSession
# We fetch the sessions and aggregate in Python to avoid SQLite date extraction issues
query_raw = db.query(HCRawExerciseSession).filter(HCRawExerciseSession.user_id == current_user.id)
if start_dt:
query_raw = query_raw.filter(HCRawExerciseSession.start_time >= start_dt)
if end_dt:
query_raw = query_raw.filter(HCRawExerciseSession.start_time <= end_dt)
sessions_raw = query_raw.all()
# Merge results
summary_map = {}
# Process manual sessions
for row in by_type_ws:
name = row[0] or "Unknown"
summary_map[name] = {
"count": row[1],
"duration": row[2] or 0,
"calories": row[3] or 0,
"distance": row[4] or 0
}
# Process raw sessions
for w in sessions_raw:
name = w.exercise_type_name or f"Activity {w.exercise_type}"
duration = 0
if w.start_time and w.end_time:
duration = (w.end_time - w.start_time).total_seconds()
if name in summary_map:
summary_map[name]["count"] += 1
summary_map[name]["duration"] += duration
summary_map[name]["calories"] += (w.calories or 0)
summary_map[name]["distance"] += (w.distance_meters or 0)
else:
summary_map[name] = {
"count": 1,
"duration": duration,
"calories": w.calories or 0,
"distance": w.distance_meters or 0
}
total_workouts = sum(s["count"] for s in summary_map.values())
return {
"total_workouts": total_workouts,
"by_activity_type": [
{
"activity_type": name,
"count": stats["count"],
"total_duration_seconds": int(stats["duration"]),
"total_calories": round(stats["calories"], 1),
"total_distance_meters": round(stats["distance"], 1)
}
for name, stats in summary_map.items()
]
}
+598
View File
@@ -0,0 +1,598 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, distinct
from typing import List, Optional, Dict, Any
from datetime import datetime, date, timedelta
import logging
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect_raw import (
HCRawSteps,
HCRawHeartRate,
HCRawHeartRateSample,
HCRawRestingHeartRate,
HCRawSleepSession,
HCRawSleepStage,
HCRawDistance,
HCRawCalories,
HCRawOxygenSaturation,
HCRawWeight,
HCRawExerciseSession,
HCRawHeartRateVariabilityRmssd,
SLEEP_STAGE_TYPES
)
from backend.models.daily_context import DailyContext
from pydantic import BaseModel
from backend import auth
router = APIRouter(prefix="/api/analytics", tags=["health-analytics"])
logger = logging.getLogger(__name__)
@router.get("/sources", response_model=List[Dict[str, str]])
async def get_available_sources(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get unique data sources available in the raw data.
Checks Steps and Sleep as primary indicators.
"""
# Query distinct sources from Steps
step_sources = db.query(
HCRawSteps.data_source,
HCRawSteps.data_source_name
).filter(
HCRawSteps.user_id == current_user.id
).distinct().all()
# Query distinct sources from Sleep
sleep_sources = db.query(
HCRawSleepSession.data_source,
HCRawSleepSession.data_source_name
).filter(
HCRawSleepSession.user_id == current_user.id
).distinct().all()
# Combine and Dedupe
sources_map = {}
for src, name in step_sources:
if src not in sources_map:
sources_map[src] = name or src
for src, name in sleep_sources:
if src not in sources_map:
sources_map[src] = name or src
return [
{"package": k, "name": v}
for k, v in sources_map.items()
]
@router.get("/daily", response_model=Dict[str, Any])
async def get_daily_analytics(
date_str: str, # YYYY-MM-DD
source: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get aggregated daily heath metrics from raw data for a specific date.
"""
try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
start_dt = datetime.combine(target_date, datetime.min.time())
end_dt = datetime.combine(target_date, datetime.max.time())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
stats = {}
# --- STEPS ---
steps_query = db.query(func.sum(HCRawSteps.count)).filter(
HCRawSteps.user_id == current_user.id,
HCRawSteps.start_time >= start_dt,
HCRawSteps.start_time <= end_dt
)
if source:
steps_query = steps_query.filter(HCRawSteps.data_source == source)
stats["steps"] = steps_query.scalar() or 0
# --- CALORIES ---
# Sum total calories
cals_query = db.query(func.sum(HCRawCalories.calories)).filter(
HCRawCalories.user_id == current_user.id,
HCRawCalories.start_time >= start_dt,
HCRawCalories.start_time <= end_dt
)
if source:
cals_query = cals_query.filter(HCRawCalories.data_source == source)
stats["calories"] = round(cals_query.scalar() or 0, 1)
# --- DISTANCE ---
dist_query = db.query(func.sum(HCRawDistance.distance_meters)).filter(
HCRawDistance.user_id == current_user.id,
HCRawDistance.start_time >= start_dt,
HCRawDistance.start_time <= end_dt
)
if source:
dist_query = dist_query.filter(HCRawDistance.data_source == source)
stats["distance_meters"] = round(dist_query.scalar() or 0, 1)
# --- SLEEP (Previous Night) ---
# Usually sleep for "today" means sleep ending today (morning).
# Logic: Sleep sessions ending between today 00:00 and today 23:59 (or noon to noon?)
# Health Connect usually assigns sleep to the day it ends.
# We sum duration of sessions overlap. Simpler: Sum duration of sessions ending on this date.
sleep_query = db.query(HCRawSleepSession).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
)
if source:
sleep_query = sleep_query.filter(HCRawSleepSession.data_source == source)
sessions = sleep_query.all()
total_sleep_seconds = 0
for s in sessions:
duration = (s.end_time - s.start_time).total_seconds()
total_sleep_seconds += duration
session_total_minutes = int(total_sleep_seconds / 60)
# Track bedtime/wake (earliest start, latest end)
bedtime = min([s.start_time for s in sessions]) if sessions else None
wake_time = max([s.end_time for s in sessions]) if sessions else None
stats["bedtime"] = bedtime.isoformat() if bedtime else None
stats["wake_time"] = wake_time.isoformat() if wake_time else None
# --- HEART RATE (Resting) ---
rhr_query = db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= start_dt,
HCRawRestingHeartRate.time <= end_dt
)
if source:
rhr_query = rhr_query.filter(HCRawRestingHeartRate.data_source == source)
stats["resting_hr"] = int(rhr_query.scalar() or 0)
# --- WEIGHT (Latest) ---
weight_query = db.query(HCRawWeight.weight_kg).filter(
HCRawWeight.user_id == current_user.id,
HCRawWeight.time <= end_dt
).order_by(HCRawWeight.time.desc())
if source:
weight_query = weight_query.filter(HCRawWeight.data_source == source)
latest_weight = weight_query.first()
stats["weight_kg"] = round(latest_weight[0], 1) if latest_weight else None
# --- SLEEP STAGES ---
# Sum duration per stage for sessions ending today
stages_query = db.query(
HCRawSleepStage.stage_name,
func.sum(func.extract('epoch', HCRawSleepStage.end_time) - func.extract('epoch', HCRawSleepStage.start_time))
).join(HCRawSleepSession).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
)
if source:
stages_query = stages_query.filter(HCRawSleepSession.data_source == source)
stages_result = stages_query.group_by(HCRawSleepStage.stage_name).all()
stats["deep_sleep_minutes"] = 0
stats["light_sleep_minutes"] = 0
stats["rem_sleep_minutes"] = 0
stats["awake_duration_minutes"] = 0
for stage_name, duration_seconds in stages_result:
if not duration_seconds:
continue
minutes = int(duration_seconds / 60)
# Normalize stage names
name = stage_name.lower() if stage_name else "unknown"
if "deep" in name:
stats["deep_sleep_minutes"] += minutes
elif "light" in name:
stats["light_sleep_minutes"] += minutes
elif "rem" in name:
stats["rem_sleep_minutes"] += minutes
elif "awake" in name:
stats["awake_duration_minutes"] += minutes
# --- SLEEP HEART RATE ---
if bedtime and wake_time:
sleep_hr_query = db.query(func.avg(HCRawHeartRateSample.bpm)).join(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRateSample.time >= bedtime,
HCRawHeartRateSample.time <= wake_time
)
if source:
sleep_hr_query = sleep_hr_query.filter(HCRawHeartRate.data_source == source)
stats["sleep_avg_hr"] = int(sleep_hr_query.scalar() or 0)
else:
stats["sleep_avg_hr"] = None
# Final Sleep Calculation: Total Sleep = Deep + Light + REM
# This excludes Awake time which is often included in session duration
actual_sleep = stats["deep_sleep_minutes"] + stats["light_sleep_minutes"] + stats["rem_sleep_minutes"]
if actual_sleep > 0:
stats["sleep_minutes"] = actual_sleep
else:
# Fallback to session duration (minus awake if reported as a stage)
stats["sleep_minutes"] = max(0, session_total_minutes - stats["awake_duration_minutes"])
# --- HEART RATE DETAILED (Min/Max/Avg) ---
# Query samples directly for accuracy
hr_stats_query = db.query(
func.min(HCRawHeartRateSample.bpm),
func.max(HCRawHeartRateSample.bpm),
func.avg(HCRawHeartRateSample.bpm)
).join(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRateSample.time >= start_dt,
HCRawHeartRateSample.time <= end_dt
)
if source:
hr_stats_query = hr_stats_query.filter(HCRawHeartRate.data_source == source)
min_hr, max_hr, avg_hr = hr_stats_query.first()
stats["min_heart_rate"] = min_hr
stats["max_heart_rate"] = max_hr
stats["avg_heart_rate"] = int(avg_hr) if avg_hr else None
# --- HRV ---
hrv_query = db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= start_dt,
HCRawHeartRateVariabilityRmssd.time <= end_dt
)
if source:
hrv_query = hrv_query.filter(HCRawHeartRateVariabilityRmssd.data_source == source)
avg_hrv = hrv_query.scalar()
stats["hrv"] = int(avg_hrv) if avg_hrv else None
# --- USER CONTEXT ---
context = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date == target_date
).first()
stats["user_context"] = {
"mood_score": context.mood_score if context else None,
"tags": context.tags.split(",") if context and context.tags else [],
"notes": context.notes if context else None
}
# --- READINESS CALCULATION ---
# 1. Fetch 30-day baselines for HRV and RHR
baseline_start = start_dt - timedelta(days=30)
baseline_hrv = float(db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= baseline_start,
HCRawHeartRateVariabilityRmssd.time < start_dt
).scalar() or 40.0) # Default fallback
baseline_rhr = float(db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= baseline_start,
HCRawRestingHeartRate.time < start_dt
).scalar() or 60.0) # Default fallback
# 2. Mental Score (Nervous System)
# HRV: Higher is better. RHR: Lower is better.
current_hrv = float(stats["hrv"] or baseline_hrv)
current_rhr = float(stats["resting_hr"] or baseline_rhr)
hrv_component = (current_hrv / baseline_hrv) * 100
rhr_component = (baseline_rhr / current_rhr) * 100
mental_score = min(100, max(0, (hrv_component + rhr_component) / 2))
# 3. Physical Score (Sleep + Activity)
# Sleep goal: 8h (480 min).
sleep_score = min(100, (stats["sleep_minutes"] / 480) * 100)
# Simple physical placeholder: just sleep for now as we don't have "yesterday's strain" handy easily without another query
# TODO: Add previous day strain integration
physical_score = sleep_score
overall_score = int((mental_score * 0.5) + (physical_score * 0.5))
# Recommendation Logic
recommendation = ""
if overall_score >= 80:
recommendation = "All systems go! Your body and mind are primed for high intensity."
elif overall_score < 40:
recommendation = "Rest day advised. Focus on recovery and sleep."
else:
# Mixed signals
if mental_score < physical_score - 10:
recommendation = "Body is ready, but nervous system is tired. Avoid HIIT, try steady cardio or yoga."
elif physical_score < mental_score - 10:
recommendation = "Mind is fresh, but body needs rest. Light technical work or mobility."
else:
recommendation = "Moderate intensity recommended. Listen to your body."
stats["readiness"] = {
"score": overall_score,
"mental_score": int(mental_score),
"physical_score": int(physical_score),
"hrv_baseline": int(baseline_hrv),
"rhr_baseline": int(baseline_rhr),
"recommendation": recommendation
}
return stats
@router.get("/range", response_model=Dict[str, Any])
async def get_range_analytics(
start_date: str,
end_date: str,
source: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get daily breakdown for a date range (e.g. for charts).
"""
try:
s_date = datetime.strptime(start_date, "%Y-%m-%d").date()
e_date = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
# Generate list of dates
delta = e_date - s_date
days = []
for i in range(delta.days + 1):
day = s_date + timedelta(days=i)
days.append(day)
results = []
# Optimized: We could do a big Group By query, but simplistic approach first loop is safer for logic correctness
# For performance on 30 days, we should use SQL Group By.
# STEPS Group By
start_dt = datetime.combine(s_date, datetime.min.time())
end_dt = datetime.combine(e_date, datetime.max.time())
# helper for range query
def get_daily_sum(model, value_col):
qs = db.query(
func.date(model.start_time).label("day"),
func.sum(value_col)
).filter(
model.user_id == current_user.id,
model.start_time >= start_dt,
model.start_time <= end_dt
)
if source:
qs = qs.filter(model.data_source == source)
return dict(qs.group_by(func.date(model.start_time)).all())
steps_data = get_daily_sum(HCRawSteps, HCRawSteps.count)
cals_data = get_daily_sum(HCRawCalories, HCRawCalories.calories)
dist_data = get_daily_sum(HCRawDistance, HCRawDistance.distance_meters)
# Resting HR (Average per day)
rhr_qs = db.query(
func.date(HCRawRestingHeartRate.time).label("day"),
func.avg(HCRawRestingHeartRate.bpm)
).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= start_dt,
HCRawRestingHeartRate.time <= end_dt
)
if source:
rhr_qs = rhr_qs.filter(HCRawRestingHeartRate.data_source == source)
rhr_data = dict(rhr_qs.group_by(func.date(HCRawRestingHeartRate.time)).all())
# HRV (Average per day)
hrv_qs = db.query(
func.date(HCRawHeartRateVariabilityRmssd.time).label("day"),
func.avg(HCRawHeartRateVariabilityRmssd.rmssd)
).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= start_dt,
HCRawHeartRateVariabilityRmssd.time <= end_dt
)
if source:
hrv_qs = hrv_qs.filter(HCRawHeartRateVariabilityRmssd.data_source == source)
hrv_data = dict(hrv_qs.group_by(func.date(HCRawHeartRateVariabilityRmssd.time)).all())
# Sleep is trickier. We want Deep + Light + REM, excluding Awake.
# 1. Query stages excluding awake
sleep_stages_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.sum(func.extract('epoch', HCRawSleepStage.end_time) - func.extract('epoch', HCRawSleepStage.start_time))
).join(HCRawSleepStage).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt,
func.lower(HCRawSleepStage.stage_name).notlike('%awake%')
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_stages_qs = sleep_stages_qs.filter(HCRawSleepSession.data_source == source)
sleep_stages_map = {str(d): s/60 for d, s in sleep_stages_qs.all()}
# 2. Query session totals as fallback
sleep_sess_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.sum(func.extract('epoch', HCRawSleepSession.end_time) - func.extract('epoch', HCRawSleepSession.start_time))
).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_sess_qs = sleep_sess_qs.filter(HCRawSleepSession.data_source == source)
sleep_sess_map = {str(d): s/60 for d, s in sleep_sess_qs.all()}
# Merge: Prioritize stages
sleep_data = {}
for d_str in set(list(sleep_stages_map.keys()) + list(sleep_sess_map.keys())):
sleep_data[d_str] = sleep_stages_map.get(d_str) or sleep_sess_map.get(d_str) or 0
# 3. Query bedtime/wake per day
sleep_times_qs = db.query(
func.date(HCRawSleepSession.end_time).label("day"),
func.min(HCRawSleepSession.start_time),
func.max(HCRawSleepSession.end_time)
).filter(
HCRawSleepSession.user_id == current_user.id,
HCRawSleepSession.end_time >= start_dt,
HCRawSleepSession.end_time <= end_dt
).group_by(func.date(HCRawSleepSession.end_time))
if source:
sleep_times_qs = sleep_times_qs.filter(HCRawSleepSession.data_source == source)
sleep_times_map = {str(d): {"bedtime": b.isoformat(), "wake_time": w.isoformat()} for d, b, w in sleep_times_qs.all()}
# Construct response
daily_data = []
for day in days:
day_str = day.strftime("%Y-%m-%d")
# Helper to safely get value from dict with string or date keys
def get_val(data_dict):
val = data_dict.get(day_str, 0)
if not val and data_dict.get(day):
val = data_dict.get(day)
return val or 0
times = sleep_times_map.get(day_str, {})
daily_data.append({
"date": day_str,
"steps": int(get_val(steps_data)),
"calories": round(get_val(cals_data), 1),
"distance_meters": round(get_val(dist_data), 1),
"resting_hr": int(get_val(rhr_data)),
"hrv": int(get_val(hrv_data)),
"sleep_minutes": int(sleep_data.get(day_str, 0)),
"bedtime": times.get("bedtime"),
"wake_time": times.get("wake_time"),
"mood_score": 0, # Placeholder
"strain": int(get_val(cals_data) / 20), # Rough strain proxy (2000 cal = 100 strain)
"recovery": 0 # Placeholder
})
# Enrich with Context and simple readiness (optimized)
# Fetch all context for range
context_map = {}
contexts = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date >= s_date,
DailyContext.date <= e_date
).all()
for c in contexts:
context_map[str(c.date)] = {"mood": c.mood_score, "tags": c.tags}
# Calculate baselines once (using end_date for simplicity)
baseline_start = e_date - timedelta(days=30)
base_hrv = float(db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
HCRawHeartRateVariabilityRmssd.time >= datetime.combine(baseline_start, datetime.min.time()),
HCRawHeartRateVariabilityRmssd.time <= datetime.combine(e_date, datetime.max.time())
).scalar() or 40.0)
base_rhr = float(db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
HCRawRestingHeartRate.user_id == current_user.id,
HCRawRestingHeartRate.time >= datetime.combine(baseline_start, datetime.min.time()),
HCRawRestingHeartRate.time <= datetime.combine(e_date, datetime.max.time())
).scalar() or 60.0)
for d in daily_data:
d_date = d["date"]
# Context
if d_date in context_map:
d["mood_score"] = context_map[d_date]["mood"]
d["tags"] = context_map[d_date]["tags"]
# Readiness Calculation
d_hrv = float(d["hrv"] or base_hrv)
d_rhr = float(d["resting_hr"] or base_rhr)
d_sleep = d["sleep_minutes"]
mental = ((d_hrv / base_hrv) * 0.5 + (base_rhr / d_rhr) * 0.5) * 100
physical = min(100, (d_sleep / 480) * 100)
d["readiness"] = int((mental + physical) / 2)
d["recovery_score"] = int(mental) # Use mental as "Recovery" for the chart green bar? Or overall readiness.
return {
"daily": daily_data,
"baselines": {
"hrv": int(base_hrv),
"rhr": int(base_rhr)
}
}
class ContextUpdate(BaseModel):
date: str
mood: int
tags: Optional[str] = None
notes: Optional[str] = None
@router.post("/context", status_code=status.HTTP_201_CREATED)
async def update_daily_context(
update: ContextUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update subjective user context (mood, tags) for a specific date.
"""
try:
target_date = datetime.strptime(update.date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
context = db.query(DailyContext).filter(
DailyContext.user_id == current_user.id,
DailyContext.date == target_date
).first()
if not context:
context = DailyContext(
user_id=current_user.id,
date=target_date,
mood_score=update.mood,
tags=update.tags,
notes=update.notes
)
db.add(context)
else:
context.mood_score = update.mood
context.tags = update.tags
context.notes = update.notes
db.commit()
db.refresh(context)
return {"status": "success", "id": context.id}
+363
View File
@@ -0,0 +1,363 @@
"""
Health Connect API - Endpoints for receiving data from Android companion app.
These endpoints allow the Android companion app to push health data
from Health Connect to the HabitForge backend.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime, date
import hashlib
import hmac
import logging
logger = logging.getLogger(__name__)
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect import (
HealthConnectSyncLog,
HealthConnectDailyMetrics,
HealthConnectExerciseSession,
HEALTH_CONNECT_EXERCISE_TYPES,
)
from backend.models.daily_health_metrics import DailyHealthMetrics
from backend.models.workout_session import WorkoutSession
from backend import auth
from backend.core.config import settings
router = APIRouter(prefix="/api/health-connect", tags=["health-connect"])
# --- Request Models ---
class DailyMetricsPayload(BaseModel):
"""Payload for daily health metrics from Health Connect."""
date: date
step_count: int = 0
distance_meters: float = 0.0
calories_burned: float = 0.0
total_calories: float = 0.0
sleep_duration_minutes: int = 0
deep_sleep_minutes: int = 0
light_sleep_minutes: int = 0
rem_sleep_minutes: int = 0
awake_duration_minutes: int = 0
avg_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
avg_spo2: Optional[float] = None
min_spo2: Optional[float] = None
max_spo2: Optional[float] = None
weight: Optional[float] = None
# Data source tracking
data_source: Optional[str] = None # Package name (e.g., "com.huami.watch.hmwatchmanager")
data_source_name: Optional[str] = None # Friendly name (e.g., "Zepp")
class ExerciseSessionPayload(BaseModel):
"""Payload for exercise session from Health Connect."""
start_time: datetime
end_time: datetime
exercise_type: int # Health Connect ExerciseType enum value
exercise_type_name: Optional[str] = None
duration_seconds: int = 0
distance_meters: Optional[float] = None
calories_burned: Optional[float] = None
avg_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
steps: Optional[int] = None
elevation_gained: Optional[float] = None
notes: Optional[str] = None
class HealthConnectSyncRequest(BaseModel):
"""Full sync request from Android companion app."""
device_id: str
sync_timestamp: str = "" # Added for Android compatibility
daily_metrics: List[DailyMetricsPayload] = []
exercise_sessions: List[ExerciseSessionPayload] = []
class SyncResponse(BaseModel):
"""Response for sync operation."""
status: str
sync_id: int
metrics_synced: int # Renamed from metrics_processed to match Android
exercises_synced: int # Renamed from sessions_processed to match Android
message: str
# --- Endpoints ---
@router.post("/sync", response_model=SyncResponse, status_code=status.HTTP_200_OK)
async def receive_health_connect_data(
request: HealthConnectSyncRequest,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Receive health data pushed from Android companion app.
This endpoint accepts data from Health Connect and stores it
for later retrieval by the HealthConnectProvider.
"""
logger.info(f"=== Health Connect Sync Request from user {current_user.username} ===")
logger.info(f"Device ID: {request.device_id}")
logger.info(f"Timestamp: {request.sync_timestamp}")
logger.info(f"Daily metrics count: {len(request.daily_metrics)}")
logger.info(f"Exercise sessions count: {len(request.exercise_sessions)}")
# Log sample data for debugging
if request.daily_metrics:
sample = request.daily_metrics[0]
logger.info(f"Sample metric - Date: {sample.date}, Steps: {sample.step_count}, Calories: {sample.calories_burned}")
if request.exercise_sessions:
sample = request.exercise_sessions[0]
logger.info(f"Sample exercise - Type: {sample.exercise_type_name}, Duration: {sample.duration_seconds}s")
# Create sync log entry
sync_log = HealthConnectSyncLog(
user_id=current_user.id,
device_id=request.device_id,
records_pushed=len(request.daily_metrics) + len(request.exercise_sessions),
status="processing"
)
db.add(sync_log)
db.flush() # Get the sync_log.id
metrics_synced = 0
exercises_synced = 0
try:
# Process daily metrics
for metrics in request.daily_metrics:
# Upsert: update if exists, insert if not
existing = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id,
HealthConnectDailyMetrics.date == metrics.date
).first()
if existing:
# Update existing record
for field, value in metrics.model_dump().items():
if value is not None:
setattr(existing, field, value)
existing.last_synced = datetime.utcnow()
existing.sync_id = sync_log.id
logger.info(f"Updated existing metric for date {metrics.date}")
else:
# Create new record
new_metrics = HealthConnectDailyMetrics(
user_id=current_user.id,
sync_id=sync_log.id,
**metrics.model_dump()
)
db.add(new_metrics)
logger.info(f"Created new metric for date {metrics.date}")
metrics_synced += 1
# --- AUTO-SYNC to Main DailyHealthMetrics Table ---
try:
main_existing = db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id,
DailyHealthMetrics.date == metrics.date
).first()
# Filter fields that exist in DailyHealthMetrics
metric_data = metrics.model_dump()
# Manual Mapping / Cleanup
# DailyMetricsPayload has snake_case keys which match DailyHealthMetrics mostly
# Exceptions:
# - DailyHealthMetrics has no 'data_source_name'
# - DailyHealthMetrics has 'data_source' (string)
update_data = {}
for key, value in metric_data.items():
if value is not None and hasattr(DailyHealthMetrics, key):
update_data[key] = value
# Explicitly set data source
update_data["data_source"] = "health_connect"
if main_existing:
# Update if value is not None (and maybe preferential logic?)
# For now, we trust the push as latest source of truth
for key, value in update_data.items():
setattr(main_existing, key, value)
logger.info(f"Auto-synced main health metric for {metrics.date}")
else:
new_main = DailyHealthMetrics(
user_id=current_user.id,
**update_data
)
db.add(new_main)
logger.info(f"Created main health metric for {metrics.date}")
except Exception as e:
logger.error(f"Failed to auto-sync daily metric to main table: {e}")
# Don't fail the request, just log
# Process exercise sessions
for session in request.exercise_sessions:
# Dedup by start_time
existing = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.user_id == current_user.id,
HealthConnectExerciseSession.start_time == session.start_time
).first()
if not existing:
# Get exercise type name if not provided
exercise_name = session.exercise_type_name or HEALTH_CONNECT_EXERCISE_TYPES.get(
session.exercise_type, f"Exercise {session.exercise_type}"
)
new_session = HealthConnectExerciseSession(
user_id=current_user.id,
sync_id=sync_log.id,
exercise_type_name=exercise_name,
**session.model_dump(exclude={"exercise_type_name"})
)
db.add(new_session)
exercises_synced += 1
logger.info(f"Created new exercise session: {exercise_name}")
# --- AUTO-SYNC to Main WorkoutSession Table ---
try:
# Check existence in main table (simple check by start time)
main_workout_exists = db.query(WorkoutSession).filter(
WorkoutSession.user_id == current_user.id,
WorkoutSession.start_time == session.start_time
).first()
if not main_workout_exists:
# key mapping
sess_data = session.model_dump(exclude={"exercise_type_name"})
# WorkoutSession fields:
# start_time, end_time, activity_type (str), activity_type_id (int),
# duration_seconds, distance_meters, calories (not calories_burned),
# avg_hr, max_hr, min_hr, notes
new_workout = WorkoutSession(
user_id=current_user.id,
start_time=session.start_time,
end_time=session.end_time,
activity_type=exercise_name,
activity_type_id=session.exercise_type,
duration_seconds=session.duration_seconds,
distance_meters=session.distance_meters or 0,
calories=session.calories_burned or 0,
avg_hr=session.avg_heart_rate,
max_hr=session.max_heart_rate,
min_hr=session.min_heart_rate,
notes=session.notes,
data_source="health_connect"
)
db.add(new_workout)
logger.info(f"Auto-synced workout session: {exercise_name}")
except Exception as e:
logger.error(f"Failed to auto-sync workout to main table: {e}")
else:
logger.info(f"Skipped duplicate exercise session at {session.start_time}")
# Update sync log
sync_log.status = "success"
sync_log.records_pushed = metrics_synced + exercises_synced
db.commit()
logger.info(f"=== Sync Complete: {metrics_synced} metrics, {exercises_synced} exercises ===")
return SyncResponse(
status="success",
sync_id=sync_log.id,
metrics_synced=metrics_synced,
exercises_synced=exercises_synced,
message=f"Successfully synced {metrics_synced} daily records and {exercises_synced} exercise sessions"
)
except Exception as e:
logger.error(f"Sync failed: {str(e)}", exc_info=True)
sync_log.status = "error"
sync_log.error_message = str(e)
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Sync failed: {str(e)}"
)
@router.get("/status", status_code=status.HTTP_200_OK)
async def get_sync_status(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get the status of Health Connect sync for current user."""
# Get latest sync
latest_sync = db.query(HealthConnectSyncLog).filter(
HealthConnectSyncLog.user_id == current_user.id
).order_by(HealthConnectSyncLog.sync_timestamp.desc()).first()
# Count records
metrics_count = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id
).count()
sessions_count = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.user_id == current_user.id
).count()
return {
"has_data": metrics_count > 0 or sessions_count > 0,
"metrics_count": metrics_count,
"sessions_count": sessions_count,
"last_sync": latest_sync.sync_timestamp if latest_sync else None,
"last_sync_status": latest_sync.status if latest_sync else None,
"last_device_id": latest_sync.device_id if latest_sync else None
}
@router.get("/metrics", status_code=status.HTTP_200_OK)
async def get_health_connect_metrics(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get Health Connect daily metrics for current user."""
from datetime import timedelta
start_date = date.today() - timedelta(days=days)
metrics = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == current_user.id,
HealthConnectDailyMetrics.date >= start_date
).order_by(HealthConnectDailyMetrics.date.desc()).all()
return [
{
"date": m.date.isoformat(),
"step_count": m.step_count,
"distance_meters": m.distance_meters,
"calories_burned": m.calories_burned,
"sleep_duration_minutes": m.sleep_duration_minutes,
"deep_sleep_minutes": m.deep_sleep_minutes,
"light_sleep_minutes": m.light_sleep_minutes,
"rem_sleep_minutes": m.rem_sleep_minutes,
"avg_heart_rate": m.avg_heart_rate,
"avg_spo2": m.avg_spo2,
"weight": m.weight,
"data_source": m.data_source,
"data_source_name": m.data_source_name
}
for m in metrics
]
+734
View File
@@ -0,0 +1,734 @@
"""
Health Connect Raw Data API - Endpoints for receiving raw health records from Android.
This endpoint receives individual health records (not aggregated) from the Android
companion app and stores them for accurate on-demand aggregation.
OPTIMIZED VERSION: Uses batch inserts and efficient duplicate detection.
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import and_, tuple_
from typing import List, Optional, Set, Tuple
from pydantic import BaseModel
from datetime import datetime
import logging
import time
logger = logging.getLogger(__name__)
from backend.database import get_db
from backend.models.user import User
from backend.models.health_connect import HealthConnectSyncLog
from backend.models.health_connect_raw import (
HCRawSteps,
HCRawHeartRate,
HCRawHeartRateSample,
HCRawRestingHeartRate,
HCRawSleepSession,
HCRawSleepStage,
HCRawDistance,
HCRawCalories,
HCRawOxygenSaturation,
HCRawWeight,
HCRawHeight,
HCRawBodyFat,
HCRawExerciseSession,
HCRawNutrition,
HCRawHydration,
SLEEP_STAGE_TYPES,
)
from backend import auth
router = APIRouter(prefix="/api/health-connect", tags=["health-connect-raw"])
# ==================== REQUEST MODELS ====================
class RawStepsPayload(BaseModel):
"""Individual step record from Health Connect"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
count: int
class HeartRateSamplePayload(BaseModel):
"""Single heart rate sample"""
time: datetime
bpm: int
class RawHeartRatePayload(BaseModel):
"""Heart rate record with samples"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
samples: List[HeartRateSamplePayload] = []
class RawRestingHeartRatePayload(BaseModel):
"""Resting heart rate record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
bpm: int
class SleepStagePayload(BaseModel):
"""Sleep stage within a session"""
start_time: datetime
end_time: datetime
stage_type: int
stage_name: Optional[str] = None
class RawSleepSessionPayload(BaseModel):
"""Sleep session with stages"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
title: Optional[str] = None
notes: Optional[str] = None
stages: List[SleepStagePayload] = []
class RawDistancePayload(BaseModel):
"""Distance record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
distance_meters: float
class RawCaloriesPayload(BaseModel):
"""Calories record (active or total)"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
calories: float
record_type: str # "active" or "total"
class RawOxygenSaturationPayload(BaseModel):
"""SpO2 record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
percentage: float
class RawWeightPayload(BaseModel):
"""Weight record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
weight_kg: float
class RawHeightPayload(BaseModel):
"""Height record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
height_meters: float
class RawBodyFatPayload(BaseModel):
"""Body fat record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
time: datetime
percentage: float
class RawExerciseSessionPayload(BaseModel):
"""Exercise session"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
exercise_type: int
exercise_type_name: Optional[str] = None
title: Optional[str] = None
notes: Optional[str] = None
distance_meters: Optional[float] = None
calories: Optional[float] = None
avg_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
steps: Optional[int] = None
elevation_gained_meters: Optional[float] = None
class RawNutritionPayload(BaseModel):
"""Nutrition record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
name: Optional[str] = None
meal_type: Optional[int] = None
calories: Optional[float] = None
protein_grams: Optional[float] = None
carbohydrates_grams: Optional[float] = None
fat_grams: Optional[float] = None
fiber_grams: Optional[float] = None
sugar_grams: Optional[float] = None
class RawHydrationPayload(BaseModel):
"""Hydration record"""
record_id: str
data_source: str
data_source_name: Optional[str] = None
start_time: datetime
end_time: datetime
volume_liters: float
class RawDataSyncRequest(BaseModel):
"""Complete raw data sync request from Android app"""
device_id: str
sync_timestamp: str = ""
# All record types
steps_records: List[RawStepsPayload] = []
heart_rate_records: List[RawHeartRatePayload] = []
resting_hr_records: List[RawRestingHeartRatePayload] = []
sleep_sessions: List[RawSleepSessionPayload] = []
distance_records: List[RawDistancePayload] = []
calories_records: List[RawCaloriesPayload] = []
spo2_records: List[RawOxygenSaturationPayload] = []
weight_records: List[RawWeightPayload] = []
height_records: List[RawHeightPayload] = []
body_fat_records: List[RawBodyFatPayload] = []
exercise_sessions: List[RawExerciseSessionPayload] = []
nutrition_records: List[RawNutritionPayload] = []
hydration_records: List[RawHydrationPayload] = []
class RawSyncResponse(BaseModel):
"""Response for raw data sync"""
status: str
sync_id: int
records_synced: dict # Count per record type
message: str
# ==================== OPTIMIZED HELPER FUNCTIONS ====================
def get_existing_record_ids(db: Session, model, user_id: int, record_ids: List[str]) -> Set[str]:
"""
Batch query to find which record_ids already exist for the user.
Returns a set of existing record_ids.
"""
if not record_ids:
return set()
# Process in batches to avoid query limits
batch_size = 500
existing_ids = set()
for i in range(0, len(record_ids), batch_size):
batch = record_ids[i:i + batch_size]
results = db.query(model.record_id).filter(
model.user_id == user_id,
model.record_id.in_(batch)
).all()
existing_ids.update(r[0] for r in results)
return existing_ids
def batch_insert_simple_records(db: Session, model, user_id: int, records: list,
sync_id: int, existing_ids: Set[str]) -> int:
"""
Batch insert records, skipping those that already exist.
Also deduplicates records within the payload itself.
Returns count of new records inserted.
"""
new_records = []
now = datetime.utcnow()
seen_ids = set() # Track record_ids we've already processed in this batch
for record in records:
# Skip if already exists in database
if record.record_id in existing_ids:
continue
# Skip if we've already seen this record_id in this payload (dedupe)
if record.record_id in seen_ids:
continue
seen_ids.add(record.record_id)
data = record.model_dump()
data['user_id'] = user_id
data['sync_id'] = sync_id
data['synced_at'] = now
new_records.append(model(**data))
if new_records:
db.bulk_save_objects(new_records)
return len(new_records)
# ==================== ENDPOINTS ====================
@router.post("/sync-raw", response_model=RawSyncResponse, status_code=status.HTTP_200_OK)
async def receive_raw_health_data(
request: RawDataSyncRequest,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Receive raw health records from Android companion app.
OPTIMIZED: Uses batch queries and bulk inserts for much better performance.
"""
start_time = time.time()
# Count total records (excluding HR samples for now)
total_records = (
len(request.steps_records) +
len(request.heart_rate_records) +
len(request.resting_hr_records) +
len(request.sleep_sessions) +
len(request.distance_records) +
len(request.calories_records) +
len(request.spo2_records) +
len(request.weight_records) +
len(request.height_records) +
len(request.body_fat_records) +
len(request.exercise_sessions) +
len(request.nutrition_records) +
len(request.hydration_records)
)
# Count HR samples
total_hr_samples = sum(len(hr.samples) for hr in request.heart_rate_records)
logger.info(f"=== Raw Data Sync from user {current_user.username} ===")
logger.info(f"Device: {request.device_id}, Records: {total_records}, HR Samples: {total_hr_samples}")
# Create sync log
sync_log = HealthConnectSyncLog(
user_id=current_user.id,
device_id=request.device_id,
records_pushed=total_records,
status="processing"
)
db.add(sync_log)
db.flush()
# Track counts
counts = {
"steps": 0,
"heart_rate": 0,
"heart_rate_samples": 0,
"resting_hr": 0,
"sleep_sessions": 0,
"sleep_stages": 0,
"distance": 0,
"calories": 0,
"spo2": 0,
"weight": 0,
"height": 0,
"body_fat": 0,
"exercise": 0,
"nutrition": 0,
"hydration": 0,
}
try:
now = datetime.utcnow()
# === STEPS ===
if request.steps_records:
step_ids = [r.record_id for r in request.steps_records]
existing_step_ids = get_existing_record_ids(db, HCRawSteps, current_user.id, step_ids)
counts["steps"] = batch_insert_simple_records(
db, HCRawSteps, current_user.id, request.steps_records,
sync_log.id, existing_step_ids
)
logger.info(f"Steps: {counts['steps']} new of {len(request.steps_records)} total")
# === DISTANCE ===
if request.distance_records:
dist_ids = [r.record_id for r in request.distance_records]
existing_dist_ids = get_existing_record_ids(db, HCRawDistance, current_user.id, dist_ids)
counts["distance"] = batch_insert_simple_records(
db, HCRawDistance, current_user.id, request.distance_records,
sync_log.id, existing_dist_ids
)
logger.info(f"Distance: {counts['distance']} new of {len(request.distance_records)} total")
# === CALORIES ===
if request.calories_records:
cal_ids = [r.record_id for r in request.calories_records]
existing_cal_ids = get_existing_record_ids(db, HCRawCalories, current_user.id, cal_ids)
counts["calories"] = batch_insert_simple_records(
db, HCRawCalories, current_user.id, request.calories_records,
sync_log.id, existing_cal_ids
)
logger.info(f"Calories: {counts['calories']} new of {len(request.calories_records)} total")
# === RESTING HR ===
if request.resting_hr_records:
rhr_ids = [r.record_id for r in request.resting_hr_records]
existing_rhr_ids = get_existing_record_ids(db, HCRawRestingHeartRate, current_user.id, rhr_ids)
counts["resting_hr"] = batch_insert_simple_records(
db, HCRawRestingHeartRate, current_user.id, request.resting_hr_records,
sync_log.id, existing_rhr_ids
)
logger.info(f"Resting HR: {counts['resting_hr']} new of {len(request.resting_hr_records)} total")
# === SpO2 ===
if request.spo2_records:
spo2_ids = [r.record_id for r in request.spo2_records]
existing_spo2_ids = get_existing_record_ids(db, HCRawOxygenSaturation, current_user.id, spo2_ids)
counts["spo2"] = batch_insert_simple_records(
db, HCRawOxygenSaturation, current_user.id, request.spo2_records,
sync_log.id, existing_spo2_ids
)
logger.info(f"SpO2: {counts['spo2']} new of {len(request.spo2_records)} total")
# === WEIGHT ===
if request.weight_records:
weight_ids = [r.record_id for r in request.weight_records]
existing_weight_ids = get_existing_record_ids(db, HCRawWeight, current_user.id, weight_ids)
counts["weight"] = batch_insert_simple_records(
db, HCRawWeight, current_user.id, request.weight_records,
sync_log.id, existing_weight_ids
)
logger.info(f"Weight: {counts['weight']} new of {len(request.weight_records)} total")
# === HEIGHT ===
if request.height_records:
height_ids = [r.record_id for r in request.height_records]
existing_height_ids = get_existing_record_ids(db, HCRawHeight, current_user.id, height_ids)
counts["height"] = batch_insert_simple_records(
db, HCRawHeight, current_user.id, request.height_records,
sync_log.id, existing_height_ids
)
logger.info(f"Height: {counts['height']} new of {len(request.height_records)} total")
# === BODY FAT ===
if request.body_fat_records:
bf_ids = [r.record_id for r in request.body_fat_records]
existing_bf_ids = get_existing_record_ids(db, HCRawBodyFat, current_user.id, bf_ids)
counts["body_fat"] = batch_insert_simple_records(
db, HCRawBodyFat, current_user.id, request.body_fat_records,
sync_log.id, existing_bf_ids
)
logger.info(f"Body Fat: {counts['body_fat']} new of {len(request.body_fat_records)} total")
# === NUTRITION ===
if request.nutrition_records:
nut_ids = [r.record_id for r in request.nutrition_records]
existing_nut_ids = get_existing_record_ids(db, HCRawNutrition, current_user.id, nut_ids)
counts["nutrition"] = batch_insert_simple_records(
db, HCRawNutrition, current_user.id, request.nutrition_records,
sync_log.id, existing_nut_ids
)
logger.info(f"Nutrition: {counts['nutrition']} new of {len(request.nutrition_records)} total")
# === HYDRATION ===
if request.hydration_records:
hyd_ids = [r.record_id for r in request.hydration_records]
existing_hyd_ids = get_existing_record_ids(db, HCRawHydration, current_user.id, hyd_ids)
counts["hydration"] = batch_insert_simple_records(
db, HCRawHydration, current_user.id, request.hydration_records,
sync_log.id, existing_hyd_ids
)
logger.info(f"Hydration: {counts['hydration']} new of {len(request.hydration_records)} total")
# === EXERCISE SESSIONS ===
if request.exercise_sessions:
ex_ids = [r.record_id for r in request.exercise_sessions]
existing_ex_ids = get_existing_record_ids(db, HCRawExerciseSession, current_user.id, ex_ids)
counts["exercise"] = batch_insert_simple_records(
db, HCRawExerciseSession, current_user.id, request.exercise_sessions,
sync_log.id, existing_ex_ids
)
logger.info(f"Exercise: {counts['exercise']} new of {len(request.exercise_sessions)} total")
# === HEART RATE (with samples - needs special handling) ===
if request.heart_rate_records:
hr_ids = [r.record_id for r in request.heart_rate_records]
existing_hr_ids = get_existing_record_ids(db, HCRawHeartRate, current_user.id, hr_ids)
# Insert new HR records
new_hr_records = []
hr_records_for_samples = [] # Track which records need samples
for record in request.heart_rate_records:
if record.record_id in existing_hr_ids:
continue
data = record.model_dump(exclude={"samples"})
data['user_id'] = current_user.id
data['sync_id'] = sync_log.id
data['synced_at'] = now
hr_obj = HCRawHeartRate(**data)
new_hr_records.append(hr_obj)
hr_records_for_samples.append((hr_obj, record.samples))
if new_hr_records:
db.bulk_save_objects(new_hr_records, return_defaults=True)
db.flush() # Get IDs
counts["heart_rate"] = len(new_hr_records)
# Now insert samples in batches
all_samples = []
for hr_obj, samples in hr_records_for_samples:
for sample in samples:
all_samples.append(HCRawHeartRateSample(
record_id=hr_obj.id,
time=sample.time,
bpm=sample.bpm
))
if all_samples:
# Insert in batches of 1000
batch_size = 1000
for i in range(0, len(all_samples), batch_size):
batch = all_samples[i:i + batch_size]
db.bulk_save_objects(batch)
counts["heart_rate_samples"] = len(all_samples)
logger.info(f"Heart Rate: {counts['heart_rate']} records, {counts['heart_rate_samples']} samples")
# === SLEEP SESSIONS (with stages) ===
if request.sleep_sessions:
sleep_ids = [r.record_id for r in request.sleep_sessions]
existing_sleep_ids = get_existing_record_ids(db, HCRawSleepSession, current_user.id, sleep_ids)
new_sleep_records = []
sleep_records_for_stages = []
for record in request.sleep_sessions:
if record.record_id in existing_sleep_ids:
continue
data = record.model_dump(exclude={"stages"})
data['user_id'] = current_user.id
data['sync_id'] = sync_log.id
data['synced_at'] = now
sleep_obj = HCRawSleepSession(**data)
new_sleep_records.append(sleep_obj)
sleep_records_for_stages.append((sleep_obj, record.stages))
if new_sleep_records:
db.bulk_save_objects(new_sleep_records, return_defaults=True)
db.flush()
counts["sleep_sessions"] = len(new_sleep_records)
# Insert stages
all_stages = []
for sleep_obj, stages in sleep_records_for_stages:
for stage in stages:
stage_name = stage.stage_name or SLEEP_STAGE_TYPES.get(stage.stage_type, "unknown")
all_stages.append(HCRawSleepStage(
session_id=sleep_obj.id,
start_time=stage.start_time,
end_time=stage.end_time,
stage_type=stage.stage_type,
stage_name=stage_name
))
if all_stages:
db.bulk_save_objects(all_stages)
counts["sleep_stages"] = len(all_stages)
logger.info(f"Sleep: {counts['sleep_sessions']} sessions, {counts['sleep_stages']} stages")
# Update sync log
sync_log.status = "success"
sync_log.records_pushed = sum(counts.values())
db.commit()
elapsed = time.time() - start_time
logger.info(f"=== Raw Sync Complete in {elapsed:.2f}s ===")
logger.info(f"Records synced: {counts}")
return RawSyncResponse(
status="success",
sync_id=sync_log.id,
records_synced=counts,
message=f"Successfully synced {sum(counts.values())} raw records in {elapsed:.2f}s"
)
except Exception as e:
logger.error(f"Raw sync failed: {str(e)}", exc_info=True)
db.rollback()
sync_log.status = "error"
sync_log.error_message = str(e)[:500] # Limit error message length
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Raw sync failed: {str(e)}"
)
@router.get("/raw/steps", status_code=status.HTTP_200_OK)
async def get_raw_steps(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = 1000,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get raw step records for a date range."""
from datetime import timedelta
if not end_date:
end_date = datetime.utcnow()
if not start_date:
start_date = end_date - timedelta(days=7)
records = db.query(HCRawSteps).filter(
HCRawSteps.user_id == current_user.id,
HCRawSteps.start_time >= start_date,
HCRawSteps.start_time <= end_date
).order_by(HCRawSteps.start_time.desc()).limit(limit).all()
return [
{
"id": r.id,
"record_id": r.record_id,
"data_source": r.data_source,
"data_source_name": r.data_source_name,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
"count": r.count
}
for r in records
]
@router.get("/raw/heart-rate", status_code=status.HTTP_200_OK)
async def get_raw_heart_rate(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
include_samples: bool = False,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get raw heart rate records for a date range."""
from datetime import timedelta
if not end_date:
end_date = datetime.utcnow()
if not start_date:
start_date = end_date - timedelta(days=1)
records = db.query(HCRawHeartRate).filter(
HCRawHeartRate.user_id == current_user.id,
HCRawHeartRate.start_time >= start_date,
HCRawHeartRate.start_time <= end_date
).order_by(HCRawHeartRate.start_time.desc()).limit(limit).all()
result = []
for r in records:
record_dict = {
"id": r.id,
"record_id": r.record_id,
"data_source": r.data_source,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
}
if include_samples:
record_dict["samples"] = [
{"time": s.time.isoformat(), "bpm": s.bpm}
for s in r.samples
]
else:
record_dict["sample_count"] = len(r.samples)
result.append(record_dict)
return result
@router.get("/raw/stats", status_code=status.HTTP_200_OK)
async def get_raw_data_stats(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get statistics about raw data stored for current user."""
from sqlalchemy import func
stats = {
"steps": db.query(func.count(HCRawSteps.id)).filter(
HCRawSteps.user_id == current_user.id
).scalar(),
"heart_rate": db.query(func.count(HCRawHeartRate.id)).filter(
HCRawHeartRate.user_id == current_user.id
).scalar(),
"heart_rate_samples": db.query(func.count(HCRawHeartRateSample.id)).join(
HCRawHeartRate
).filter(
HCRawHeartRate.user_id == current_user.id
).scalar(),
"resting_hr": db.query(func.count(HCRawRestingHeartRate.id)).filter(
HCRawRestingHeartRate.user_id == current_user.id
).scalar(),
"sleep_sessions": db.query(func.count(HCRawSleepSession.id)).filter(
HCRawSleepSession.user_id == current_user.id
).scalar(),
"distance": db.query(func.count(HCRawDistance.id)).filter(
HCRawDistance.user_id == current_user.id
).scalar(),
"calories": db.query(func.count(HCRawCalories.id)).filter(
HCRawCalories.user_id == current_user.id
).scalar(),
"spo2": db.query(func.count(HCRawOxygenSaturation.id)).filter(
HCRawOxygenSaturation.user_id == current_user.id
).scalar(),
"weight": db.query(func.count(HCRawWeight.id)).filter(
HCRawWeight.user_id == current_user.id
).scalar(),
"exercise": db.query(func.count(HCRawExerciseSession.id)).filter(
HCRawExerciseSession.user_id == current_user.id
).scalar(),
}
# Get date range
oldest_step = db.query(func.min(HCRawSteps.start_time)).filter(
HCRawSteps.user_id == current_user.id
).scalar()
newest_step = db.query(func.max(HCRawSteps.start_time)).filter(
HCRawSteps.user_id == current_user.id
).scalar()
return {
"record_counts": stats,
"total_records": sum(stats.values()),
"date_range": {
"oldest": oldest_step.isoformat() if oldest_step else None,
"newest": newest_step.isoformat() if newest_step else None
}
}
+63
View File
@@ -0,0 +1,63 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from .core.config import settings
from .database import get_db
from .models.user import User
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
"""Get the current authenticated user from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: Optional[str] = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
return user
+1
View File
@@ -0,0 +1 @@
"""Core module for configuration and security."""
+62
View File
@@ -0,0 +1,62 @@
"""Configuration module using pydantic_settings for environment-based configuration."""
from secrets import token_urlsafe
from typing import List, Union
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Security
SECRET_KEY: str = "dev_secret_key_fixed_for_persistence_change_in_prod"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_MINUTES: int = 43200 # 30 days
# Database
DATABASE_URL: str = "sqlite:///./data/habitforge.db"
# CORS
CORS_ORIGINS: Union[List[str], str] = ["http://localhost:8000", "http://localhost:3000"]
CORS_ALLOW_CREDENTIALS: bool = True
CORS_ALLOW_METHODS: Union[List[str], str] = ["*"]
CORS_ALLOW_HEADERS: Union[List[str], str] = ["*"]
# Google OAuth
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/api/google/callback"
# Rate Limiting
RATE_LIMIT_ENABLED: bool = True
RATE_LIMIT_TIMES: int = 60
RATE_LIMIT_SECONDS: int = 60
# Application
APP_NAME: str = "HabitForge API"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
# Health Data Provider Selection
# Options: "health_connect"
HEALTH_PROVIDER: str = "health_connect"
@field_validator("CORS_ORIGINS", "CORS_ALLOW_METHODS", "CORS_ALLOW_HEADERS", mode="before")
@classmethod
def assemble_cors_list(cls, v: Union[str, List[str]]) -> List[str]:
"""Convert a comma-separated string into a list of strings if necessary."""
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",") if i.strip()]
return v
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore"
)
# Global settings instance
settings = Settings()
+29
View File
@@ -0,0 +1,29 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from .core.config import settings
# Ensure data directory exists for SQLite
if settings.DATABASE_URL.startswith("sqlite"):
os.makedirs("./data", exist_ok=True)
# Create engine with appropriate settings based on database type
if settings.DATABASE_URL.startswith("sqlite"):
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False}
)
else:
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+16
View File
@@ -0,0 +1,16 @@
"""Integrations module for external APIs."""
from backend.integrations.health_provider import (
HealthDataProvider,
DataSource,
HealthProviderFactory
)
# Import providers to register them with the factory
# from backend.integrations import google_fit_provider
from backend.integrations import health_connect_provider
__all__ = [
"HealthDataProvider",
"DataSource",
"HealthProviderFactory",
]
@@ -0,0 +1,162 @@
"""
Health Connect Provider - Implementation for Health Connect API.
This module provides the Health Connect implementation that reads data
pushed from the Android companion app via the /api/health-connect/sync endpoint.
"""
import datetime
from typing import Dict, Any, List, Optional
from backend.integrations.health_provider import (
HealthDataProvider,
DataSource,
HealthProviderFactory
)
class HealthConnectProvider(HealthDataProvider):
"""
Health Connect implementation of HealthDataProvider.
This provider reads data that has been pushed from an Android companion app
via the /api/health-connect/sync endpoint and stored in the database.
"""
@property
def source(self) -> DataSource:
return DataSource.HEALTH_CONNECT
def get_auth_url(self) -> str:
"""
Health Connect doesn't use OAuth - permissions are granted on-device.
Returns a URL to download the Android companion app.
"""
# TODO: Replace with actual Play Store URL when app is published
return "https://play.google.com/store/apps/details?id=com.habitforge.companion"
async def fetch_daily_metrics(
self,
access_token: Optional[str], # Not used for Health Connect, but required by interface
days: int = 7,
timezone: str = "UTC",
user_id: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Fetch daily metrics from Health Connect data store.
Reads data that was pushed from the Android companion app.
"""
if not user_id:
print("[HealthConnect] ERROR: user_id is required")
return []
print(f"[HealthConnect] Fetching daily metrics for user {user_id}, last {days} days")
from backend.database import SessionLocal
from backend.models.health_connect import HealthConnectDailyMetrics
from datetime import timedelta, date as date_type
db = SessionLocal()
try:
start_date = date_type.today() - timedelta(days=days)
metrics = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == user_id,
HealthConnectDailyMetrics.date >= start_date
).order_by(HealthConnectDailyMetrics.date.desc()).all()
if not metrics:
print(f"[HealthConnect] No data found. Ensure Android app has synced data.")
return []
print(f"[HealthConnect] Found {len(metrics)} days of data")
# Convert to standard format
return [
{
"date": m.date,
"step_count": m.step_count or 0,
"calories_burned": m.calories_burned or 0.0,
"distance_meters": m.distance_meters or 0.0,
"sleep_duration_minutes": m.sleep_duration_minutes or 0,
"deep_sleep_minutes": m.deep_sleep_minutes or 0,
"light_sleep_minutes": m.light_sleep_minutes or 0,
"rem_sleep_minutes": m.rem_sleep_minutes or 0,
"awake_duration_minutes": m.awake_duration_minutes or 0,
"avg_heart_rate": m.avg_heart_rate or 0,
"min_heart_rate": m.min_heart_rate or 0,
"max_heart_rate": m.max_heart_rate or 0,
"avg_spo2": m.avg_spo2 or 0.0,
"pai_score": 0.0, # Calculate if needed
"weight": m.weight,
"resting_heart_rate": m.resting_heart_rate,
"hrv": None
}
for m in metrics
]
finally:
db.close()
async def fetch_workouts(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch exercise sessions from Health Connect data store.
"""
# Note: We need user_id but the interface doesn't pass it for workouts
# This is a limitation we'll need to address in the interface
print(f"[HealthConnect] fetch_workouts called")
from backend.database import SessionLocal
from backend.models.health_connect import HealthConnectExerciseSession
from datetime import timedelta
db = SessionLocal()
try:
start_date = datetime.datetime.now() - timedelta(days=days)
# Query all sessions (we can't filter by user without user_id)
# In practice, this would be called with user context
sessions = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.start_time >= start_date
).order_by(HealthConnectExerciseSession.start_time.desc()).all()
return [
{
"start_time": s.start_time,
"end_time": s.end_time,
"activity_type": s.exercise_type_name,
"activity_type_id": s.exercise_type,
"duration_seconds": s.duration_seconds,
"distance_meters": s.distance_meters or 0.0,
"calories": s.calories_burned or 0.0,
"avg_hr": s.avg_heart_rate,
"max_hr": s.max_heart_rate,
"min_hr": s.min_heart_rate
}
for s in sessions
]
finally:
db.close()
async def fetch_sleep_sessions(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch sleep sessions from Health Connect data store.
Note: Health Connect stores sleep as part of daily metrics.
This returns an empty list as sleep is handled in fetch_daily_metrics.
"""
print(f"[HealthConnect] fetch_sleep_sessions called")
# Sleep data is included in daily metrics for Health Connect
return []
# Register the provider with the factory
HealthProviderFactory.register("health_connect", HealthConnectProvider)

Some files were not shown because too many files have changed in this diff Show More