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

This commit is contained in:
2026-07-31 08:58:53 -04:00
commit c7fce339c2
124 changed files with 17558 additions and 0 deletions
+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
+87
View File
@@ -0,0 +1,87 @@
# 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
+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.models import User, Challenge, Tracking, DailyHealthMetrics, WorkoutSession
# 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 ###
+203
View File
@@ -0,0 +1,203 @@
# HabitForge Android Companion App
Application Android permettant de synchroniser les données de Health Connect avec votre serveur HabitForge.
## 🆕 Nouvelles Fonctionnalités
### Suivi des Synchronisations
- **Historique complet** : Visualisez l'historique de toutes les synchronisations
- **Statistiques** : Nombre de syncs réussies/échouées, métriques totales synchronisées
- **Détails** : Chaque sync affiche le temps de traitement, les données transférées et les erreurs
### Connectivité Serveur
- **Indicateur en temps réel** : Voyez immédiatement si le serveur est en ligne
- **Test de connexion** : Bouton dédié pour tester la connexion et l'authentification
- **Temps de réponse** : Affichage du temps de réponse du serveur
### Validation des Données
- **Vérification de compatibilité** : Validez que vos données Health Connect sont compatibles avec le schéma HabitForge
- **Aperçu des données** : Prévisualisation des données avant synchronisation
- **Diagnostic** : Identification des problèmes potentiels de mapping
### Configuration Avancée
- **Période de sync configurable** : Choisissez la fréquence de synchronisation automatique
- Toutes les heures
- Toutes les 3 heures
- Toutes les 6 heures (par défaut)
- Toutes les 12 heures
- Une fois par jour
## Prérequis
- Android Studio (version 2024.2.1 ou plus récente)
- JDK 17
- Un appareil Android avec Android 9+ (API 28+)
- Health Connect installé sur l'appareil
## Structure du projet
```
android-companion/
├── app/
│ ├── src/main/
│ │ ├── java/com/habitforge/companion/
│ │ │ ├── HabitForgeApp.kt # Application class
│ │ │ ├── MainActivity.kt # UI principale
│ │ │ ├── HealthConnectManager.kt # Lecture des données Health Connect
│ │ │ ├── SyncService.kt # Service de sync en arrière-plan
│ │ │ ├── SyncHistoryManager.kt # Gestion de l'historique des syncs
│ │ │ ├── ServerConnectionManager.kt # Test de connectivité serveur
│ │ │ ├── DataCompatibilityValidator.kt # Validation schéma de données
│ │ │ ├── HabitForgeApi.kt # API Retrofit pour le backend
│ │ │ └── PrivacyPolicyActivity.kt # Page de politique de confidentialité
│ │ ├── res/
│ │ │ ├── layout/ # Layouts XML
│ │ │ ├── values/ # Couleurs, strings, thèmes
│ │ │ └── drawable/ # Icônes et indicateurs de status
│ │ └── AndroidManifest.xml # Permissions Health Connect
│ └── build.gradle.kts # Dépendances
├── build.gradle.kts # Configuration root
├── settings.gradle.kts # Settings Gradle
└── gradle.properties # Propriétés Gradle
```
## Données synchronisées
L'application lit les données suivantes depuis Health Connect :
### Métriques quotidiennes
- Pas (Steps)
- Distance parcourue
- Calories brûlées
- Fréquence cardiaque (moyenne, min, max, repos)
- Sommeil (durée, phases : profond, léger, REM, éveillé)
- Poids
- SpO2
### Sessions d'exercice
- Type d'exercice (Running, Walking, Cycling, Swimming, Weight Training, etc.)
- Durée
- Heure de début/fin
- Notes
## Compilation
### Option 1 : Via Android Studio
1. Ouvrir Android Studio
2. `File` > `Open` > Sélectionner le dossier `android-companion`
3. Attendre que Gradle synchronise
4. `Build` > `Build Bundle(s) / APK(s)` > `Build APK(s)`
### Option 2 : Via ligne de commande
```powershell
# Se placer dans le dossier du projet
cd c:\dev\git\python\HabitForge\android-companion
# Compiler en mode debug
.\gradlew assembleDebug
# Le fichier APK sera dans :
# app\build\outputs\apk\debug\app-debug.apk
# Compiler en mode release (nécessite une signature)
.\gradlew assembleRelease
```
## Installation sur téléphone
### Via ADB
```powershell
adb install app\build\outputs\apk\debug\app-debug.apk
```
### Via Android Studio
- Connecter le téléphone en USB (Mode débogage activé)
- Cliquer sur "Run" (bouton vert)
## Configuration
1. **Installer Health Connect** sur le téléphone (Play Store)
2. **Ouvrir HabitForge Companion**
3. **Accorder les permissions** quand demandé
4. **Se connecter à votre compte** :
- Cliquez sur "Login"
- Entrez l'URL du serveur (ex: `http://192.168.x.x:8000`)
- Utilisez les **mêmes identifiants** que l'interface web HabitForge
5. **Tester la connexion** : Utilisez le bouton "Test Connection" pour vérifier
6. **Valider les données** : Cliquez sur "Validate Data Schema"
7. **Configurer la fréquence** : Choisissez l'intervalle de sync souhaité
8. **Synchroniser** manuellement ou laisser l'auto-sync
## Interface Utilisateur
### Cartes de l'écran principal
| Carte | Description |
|-------|-------------|
| **Health Connect** | Statut de Health Connect, bouton pour l'ouvrir |
| **Permissions** | Permissions accordées/requises, bouton pour les demander |
| **Server** | Configuration serveur, indicateur de disponibilité, test de connexion |
| **Sync Settings** | Fréquence de sync, dernière sync, bouton Sync Now |
| **Data Compatibility** | Validation du schéma, aperçu des données |
| **Sync History** | Historique récent, statistiques, bouton historique complet |
### Indicateurs de Status Serveur
| Indicateur | Signification |
|------------|---------------|
| 🟢 Online | Serveur accessible et connecté |
| 🟠 Auth needed | Serveur accessible mais non connecté |
| 🟠 Timeout | Serveur lent à répondre |
| 🔴 Offline | Serveur inaccessible |
| ⚫ Unknown | Statut non vérifié |
## Dépannage
### "Health Connect not available"
- L'appareil doit être sous Android 9+
- Installer Health Connect depuis le Play Store
### "Permissions not granted"
- Réouvrir l'app et cliquer sur "Grant Permissions"
- S'assurer que Health Connect a des données (d'autres apps comme Google Fit)
### "Login failed" / "Invalid username or password"
- Vérifier que le serveur HabitForge est accessible (utilisez "Test Connection")
- Vérifier que les identifiants sont corrects (mêmes que l'interface web)
- Consulter les logs via `adb logcat | grep HabitForge`
### "Sync failed"
- Vérifier que vous êtes bien connecté
- Vérifier l'historique des syncs pour voir le message d'erreur
### "Connection timeout"
- Vérifier que le serveur est démarré
- Vérifier la connexion réseau
- Si sur le même réseau local, utiliser l'IP locale (ex: 192.168.x.x)
### "Data validation issues"
- Cliquez sur "Validate Data Schema" pour diagnostiquer
- Vérifiez que Health Connect contient des données récentes
- Accordez toutes les permissions demandées
## API Backend requise
L'application nécessite les endpoints suivants sur votre serveur HabitForge :
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/api/health` | GET | Health check (retourne `{"status": "healthy"}`) |
| `/api/auth/token` | POST | Authentification (OAuth2 form: username, password) |
| `/api/auth/me` | GET | Récupérer les infos utilisateur connecté |
| `/api/health-connect/sync` | POST | Réception des données synchronisées |
## Sécurité
- L'authentification utilise les mêmes identifiants que l'interface web
- Les tokens JWT sont stockés dans SharedPreferences (mode privé)
- Toutes les connexions utilisent HTTPS en production
- Les données ne quittent jamais l'appareil sauf vers votre serveur configuré
- L'historique des syncs est stocké localement uniquement
- Déconnexion possible via le bouton "Logout"
+115
View File
@@ -0,0 +1,115 @@
import java.util.Properties
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
fun getBuildNumber(): Int {
val versionFile = file("version.properties")
val properties = Properties()
if (versionFile.exists()) {
properties.load(FileInputStream(versionFile))
}
val buildNumber = (properties.getProperty("build.number") ?: "0").toInt() + 1
properties.setProperty("build.number", buildNumber.toString())
properties.store(FileOutputStream(versionFile), "Auto-incremented Build Number")
return buildNumber
}
android {
namespace = "com.habitforge.companion"
compileSdk = 35
defaultConfig {
applicationId = "com.habitforge.companion"
minSdk = 28 // Health Connect requires Android 9+
targetSdk = 34
val buildNum = getBuildNumber()
versionCode = buildNum
versionName = "1.0.0.$buildNum"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Backend URL - change this to your server address
buildConfigField("String", "BACKEND_URL", "\"http://10.0.2.2:8000\"") // 10.0.2.2 = localhost from emulator
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// For production, change this to your actual server URL
buildConfigField("String", "BACKEND_URL", "\"https://your-habitforge-server.com\"")
}
debug {
buildConfigField("String", "BACKEND_URL", "\"http://10.0.2.2:8000\"")
}
}
applicationVariants.all {
val variant = this
outputs.all {
(this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl)?.outputFileName = "HabitForge-companion-v${variant.versionName}.apk"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
buildConfig = true
}
}
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.activity:activity-ktx:1.8.2")
// Health Connect SDK
implementation("androidx.health.connect:connect-client:1.1.0-alpha10")
// WorkManager for background sync
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
+11
View File
@@ -0,0 +1,11 @@
# Add project specific ProGuard rules here.
# Keep Retrofit and Gson
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.habitforge.companion.** { *; }
-keep class retrofit2.** { *; }
-keep class com.google.gson.** { *; }
# Keep Health Connect classes
-keep class androidx.health.connect.** { *; }
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for syncing with backend -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Foreground service permission for reliable syncing -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Health Connect Permissions -->
<!-- Activity Data -->
<uses-permission android:name="android.permission.health.READ_STEPS" />
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<!-- Heart & Vitals -->
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE_VARIABILITY" />
<uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION" />
<uses-permission android:name="android.permission.health.READ_RESPIRATORY_RATE" />
<uses-permission android:name="android.permission.health.READ_BLOOD_PRESSURE" />
<!-- Body Measurements -->
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
<uses-permission android:name="android.permission.health.READ_HEIGHT" />
<uses-permission android:name="android.permission.health.READ_BODY_FAT" />
<uses-permission android:name="android.permission.health.READ_BODY_WATER_MASS" />
<uses-permission android:name="android.permission.health.READ_LEAN_BODY_MASS" />
<uses-permission android:name="android.permission.health.READ_BONE_MASS" />
<uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
<!-- Sleep -->
<uses-permission android:name="android.permission.health.READ_SLEEP" />
<!-- Exercise / Workouts -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.READ_POWER" />
<uses-permission android:name="android.permission.health.READ_SPEED" />
<!-- Nutrition (optional) -->
<uses-permission android:name="android.permission.health.READ_NUTRITION" />
<uses-permission android:name="android.permission.health.READ_HYDRATION" />
<!-- Historical Data Access (for data older than 30 days) -->
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY" />
<application
android:name=".HabitForgeApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HabitForgeCompanion"
android:usesCleartextTraffic="true"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.HabitForgeCompanion">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Receive Health Connect permission results -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- Privacy Policy Activity (Required by Health Connect) -->
<activity
android:name=".PrivacyPolicyActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity>
<!-- WorkManager initializer -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup" />
</provider>
</application>
<!-- Declare that this app uses Health Connect -->
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>
@@ -0,0 +1,236 @@
package com.habitforge.companion
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Manages user authentication and session state.
* Handles login, logout, and secure token storage.
*/
class AuthManager(private val context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
companion object {
private const val PREFS_NAME = "habitforge_auth"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_USERNAME = "username"
private const val KEY_USER_ID = "user_id"
private const val KEY_SERVER_URL = "server_url"
private const val KEY_IS_LOGGED_IN = "is_logged_in"
// Default server URL (can be changed)
const val DEFAULT_SERVER_URL = "http://192.168.1.100:8000"
}
/**
* Check if user is currently logged in
*/
fun isLoggedIn(): Boolean {
return prefs.getBoolean(KEY_IS_LOGGED_IN, false) &&
!prefs.getString(KEY_ACCESS_TOKEN, null).isNullOrEmpty()
}
/**
* Get stored access token with Bearer prefix
*/
fun getAuthorizationHeader(): String? {
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
return if (token != null) "Bearer $token" else null
}
/**
* Get raw access token (without Bearer prefix)
*/
fun getAccessToken(): String? {
return prefs.getString(KEY_ACCESS_TOKEN, null)
}
/**
* Get current username
*/
fun getUsername(): String? {
return prefs.getString(KEY_USERNAME, null)
}
/**
* Get current user ID
*/
fun getUserId(): Int {
return prefs.getInt(KEY_USER_ID, -1)
}
/**
* Get configured server URL
*/
fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
}
/**
* Set server URL
*/
fun setServerUrl(url: String) {
prefs.edit().putString(KEY_SERVER_URL, url).apply()
}
/**
* Attempt to login with username and password
* @return LoginResult with success/failure and message
*/
suspend fun login(serverUrl: String, username: String, password: String): LoginResult {
return withContext(Dispatchers.IO) {
try {
// Ensure URL has proper format
val formattedUrl = formatServerUrl(serverUrl)
// Create API client
val api = HabitForgeApi.create(formattedUrl)
// Attempt login
val response = api.login(username, password)
if (response.isSuccessful && response.body() != null) {
val loginResponse = response.body()!!
val token = loginResponse.access_token
// Verify token by getting user info
val userResponse = api.getCurrentUser("Bearer $token")
if (userResponse.isSuccessful && userResponse.body() != null) {
val user = userResponse.body()!!
// Save credentials
saveSession(
serverUrl = formattedUrl,
token = token,
username = user.username,
userId = user.id
)
LoginResult(
success = true,
message = "Welcome back, ${user.username}!",
username = user.username
)
} else {
LoginResult(
success = false,
message = "Login successful but failed to get user info"
)
}
} else {
val errorBody = response.errorBody()?.string()
val errorMessage = when (response.code()) {
401 -> "Invalid username or password"
404 -> "Server not found. Check URL."
500 -> "Server error. Please try again."
else -> errorBody ?: "Login failed (${response.code()})"
}
LoginResult(success = false, message = errorMessage)
}
} catch (e: java.net.UnknownHostException) {
LoginResult(success = false, message = "Cannot reach server. Check URL and network.")
} catch (e: java.net.ConnectException) {
LoginResult(success = false, message = "Connection refused. Is the server running?")
} catch (e: java.net.SocketTimeoutException) {
LoginResult(success = false, message = "Connection timeout. Server may be slow.")
} catch (e: Exception) {
LoginResult(success = false, message = "Error: ${e.message}")
}
}
}
/**
* Save session data after successful login
*/
private fun saveSession(serverUrl: String, token: String, username: String, userId: Int) {
prefs.edit()
.putString(KEY_SERVER_URL, serverUrl)
.putString(KEY_ACCESS_TOKEN, token)
.putString(KEY_USERNAME, username)
.putInt(KEY_USER_ID, userId)
.putBoolean(KEY_IS_LOGGED_IN, true)
.apply()
// Also update the old prefs for backward compatibility with SyncService
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.edit()
.putString("server_url", serverUrl)
.putString("auth_token", token)
.apply()
}
/**
* Clear session (logout)
*/
fun logout() {
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.remove(KEY_USERNAME)
.remove(KEY_USER_ID)
.putBoolean(KEY_IS_LOGGED_IN, false)
.apply()
// Clear old prefs too
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.edit()
.remove("auth_token")
.apply()
}
/**
* Refresh token if needed (for future token refresh implementation)
*/
suspend fun refreshTokenIfNeeded(): Boolean {
// Future: implement token refresh
// For now, just verify current token is valid
val token = getAuthorizationHeader() ?: return false
val serverUrl = getServerUrl()
return withContext(Dispatchers.IO) {
try {
val api = HabitForgeApi.create(serverUrl)
val response = api.getCurrentUser(token)
response.isSuccessful
} catch (e: Exception) {
false
}
}
}
/**
* Format server URL to ensure it has proper protocol and no trailing slash
*/
private fun formatServerUrl(url: String): String {
var formatted = url.trim()
// Add protocol if missing
if (!formatted.startsWith("http://") && !formatted.startsWith("https://")) {
formatted = "http://$formatted"
}
// Remove trailing slash
if (formatted.endsWith("/")) {
formatted = formatted.dropLast(1)
}
// Ensure ends with proper format for Retrofit baseUrl
if (!formatted.endsWith("/")) {
formatted = "$formatted/"
}
return formatted
}
}
/**
* Result of a login attempt
*/
data class LoginResult(
val success: Boolean,
val message: String,
val username: String? = null
)
@@ -0,0 +1,354 @@
package com.habitforge.companion
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.util.Locale
/**
* Validates that Health Connect data can be properly mapped to HabitForge schema
*/
class DataCompatibilityValidator(
private val context: Context,
private val healthConnectManager: HealthConnectManager
) {
companion object {
private const val TAG = "DataCompatibilityValidator"
}
/**
* Run full compatibility validation
*/
suspend fun validateCompatibility(): ValidationResult = withContext(Dispatchers.IO) {
val issues = mutableListOf<ValidationIssue>()
val validations = mutableListOf<ValidationItem>()
Log.i(TAG, "Starting data compatibility validation...")
// Check Health Connect availability
val availability = healthConnectManager.isAvailable()
validations.add(
ValidationItem(
name = "Health Connect Availability",
passed = availability == HealthConnectAvailability.AVAILABLE,
message = when (availability) {
HealthConnectAvailability.AVAILABLE -> "Health Connect is available"
HealthConnectAvailability.NOT_INSTALLED -> "Health Connect is not installed"
HealthConnectAvailability.NOT_SUPPORTED -> "Health Connect is not supported on this device"
}
)
)
if (availability != HealthConnectAvailability.AVAILABLE) {
return@withContext ValidationResult(
isCompatible = false,
validations = validations,
issues = listOf(ValidationIssue(
severity = IssueSeverity.ERROR,
category = "Health Connect",
message = "Health Connect is not available"
)),
dataPreview = null
)
}
// Check permissions
val hasPermissions = healthConnectManager.hasAllPermissions()
val grantedPermissions = healthConnectManager.getGrantedPermissions()
val totalPermissions = HealthConnectManager.PERMISSIONS.size
validations.add(
ValidationItem(
name = "Permissions",
passed = hasPermissions,
message = "Granted: ${grantedPermissions.size}/$totalPermissions"
)
)
if (!hasPermissions) {
issues.add(ValidationIssue(
severity = IssueSeverity.WARNING,
category = "Permissions",
message = "Not all permissions granted. Some data types may not sync."
))
}
// Try to read sample data to validate schema mapping
try {
val endDate = LocalDate.now()
val startDate = endDate.minusDays(1)
// Test daily metrics reading
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
validations.add(
ValidationItem(
name = "Daily Metrics Schema",
passed = true,
message = "Successfully read ${metrics.size} day(s) of metrics"
)
)
// Validate each metric field
val fieldValidations = validateDailyMetricsFields(metrics)
validations.addAll(fieldValidations.validations)
issues.addAll(fieldValidations.issues)
// Test exercise sessions reading
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
validations.add(
ValidationItem(
name = "Exercise Sessions Schema",
passed = true,
message = "Successfully read ${exercises.size} exercise session(s)"
)
)
// Validate exercise fields
val exerciseValidations = validateExerciseFields(exercises)
validations.addAll(exerciseValidations.validations)
issues.addAll(exerciseValidations.issues)
// Build data preview
val dataPreview = buildDataPreview(metrics, exercises)
// Final compatibility check
val hasErrors = issues.any { it.severity == IssueSeverity.ERROR }
ValidationResult(
isCompatible = !hasErrors,
validations = validations,
issues = issues,
dataPreview = dataPreview
)
} catch (e: Exception) {
Log.e(TAG, "Validation failed", e)
issues.add(ValidationIssue(
severity = IssueSeverity.ERROR,
category = "Data Reading",
message = "Failed to read data: ${e.localizedMessage}"
))
ValidationResult(
isCompatible = false,
validations = validations,
issues = issues,
dataPreview = null
)
}
}
private fun validateDailyMetricsFields(metrics: List<DailyMetrics>): FieldValidationResult {
val validations = mutableListOf<ValidationItem>()
val issues = mutableListOf<ValidationIssue>()
if (metrics.isEmpty()) {
issues.add(ValidationIssue(
severity = IssueSeverity.INFO,
category = "Daily Metrics",
message = "No daily metrics data found for the test period"
))
return FieldValidationResult(validations, issues)
}
val sample = metrics.first()
// Steps validation
validations.add(ValidationItem(
name = "Steps Field",
passed = sample.steps >= 0,
message = "Value: ${sample.steps}"
))
// Distance validation
validations.add(ValidationItem(
name = "Distance Field",
passed = sample.distanceMeters >= 0,
message = "Value: ${String.format(Locale.US, "%.2f", sample.distanceMeters)} meters"
))
// Calories validation
validations.add(ValidationItem(
name = "Calories Field",
passed = sample.caloriesBurned >= 0,
message = "Value: ${sample.caloriesBurned} kcal"
))
// Heart rate validation (optional field)
val hrStatus = if (sample.avgHeartRate != null) {
"Value: ${sample.avgHeartRate} bpm"
} else {
"No data available (optional)"
}
validations.add(ValidationItem(
name = "Heart Rate Field",
passed = true,
message = hrStatus
))
// Sleep validation
validations.add(ValidationItem(
name = "Sleep Fields",
passed = sample.sleepDurationMinutes >= 0,
message = "Duration: ${sample.sleepDurationMinutes} min, Deep: ${sample.deepSleepMinutes} min"
))
// Weight validation (optional)
val weightStatus = if (sample.weight != null) {
"Value: ${String.format(Locale.US, "%.1f", sample.weight)} kg"
} else {
"No data available (optional)"
}
validations.add(ValidationItem(
name = "Weight Field",
passed = true,
message = weightStatus
))
// SpO2 validation (optional)
val spo2Status = if (sample.spo2 != null) {
"Value: ${String.format(Locale.US, "%.1f", sample.spo2)}%"
} else {
"No data available (optional)"
}
validations.add(ValidationItem(
name = "SpO2 Field",
passed = true,
message = spo2Status
))
return FieldValidationResult(validations, issues)
}
private fun validateExerciseFields(exercises: List<ExerciseData>): FieldValidationResult {
val validations = mutableListOf<ValidationItem>()
val issues = mutableListOf<ValidationIssue>()
if (exercises.isEmpty()) {
issues.add(ValidationIssue(
severity = IssueSeverity.INFO,
category = "Exercise Sessions",
message = "No exercise sessions found for the test period"
))
return FieldValidationResult(validations, issues)
}
val sample = exercises.first()
// Time fields validation
validations.add(ValidationItem(
name = "Exercise Time Fields",
passed = sample.startTime.isNotEmpty() && sample.endTime.isNotEmpty(),
message = "Start: ${sample.startTime.take(19)}"
))
// Exercise type validation
validations.add(ValidationItem(
name = "Exercise Type Field",
passed = sample.exerciseType >= 0,
message = "Type: ${sample.exerciseTypeName}"
))
// Duration validation
validations.add(ValidationItem(
name = "Duration Field",
passed = sample.durationMinutes > 0,
message = "Duration: ${sample.durationMinutes} minutes"
))
return FieldValidationResult(validations, issues)
}
private fun buildDataPreview(metrics: List<DailyMetrics>, exercises: List<ExerciseData>): DataPreview {
val metricsPreview = if (metrics.isNotEmpty()) {
val m = metrics.first()
"""
|Date: ${m.date}
|Steps: ${m.steps}
|Distance: ${String.format(Locale.US, "%.0f", m.distanceMeters)}m
|Calories: ${m.caloriesBurned}
|Heart Rate: ${m.avgHeartRate ?: "N/A"} bpm
|Sleep: ${m.sleepDurationMinutes} min
""".trimMargin()
} else {
"No metrics data available"
}
val exercisesPreview = if (exercises.isNotEmpty()) {
exercises.take(3).joinToString("\n") { e ->
"• ${e.exerciseTypeName}: ${e.durationMinutes} min"
}
} else {
"No exercise sessions available"
}
return DataPreview(
dailyMetricsPreview = metricsPreview,
exerciseSessionsPreview = exercisesPreview,
metricsCount = metrics.size,
exercisesCount = exercises.size
)
}
}
// ==================== DATA CLASSES ====================
data class ValidationResult(
val isCompatible: Boolean,
val validations: List<ValidationItem>,
val issues: List<ValidationIssue>,
val dataPreview: DataPreview?
) {
fun getOverallStatus(): String {
return if (isCompatible) {
val warnings = issues.count { it.severity == IssueSeverity.WARNING }
if (warnings > 0) {
"Compatible with $warnings warning(s)"
} else {
"Fully compatible"
}
} else {
"Incompatible - see errors"
}
}
fun getStatusIcon(): String {
return if (isCompatible) {
if (issues.any { it.severity == IssueSeverity.WARNING }) "⚠" else "✓"
} else {
"✗"
}
}
}
data class ValidationItem(
val name: String,
val passed: Boolean,
val message: String
)
data class ValidationIssue(
val severity: IssueSeverity,
val category: String,
val message: String
)
enum class IssueSeverity {
INFO,
WARNING,
ERROR
}
data class DataPreview(
val dailyMetricsPreview: String,
val exerciseSessionsPreview: String,
val metricsCount: Int,
val exercisesCount: Int
)
private data class FieldValidationResult(
val validations: List<ValidationItem>,
val issues: List<ValidationIssue>
)
@@ -0,0 +1,140 @@
package com.habitforge.companion
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
/**
* Retrofit API interface for communicating with HabitForge backend
*/
interface HabitForgeApi {
/**
* Login with username and password (OAuth2 form data)
* Returns access token on success
*/
@FormUrlEncoded
@POST("api/auth/token")
suspend fun login(
@Field("username") username: String,
@Field("password") password: String
): Response<LoginResponse>
/**
* Get current user information
*/
@GET("api/auth/me")
suspend fun getCurrentUser(
@Header("Authorization") token: String
): Response<UserResponse>
@POST("api/health-connect/sync")
suspend fun syncHealthData(
@Header("Authorization") token: String,
@Body request: HealthConnectSyncRequest
): Response<SyncResponse>
@GET("api/health-connect/status")
suspend fun getSyncStatus(
@Header("Authorization") token: String
): Response<SyncStatusResponse>
companion object {
fun create(baseUrl: String): HabitForgeApi {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(HabitForgeApi::class.java)
}
}
}
// ==================== REQUEST/RESPONSE MODELS ====================
data class HealthConnectSyncRequest(
val device_id: String,
val sync_timestamp: String,
val daily_metrics: List<DailyMetricsPayload>,
val exercise_sessions: List<ExerciseSessionPayload>
)
data class DailyMetricsPayload(
val date: String,
val step_count: Int,
val distance_meters: Double,
val calories_burned: Double,
val total_calories: Double = 0.0,
val avg_heart_rate: Int?,
val min_heart_rate: Int?,
val max_heart_rate: Int?,
val resting_heart_rate: Int?,
val sleep_duration_minutes: Int,
val deep_sleep_minutes: Int,
val light_sleep_minutes: Int,
val rem_sleep_minutes: Int,
val awake_duration_minutes: Int,
val weight: Double?,
val avg_spo2: Double?,
val min_spo2: Double? = null,
val max_spo2: Double? = null
)
data class ExerciseSessionPayload(
val start_time: String,
val end_time: String,
val exercise_type: Int,
val exercise_type_name: String?,
val duration_seconds: Int,
val title: String?,
val notes: String?,
val calories_burned: Double? = null,
val distance_meters: Double? = null,
val avg_heart_rate: Int? = null,
val max_heart_rate: Int? = null,
val min_heart_rate: Int? = null,
val steps: Int? = null,
val elevation_gained: Double? = null
)
data class SyncResponse(
val status: String,
val message: String,
val sync_id: Int,
val metrics_synced: Int,
val exercises_synced: Int
)
data class SyncStatusResponse(
val has_data: Boolean,
val last_sync: String?,
val total_records: Int
)
// ==================== AUTH MODELS ====================
data class LoginResponse(
val access_token: String,
val token_type: String
)
data class UserResponse(
val id: Int,
val username: String
)
@@ -0,0 +1,27 @@
package com.habitforge.companion
import android.app.Application
import androidx.work.Configuration
import androidx.work.WorkManager
class HabitForgeApp : Application(), Configuration.Provider {
lateinit var healthConnectManager: HealthConnectManager
private set
override fun onCreate() {
super.onCreate()
instance = this
healthConnectManager = HealthConnectManager(this)
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.INFO)
.build()
companion object {
lateinit var instance: HabitForgeApp
private set
}
}
@@ -0,0 +1,483 @@
package com.habitforge.companion
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.*
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
/**
* Health Connect Manager - Handles all interactions with Health Connect API
* Implements: Permission requests and data reading for all required health types
*/
class HealthConnectManager(private val context: Context) {
private var healthConnectClient: HealthConnectClient? = null
companion object {
private const val TAG = "HealthConnectManager"
// All permissions we need to read health data
val PERMISSIONS = setOf(
// Activity
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(DistanceRecord::class),
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
// Heart & Vitals
HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getReadPermission(RestingHeartRateRecord::class),
HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class),
HealthPermission.getReadPermission(OxygenSaturationRecord::class),
HealthPermission.getReadPermission(RespiratoryRateRecord::class),
// Body Measurements
HealthPermission.getReadPermission(WeightRecord::class),
HealthPermission.getReadPermission(HeightRecord::class),
HealthPermission.getReadPermission(BodyFatRecord::class),
HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
// Sleep
HealthPermission.getReadPermission(SleepSessionRecord::class),
// Exercise
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(PowerRecord::class),
HealthPermission.getReadPermission(SpeedRecord::class),
// Nutrition
HealthPermission.getReadPermission(NutritionRecord::class),
HealthPermission.getReadPermission(HydrationRecord::class),
// Historical data access (for data older than 30 days)
"android.permission.health.READ_HEALTH_DATA_HISTORY"
)
}
/**
* Check if Health Connect is available on this device
*/
fun isAvailable(): HealthConnectAvailability {
return when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> {
healthConnectClient = HealthConnectClient.getOrCreate(context)
HealthConnectAvailability.AVAILABLE
}
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
HealthConnectAvailability.NOT_INSTALLED
}
else -> HealthConnectAvailability.NOT_SUPPORTED
}
}
/**
* Check which permissions are currently granted
*/
suspend fun getGrantedPermissions(): Set<String> {
val client = healthConnectClient ?: return emptySet()
return client.permissionController.getGrantedPermissions()
}
/**
* Check if we have all required permissions
*/
suspend fun hasAllPermissions(): Boolean {
val granted = getGrantedPermissions()
return PERMISSIONS.all { it in granted }
}
// ==================== DATA READING METHODS ====================
/**
* Read daily metrics for a specific date range
*/
/**
* Read daily metrics for a specific date range efficiently
*/
suspend fun readDailyMetrics(startDate: LocalDate, endDate: LocalDate): List<DailyMetrics> = withContext(Dispatchers.IO) {
val client = healthConnectClient ?: run {
if (isAvailable() == HealthConnectAvailability.AVAILABLE) healthConnectClient else null
} ?: return@withContext emptyList()
// Validate date range
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
val actualEndDate = if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
Log.d(TAG, "Reading daily metrics (bulk) from $actualStartDate to $actualEndDate")
val startInstant = actualStartDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
val endInstant = actualEndDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
val timeRange = TimeRangeFilter.between(startInstant, endInstant)
// Fetch all metrics in bulk WITH PAGINATION for complete history
val allSteps = try { readAllRecordsPaginated<StepsRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading steps", e); emptyList() }
val allDistance = try { readAllRecordsPaginated<DistanceRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading distance", e); emptyList() }
val allCalories = try { readAllRecordsPaginated<TotalCaloriesBurnedRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading calories", e); emptyList() }
val allHeartRate = try { readAllRecordsPaginated<HeartRateRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading heart rate", e); emptyList() }
val allRestingHR = try { readAllRecordsPaginated<RestingHeartRateRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading resting HR", e); emptyList() }
val allSleep = try { readAllRecordsPaginated<SleepSessionRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading sleep", e); emptyList() }
val allWeight = try { readAllRecordsPaginated<WeightRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading weight", e); emptyList() }
val allSpo2 = try { readAllRecordsPaginated<OxygenSaturationRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading spo2", e); emptyList() }
Log.d(TAG, "Loaded totals - Steps: ${allSteps.size}, Distance: ${allDistance.size}, Calories: ${allCalories.size}, HR: ${allHeartRate.size}, SpO2: ${allSpo2.size}")
val metrics = mutableListOf<DailyMetrics>()
var currentDate = actualStartDate
while (!currentDate.isAfter(actualEndDate)) {
val dayStart = currentDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
val dayEnd = currentDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
// Filter records for this specific day (in memory)
val daySteps = allSteps.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.count }
val dayDistance = allDistance.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.distance.inMeters }
val dayCalories = allCalories.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.energy.inKilocalories }
val dayHRRecords = allHeartRate.filter { it.startTime >= dayStart && it.endTime <= dayEnd }
val hrSamples = dayHRRecords.flatMap { it.samples }
val dayRestingHR = allRestingHR.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.beatsPerMinute?.toInt()
// For weight and spo2, take the latest of the day
val dayWeight = allWeight.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.weight?.inKilograms
val daySpo2 = allSpo2.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.percentage?.value
// Sleep logic - sleep for "today" is usually the one finishing today
val daySleep = allSleep.filter { it.endTime >= dayStart && it.endTime <= dayEnd }
var totalSleep = 0
var deep = 0
var light = 0
var rem = 0
var awake = 0
daySleep.forEach { session ->
totalSleep += ChronoUnit.MINUTES.between(session.startTime, session.endTime).toInt()
session.stages.forEach { stage ->
val dur = ChronoUnit.MINUTES.between(stage.startTime, stage.endTime).toInt()
when (stage.stage) {
SleepSessionRecord.STAGE_TYPE_DEEP -> deep += dur
SleepSessionRecord.STAGE_TYPE_LIGHT -> light += dur
SleepSessionRecord.STAGE_TYPE_REM -> rem += dur
SleepSessionRecord.STAGE_TYPE_AWAKE -> awake += dur
}
}
}
metrics.add(DailyMetrics(
date = currentDate.toString(),
steps = daySteps,
distanceMeters = dayDistance,
caloriesBurned = dayCalories,
avgHeartRate = if (hrSamples.isNotEmpty()) hrSamples.map { it.beatsPerMinute }.average().toInt() else null,
minHeartRate = if (hrSamples.isNotEmpty()) hrSamples.minOf { it.beatsPerMinute }.toInt() else null,
maxHeartRate = if (hrSamples.isNotEmpty()) hrSamples.maxOf { it.beatsPerMinute }.toInt() else null,
restingHeartRate = dayRestingHR,
sleepDurationMinutes = totalSleep,
deepSleepMinutes = deep,
lightSleepMinutes = light,
remSleepMinutes = rem,
awakeMinutes = awake,
weight = dayWeight,
spo2 = daySpo2
))
currentDate = currentDate.plusDays(1)
}
metrics
}
/**
* Read exercise sessions for a date range with pagination support
*/
suspend fun readExerciseSessions(startDate: LocalDate, endDate: LocalDate): List<ExerciseData> = withContext(Dispatchers.IO) {
// Ensure client is initialized
val client = healthConnectClient ?: run {
if (isAvailable() == HealthConnectAvailability.AVAILABLE) {
healthConnectClient
} else {
Log.e(TAG, "Health Connect client not available for exercises")
return@withContext emptyList()
}
} ?: return@withContext emptyList()
// Validate date range
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
val actualEndDate = if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
Log.d(TAG, "Reading exercise sessions from $actualStartDate to $actualEndDate")
val exercises = mutableListOf<ExerciseData>()
try {
val timeRange = TimeRangeFilter.between(
actualStartDate.atStartOfDay(ZoneId.systemDefault()).toInstant(),
actualEndDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
)
// Use pagination to get ALL exercise records
var pageToken: String? = null
var totalRecords = 0
do {
val request = if (pageToken != null) {
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = timeRange,
pageToken = pageToken
)
} else {
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = timeRange
)
}
val response = client.readRecords(request)
totalRecords += response.records.size
for (record in response.records) {
// Validate that startTime is before endTime for this record
if (record.startTime.isBefore(record.endTime)) {
val durationMinutes = ChronoUnit.MINUTES.between(record.startTime, record.endTime).toInt()
exercises.add(
ExerciseData(
startTime = record.startTime.toString(),
endTime = record.endTime.toString(),
exerciseType = record.exerciseType,
exerciseTypeName = mapExerciseType(record.exerciseType),
durationMinutes = durationMinutes,
title = record.title ?: mapExerciseType(record.exerciseType),
notes = record.notes
)
)
} else {
Log.w(TAG, "Skipping exercise record with invalid time: start=${record.startTime}, end=${record.endTime}")
}
}
pageToken = response.pageToken
Log.d(TAG, "Read ${response.records.size} exercise records (total: $totalRecords, hasMore: ${pageToken != null})")
} while (pageToken != null)
} catch (e: Exception) {
Log.e(TAG, "Error reading exercise sessions", e)
}
Log.d(TAG, "Total exercise sessions found: ${exercises.size}")
exercises
}
// ==================== PRIVATE HELPER METHODS ====================
/**
* Generic helper to read ALL records with pagination support.
* Health Connect returns max 1000 records per request by default.
* We use pageToken to fetch all pages until no more data is available.
*/
private suspend inline fun <reified T : Record> readAllRecordsPaginated(
client: HealthConnectClient,
timeRange: TimeRangeFilter
): List<T> {
val allRecords = mutableListOf<T>()
var pageToken: String? = null
do {
val request = if (pageToken != null) {
ReadRecordsRequest(
recordType = T::class,
timeRangeFilter = timeRange,
pageToken = pageToken
)
} else {
ReadRecordsRequest(
recordType = T::class,
timeRangeFilter = timeRange
)
}
val response = client.readRecords(request)
allRecords.addAll(response.records)
pageToken = response.pageToken
Log.d(TAG, "Read ${response.records.size} ${T::class.simpleName} records (total: ${allRecords.size}, hasMore: ${pageToken != null})")
} while (pageToken != null)
return allRecords
}
private suspend fun readSteps(client: HealthConnectClient, range: TimeRangeFilter): Long {
return try {
val response = client.readRecords(ReadRecordsRequest(StepsRecord::class, range))
response.records.sumOf { it.count }
} catch (e: Exception) {
Log.w(TAG, "Could not read steps", e)
0L
}
}
private suspend fun readDistance(client: HealthConnectClient, range: TimeRangeFilter): Double {
return try {
val response = client.readRecords(ReadRecordsRequest(DistanceRecord::class, range))
response.records.sumOf { it.distance.inMeters }
} catch (e: Exception) {
Log.w(TAG, "Could not read distance", e)
0.0
}
}
private suspend fun readCalories(client: HealthConnectClient, range: TimeRangeFilter): Double {
return try {
val response = client.readRecords(ReadRecordsRequest(TotalCaloriesBurnedRecord::class, range))
response.records.sumOf { it.energy.inKilocalories }
} catch (e: Exception) {
Log.w(TAG, "Could not read calories", e)
0.0
}
}
private suspend fun readHeartRate(client: HealthConnectClient, range: TimeRangeFilter): HeartRateData {
return try {
val response = client.readRecords(ReadRecordsRequest(HeartRateRecord::class, range))
val samples = response.records.flatMap { it.samples }
val resting = try {
client.readRecords(ReadRecordsRequest(RestingHeartRateRecord::class, range))
.records.firstOrNull()?.beatsPerMinute
} catch (e: Exception) {
null
}
if (samples.isEmpty()) {
HeartRateData(avg = null, min = null, max = null, resting = resting?.toInt())
} else {
HeartRateData(
avg = samples.map { it.beatsPerMinute }.average().toInt(),
min = samples.minOfOrNull { it.beatsPerMinute }?.toInt(),
max = samples.maxOfOrNull { it.beatsPerMinute }?.toInt(),
resting = resting?.toInt()
)
}
} catch (e: Exception) {
Log.w(TAG, "Could not read heart rate", e)
HeartRateData(null, null, null, null)
}
}
private suspend fun readSleep(client: HealthConnectClient, date: LocalDate): SleepData {
val start = date.minusDays(1).atTime(12, 0).toInstant(ZoneId.systemDefault().rules.getOffset(Instant.now()))
val end = date.atTime(11, 59).toInstant(ZoneId.systemDefault().rules.getOffset(Instant.now()))
val range = TimeRangeFilter.between(start, end)
return try {
val response = client.readRecords(ReadRecordsRequest(SleepSessionRecord::class, range))
val session = response.records.firstOrNull()
SleepData(
totalMinutes = session?.let { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
deepMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_DEEP }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
lightMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_LIGHT }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
remSleepMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_REM }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
awakeMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_AWAKE }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
)
} catch (e: Exception) {
Log.w(TAG, "Could not read sleep", e)
SleepData(0, 0, 0, 0, 0)
}
}
private suspend fun readLatestWeight(client: HealthConnectClient, range: TimeRangeFilter): Double? {
return try {
val response = client.readRecords(ReadRecordsRequest(WeightRecord::class, range))
response.records.lastOrNull()?.weight?.inKilograms
} catch (e: Exception) {
Log.w(TAG, "Could not read weight", e)
null
}
}
private suspend fun readSpO2(client: HealthConnectClient, range: TimeRangeFilter): Double? {
return try {
val response = client.readRecords(ReadRecordsRequest(OxygenSaturationRecord::class, range))
response.records.lastOrNull()?.percentage?.value
} catch (e: Exception) {
Log.w(TAG, "Could not read SpO2", e)
null
}
}
private fun mapExerciseType(type: Int): String {
return when (type) {
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "Walking"
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING -> "Running"
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "Hiking"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "Biking"
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL -> "Swimming"
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING -> "Strength Training"
ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "Yoga"
ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "Pilates"
else -> "Other"
}
}
}
// ==================== DATA CLASSES ====================
enum class HealthConnectAvailability {
AVAILABLE,
NOT_INSTALLED,
NOT_SUPPORTED
}
data class DailyMetrics(
val date: String,
val steps: Long,
val distanceMeters: Double,
val caloriesBurned: Double,
val avgHeartRate: Int?,
val minHeartRate: Int?,
val maxHeartRate: Int?,
val restingHeartRate: Int?,
val sleepDurationMinutes: Int,
val deepSleepMinutes: Int,
val lightSleepMinutes: Int,
val remSleepMinutes: Int,
val awakeMinutes: Int,
val weight: Double?,
val spo2: Double?
)
data class ExerciseData(
val startTime: String,
val endTime: String,
val exerciseType: Int,
val exerciseTypeName: String,
val durationMinutes: Int,
val title: String?,
val notes: String?
)
data class HeartRateData(
val avg: Int?,
val min: Int?,
val max: Int?,
val resting: Int?
)
data class SleepData(
val totalMinutes: Int,
val deepMinutes: Int,
val lightMinutes: Int,
val remSleepMinutes: Int,
val awakeMinutes: Int
)
@@ -0,0 +1,973 @@
package com.habitforge.companion
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.health.connect.client.PermissionController
import androidx.lifecycle.lifecycleScope
import com.habitforge.companion.databinding.ActivityMainBinding
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
/**
* Main Activity - Handles UI, permission requests, sync triggers, and status monitoring
*/
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var healthConnectManager: HealthConnectManager
private lateinit var syncHistoryManager: SyncHistoryManager
private lateinit var serverConnectionManager: ServerConnectionManager
private lateinit var dataValidator: DataCompatibilityValidator
private lateinit var authManager: AuthManager
private lateinit var metricsSyncTracker: MetricsSyncTracker
// Permission request launcher
private val permissionLauncher = registerForActivityResult(
PermissionController.createRequestPermissionResultContract()
) { granted ->
Log.i(TAG, "Permissions granted: $granted")
lifecycleScope.launch {
updateUI()
}
}
companion object {
private const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
healthConnectManager = HabitForgeApp.instance.healthConnectManager
syncHistoryManager = SyncHistoryManager(this)
serverConnectionManager = ServerConnectionManager(this)
dataValidator = DataCompatibilityValidator(this, healthConnectManager)
authManager = AuthManager(this)
metricsSyncTracker = MetricsSyncTracker(this)
setupUI()
setupSyncPeriodSpinner()
checkHealthConnectAvailability()
}
override fun onResume() {
super.onResume()
lifecycleScope.launch {
updateUI()
checkServerAvailability()
updateSyncHistory()
updateMetricsOverview()
}
}
private fun setupUI() {
// Request Permissions Button
binding.btnRequestPermissions.setOnClickListener {
requestHealthConnectPermissions()
}
// Sync Now Button
binding.btnSyncNow.setOnClickListener {
performSync()
}
// Configure Server Button
binding.btnConfigureServer.setOnClickListener {
showServerConfigDialog()
}
// Test Connection Button
binding.btnTestConnection.setOnClickListener {
testServerConnection()
}
// Open Health Connect Button
binding.btnOpenHealthConnect.setOnClickListener {
openHealthConnect()
}
// Validate Data Button
binding.btnValidateData.setOnClickListener {
validateDataCompatibility()
}
// View All History Button
binding.btnViewAllHistory.setOnClickListener {
showFullSyncHistory()
}
// Refresh Metrics Button
binding.btnRefreshMetrics.setOnClickListener {
refreshMetricsOverview()
}
// Initialize metrics display
updateMetricsOverview()
}
private fun setupSyncPeriodSpinner() {
val options = SyncService.getSyncPeriodOptions()
val adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
options.map { it.label }
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerSyncPeriod.adapter = adapter
// Set current selection
val currentPeriod = SyncService.getSyncPeriod(this)
val currentIndex = options.indexOfFirst { it.minutes == currentPeriod }
if (currentIndex >= 0) {
binding.spinnerSyncPeriod.setSelection(currentIndex)
}
// Handle selection changes
binding.spinnerSyncPeriod.setOnItemSelectedListener(object : android.widget.AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
val selectedPeriod = options[position].minutes
if (selectedPeriod != SyncService.getSyncPeriod(this@MainActivity)) {
SyncService.schedulePeriodicSync(this@MainActivity, selectedPeriod)
Toast.makeText(
this@MainActivity,
"Sync period updated to ${options[position].label}",
Toast.LENGTH_SHORT
).show()
}
}
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
})
}
private fun checkHealthConnectAvailability() {
when (healthConnectManager.isAvailable()) {
HealthConnectAvailability.AVAILABLE -> {
binding.statusHealthConnect.text = "✓ Health Connect Available"
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.green))
binding.layoutAvailable.visibility = View.VISIBLE
binding.layoutUnavailable.visibility = View.GONE
lifecycleScope.launch {
updateUI()
}
}
HealthConnectAvailability.NOT_INSTALLED -> {
binding.statusHealthConnect.text = "✗ Health Connect Not Installed"
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.orange))
binding.layoutAvailable.visibility = View.GONE
binding.layoutUnavailable.visibility = View.VISIBLE
binding.btnInstallHealthConnect.setOnClickListener {
installHealthConnect()
}
}
HealthConnectAvailability.NOT_SUPPORTED -> {
binding.statusHealthConnect.text = "✗ Health Connect Not Supported"
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.red))
binding.layoutAvailable.visibility = View.GONE
binding.layoutUnavailable.visibility = View.VISIBLE
binding.btnInstallHealthConnect.visibility = View.GONE
binding.tvUnavailableMessage.text = "Your device does not support Health Connect. Android 9+ is required."
}
}
}
private suspend fun updateUI() {
// Check permissions status
val hasPermissions = healthConnectManager.hasAllPermissions()
val grantedCount = healthConnectManager.getGrantedPermissions().size
val totalCount = HealthConnectManager.PERMISSIONS.size
if (hasPermissions) {
binding.statusPermissions.text = "✓ All Permissions Granted ($grantedCount/$totalCount)"
binding.statusPermissions.setTextColor(ContextCompat.getColor(this, R.color.green))
binding.btnRequestPermissions.text = "Manage Permissions"
binding.btnSyncNow.isEnabled = true
} else {
binding.statusPermissions.text = "⚠ Permissions Required ($grantedCount/$totalCount)"
binding.statusPermissions.setTextColor(ContextCompat.getColor(this, R.color.orange))
binding.btnRequestPermissions.text = "Grant Permissions"
binding.btnSyncNow.isEnabled = false
}
// Check authentication status
if (authManager.isLoggedIn()) {
val username = authManager.getUsername()
binding.statusServer.text = "✓ Logged in as $username"
binding.statusServer.setTextColor(ContextCompat.getColor(this, R.color.green))
binding.tvServerUrl.text = authManager.getServerUrl()
binding.btnConfigureServer.text = "Logout"
} else {
binding.statusServer.text = "⚠ Not logged in"
binding.statusServer.setTextColor(ContextCompat.getColor(this, R.color.orange))
binding.tvServerUrl.text = "Tap Login to connect your account"
binding.btnConfigureServer.text = "Login"
}
// Last sync time
val prefs = getSharedPreferences("habitforge", MODE_PRIVATE)
val lastSync = prefs.getLong("last_sync", 0)
if (lastSync > 0) {
val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
binding.tvLastSync.text = "Last sync: ${dateFormat.format(Date(lastSync))}"
} else {
binding.tvLastSync.text = "Never synced"
}
}
private suspend fun checkServerAvailability() {
binding.tvServerAvailability.text = "Checking..."
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_gray)
val result = serverConnectionManager.testConnection()
when (result.status) {
ServerStatus.ONLINE -> {
if (result.authenticated) {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_green)
binding.tvServerAvailability.text = "Online"
binding.tvServerResponseTime.text = "Response: ${result.responseTimeMs}ms"
binding.tvServerResponseTime.visibility = View.VISIBLE
} else {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
binding.tvServerAvailability.text = "Auth needed"
binding.tvServerResponseTime.visibility = View.GONE
}
}
ServerStatus.UNREACHABLE, ServerStatus.OFFLINE -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_red)
binding.tvServerAvailability.text = "Offline"
binding.tvServerResponseTime.visibility = View.GONE
}
ServerStatus.TIMEOUT -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
binding.tvServerAvailability.text = "Timeout"
binding.tvServerResponseTime.visibility = View.GONE
}
ServerStatus.AUTH_ERROR -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
binding.tvServerAvailability.text = "Auth error"
binding.tvServerResponseTime.visibility = View.GONE
}
ServerStatus.SERVER_ERROR -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_red)
binding.tvServerAvailability.text = "Error"
binding.tvServerResponseTime.visibility = View.GONE
}
else -> {
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_gray)
binding.tvServerAvailability.text = "Unknown"
binding.tvServerResponseTime.visibility = View.GONE
}
}
}
private fun updateSyncHistory() {
val stats = syncHistoryManager.getStats()
val recentSyncs = syncHistoryManager.getRecentHistory(5)
// Update stats
binding.tvSuccessCount.text = stats.successfulSyncs.toString()
binding.tvFailedCount.text = stats.failedSyncs.toString()
binding.tvMetricsSynced.text = stats.totalMetricsSynced.toString()
if (stats.totalSyncs > 0) {
binding.tvSyncStats.text = "${String.format("%.0f", stats.getSuccessRate())}% success"
} else {
binding.tvSyncStats.text = ""
}
// Update recent syncs list
binding.layoutRecentSyncs.removeAllViews()
if (recentSyncs.isEmpty()) {
binding.tvNoSyncHistory.visibility = View.VISIBLE
binding.btnViewAllHistory.visibility = View.GONE
} else {
binding.tvNoSyncHistory.visibility = View.GONE
binding.btnViewAllHistory.visibility = if (syncHistoryManager.getHistory().size > 5) View.VISIBLE else View.GONE
for (sync in recentSyncs) {
val itemView = createSyncHistoryItem(sync)
binding.layoutRecentSyncs.addView(itemView)
}
}
}
private fun createSyncHistoryItem(sync: SyncRecord): View {
val itemView = LayoutInflater.from(this).inflate(android.R.layout.simple_list_item_2, null)
val text1 = itemView.findViewById<TextView>(android.R.id.text1)
val text2 = itemView.findViewById<TextView>(android.R.id.text2)
text1.text = "${sync.getStatusIcon()} ${sync.getFormattedTimestamp()}"
text1.setTextColor(ContextCompat.getColor(this, sync.getStatusColor()))
text1.textSize = 13f
val details = if (sync.status == SyncStatus.SUCCESS) {
"Metrics: ${sync.metricsSynced}, Exercises: ${sync.exercisesSynced} (${sync.syncDurationMs}ms)"
} else {
sync.errorMessage ?: "Unknown error"
}
text2.text = details
text2.textSize = 11f
text2.setTextColor(ContextCompat.getColor(this, android.R.color.darker_gray))
val params = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
params.setMargins(0, 4, 0, 4)
itemView.layoutParams = params
return itemView
}
private fun testServerConnection() {
binding.btnTestConnection.isEnabled = false
binding.btnTestConnection.text = "Testing..."
lifecycleScope.launch {
val result = serverConnectionManager.testConnection()
binding.btnTestConnection.isEnabled = true
binding.btnTestConnection.text = "Test Connection"
// Show result dialog
AlertDialog.Builder(this@MainActivity)
.setTitle("Connection Test Result")
.setMessage("""
Status: ${result.getStatusIcon()} ${result.status}
${result.message}
${if (result.responseTimeMs > 0) "Response time: ${result.responseTimeMs}ms" else ""}
${if (result.authenticated) "✓ Authentication: Valid" else ""}
""".trimIndent().trim())
.setPositiveButton("OK", null)
.show()
// Update server status indicator
checkServerAvailability()
}
}
private fun validateDataCompatibility() {
binding.btnValidateData.isEnabled = false
binding.btnValidateData.text = "Validating..."
lifecycleScope.launch {
val result = dataValidator.validateCompatibility()
runOnUiThread {
binding.btnValidateData.isEnabled = true
binding.btnValidateData.text = "Validate Data Schema"
// Update status
binding.statusDataCompatibility.text = "${result.getStatusIcon()} ${result.getOverallStatus()}"
if (result.isCompatible) {
binding.statusDataCompatibility.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.green))
} else {
binding.statusDataCompatibility.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.red))
}
// Show validation results
binding.layoutValidationResults.visibility = View.VISIBLE
val resultsText = StringBuilder()
// Add validation items
for (item in result.validations) {
val icon = if (item.passed) "✓" else "✗"
resultsText.appendLine("$icon ${item.name}")
resultsText.appendLine(" ${item.message}")
}
// Add issues if any
if (result.issues.isNotEmpty()) {
resultsText.appendLine("\nIssues:")
for (issue in result.issues) {
val icon = when (issue.severity) {
IssueSeverity.ERROR -> "❌"
IssueSeverity.WARNING -> "⚠️"
IssueSeverity.INFO -> "ℹ️"
}
resultsText.appendLine("$icon [${issue.category}] ${issue.message}")
}
}
// Add data preview if available
result.dataPreview?.let { preview ->
resultsText.appendLine("\nData Preview:")
resultsText.appendLine(" Metrics: ${preview.metricsCount} day(s)")
resultsText.appendLine(" Exercises: ${preview.exercisesCount} session(s)")
}
binding.tvValidationResultsList.text = resultsText.toString()
// Update details text
binding.tvCompatibilityDetails.text = if (result.isCompatible) {
"All Health Connect data types can be synced with HabitForge."
} else {
"Some issues were found. Check the validation results below."
}
}
}
}
private fun showFullSyncHistory() {
val history = syncHistoryManager.getHistory()
val message = if (history.isEmpty()) {
"No sync history available."
} else {
history.take(20).joinToString("\n\n") { sync ->
val details = if (sync.status == SyncStatus.SUCCESS) {
"Metrics: ${sync.metricsSynced}, Exercises: ${sync.exercisesSynced}"
} else {
sync.errorMessage ?: "Unknown error"
}
"${sync.getStatusIcon()} ${sync.getFormattedTimestamp()}\n$details"
}
}
AlertDialog.Builder(this)
.setTitle("Sync History")
.setMessage(message)
.setPositiveButton("OK", null)
.setNeutralButton("Clear History") { _, _ ->
syncHistoryManager.clearHistory()
updateSyncHistory()
Toast.makeText(this, "History cleared", Toast.LENGTH_SHORT).show()
}
.show()
}
private fun requestHealthConnectPermissions() {
permissionLauncher.launch(HealthConnectManager.PERMISSIONS)
}
private fun performSync() {
if (!authManager.isLoggedIn()) {
Toast.makeText(this, "Please login first", Toast.LENGTH_SHORT).show()
showLoginDialog()
return
}
binding.btnSyncNow.isEnabled = false
binding.btnSyncNow.text = "Syncing..."
binding.progressSync.visibility = View.VISIBLE
SyncService.syncNow(this)
// Re-enable after delay (actual completion is async)
binding.root.postDelayed({
binding.btnSyncNow.isEnabled = true
binding.btnSyncNow.text = "Sync Now"
binding.progressSync.visibility = View.GONE
lifecycleScope.launch {
updateUI()
updateSyncHistory()
}
Toast.makeText(this, "Sync started in background", Toast.LENGTH_SHORT).show()
}, 2000)
}
private fun showServerConfigDialog() {
// If already logged in, show logout confirmation
if (authManager.isLoggedIn()) {
AlertDialog.Builder(this)
.setTitle("Logout")
.setMessage("Are you sure you want to logout from ${authManager.getUsername()}?")
.setPositiveButton("Logout") { _, _ ->
authManager.logout()
lifecycleScope.launch {
updateUI()
checkServerAvailability()
}
Toast.makeText(this, "Logged out successfully", Toast.LENGTH_SHORT).show()
}
.setNegativeButton("Cancel", null)
.show()
return
}
// Show login dialog
showLoginDialog()
}
private fun showLoginDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_server_config, null)
val etServerUrl = dialogView.findViewById<android.widget.EditText>(R.id.etServerUrl)
val etUsername = dialogView.findViewById<android.widget.EditText>(R.id.etUsername)
val etPassword = dialogView.findViewById<android.widget.EditText>(R.id.etPassword)
val tvError = dialogView.findViewById<TextView>(R.id.tvError)
val progressLogin = dialogView.findViewById<android.widget.ProgressBar>(R.id.progressLogin)
// Pre-fill server URL
etServerUrl.setText(authManager.getServerUrl())
val dialog = AlertDialog.Builder(this)
.setTitle("Login to HabitForge")
.setView(dialogView)
.setPositiveButton("Login", null) // Set to null to prevent auto-dismiss
.setNegativeButton("Cancel", null)
.create()
dialog.setOnShowListener {
val loginButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
loginButton.setOnClickListener {
val serverUrl = etServerUrl.text.toString().trim()
val username = etUsername.text.toString().trim()
val password = etPassword.text.toString()
// Validation
if (serverUrl.isEmpty()) {
tvError.text = "Please enter server URL"
tvError.visibility = View.VISIBLE
return@setOnClickListener
}
if (username.isEmpty()) {
tvError.text = "Please enter username"
tvError.visibility = View.VISIBLE
return@setOnClickListener
}
if (password.isEmpty()) {
tvError.text = "Please enter password"
tvError.visibility = View.VISIBLE
return@setOnClickListener
}
// Show loading
tvError.visibility = View.GONE
progressLogin.visibility = View.VISIBLE
loginButton.isEnabled = false
etServerUrl.isEnabled = false
etUsername.isEnabled = false
etPassword.isEnabled = false
// Attempt login
lifecycleScope.launch {
val result = authManager.login(serverUrl, username, password)
if (result.success) {
dialog.dismiss()
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
// Schedule periodic sync
SyncService.schedulePeriodicSync(this@MainActivity)
updateUI()
checkServerAvailability()
} else {
// Show error
progressLogin.visibility = View.GONE
tvError.text = result.message
tvError.visibility = View.VISIBLE
loginButton.isEnabled = true
etServerUrl.isEnabled = true
etUsername.isEnabled = true
etPassword.isEnabled = true
}
}
}
}
dialog.show()
}
private fun openHealthConnect() {
try {
val intent = Intent("android.health.connect.action.HEALTH_HOME_SETTINGS")
startActivity(intent)
} catch (e: Exception) {
// Fallback for older versions
try {
val intent = Intent(Intent.ACTION_VIEW).apply {
setPackage("com.google.android.apps.healthdata")
}
startActivity(intent)
} catch (e2: Exception) {
Toast.makeText(this, "Could not open Health Connect", Toast.LENGTH_SHORT).show()
}
}
}
private fun installHealthConnect() {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(
"https://play.google.com/store/apps/details?id=com.google.android.apps.healthdata"
)))
} catch (e: Exception) {
Toast.makeText(this, "Could not open Play Store", Toast.LENGTH_SHORT).show()
}
}
// ==================== METRICS OVERVIEW ====================
private fun updateMetricsOverview() {
val allStatus = metricsSyncTracker.getAllMetricsStatus()
// Update summary text
val syncedCount = allStatus.count { it.lastSyncTime != null }
val availableCount = allStatus.count { it.isAvailable }
binding.tvMetricsSummary.text = "$syncedCount/${allStatus.size} metrics synced, $availableCount with data"
// Update each metric view with click listener
setupMetricClickListener(binding.metricSteps.root, "steps", allStatus.find { it.metricType.id == "steps" })
setupMetricClickListener(binding.metricDistance.root, "distance", allStatus.find { it.metricType.id == "distance" })
setupMetricClickListener(binding.metricCalories.root, "calories", allStatus.find { it.metricType.id == "calories" })
setupMetricClickListener(binding.metricHeartRate.root, "heart_rate", allStatus.find { it.metricType.id == "heart_rate" })
setupMetricClickListener(binding.metricRestingHR.root, "resting_hr", allStatus.find { it.metricType.id == "resting_hr" })
setupMetricClickListener(binding.metricSleep.root, "sleep", allStatus.find { it.metricType.id == "sleep" })
setupMetricClickListener(binding.metricWeight.root, "weight", allStatus.find { it.metricType.id == "weight" })
setupMetricClickListener(binding.metricSpO2.root, "spo2", allStatus.find { it.metricType.id == "spo2" })
setupMetricClickListener(binding.metricExercise.root, "exercise", allStatus.find { it.metricType.id == "exercise" })
}
private fun setupMetricClickListener(metricView: View, metricId: String, status: MetricSyncStatus?) {
updateMetricView(metricView, status)
metricView.setOnClickListener {
showMetricHistory(metricId)
}
}
private fun updateMetricView(metricView: View, status: MetricSyncStatus?) {
val metricType = status?.metricType ?: return
val tvIcon = metricView.findViewById<TextView>(R.id.tvMetricIcon)
val tvName = metricView.findViewById<TextView>(R.id.tvMetricName)
val tvValue = metricView.findViewById<TextView>(R.id.tvMetricValue)
val tvStatus = metricView.findViewById<TextView>(R.id.tvMetricStatus)
val tvSyncTime = metricView.findViewById<TextView>(R.id.tvMetricSyncTime)
tvIcon.text = metricType.icon
tvName.text = metricType.displayName
tvStatus.text = status.getStatusIcon()
tvValue.text = status.getStatusText()
if (status.lastSyncTime != null) {
tvSyncTime.visibility = View.VISIBLE
tvSyncTime.text = status.getFormattedSyncTime()
} else {
tvSyncTime.visibility = View.GONE
}
// Set background color based on status
val bgColor = when {
status.isAvailable && status.lastSyncTime != null -> "#E8F5E9" // Light green
status.lastSyncTime != null -> "#FFF3E0" // Light orange
else -> "#F5F5F5" // Light gray
}
metricView.setBackgroundColor(android.graphics.Color.parseColor(bgColor))
}
private fun refreshMetricsOverview() {
lifecycleScope.launch {
binding.btnRefreshMetrics.isEnabled = false
binding.btnRefreshMetrics.text = "Reading from Health Connect..."
try {
// Read latest data from Health Connect (last 7 days for better data)
val endDate = java.time.LocalDate.now()
val startDate = endDate.minusDays(7)
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
if (metrics.isNotEmpty()) {
// Find the best (most recent non-zero) values for each metric
val sortedMetrics = metrics.sortedByDescending { it.date }
// For steps - find most recent day with steps > 0 (could be today or yesterday)
val stepsMetric = sortedMetrics.firstOrNull { it.steps > 0 } ?: sortedMetrics.first()
// Find best values from available data
val bestSteps = stepsMetric.steps
val bestDistance = sortedMetrics.firstOrNull { it.distanceMeters > 0 }?.distanceMeters ?: 0.0
val bestCalories = sortedMetrics.firstOrNull { it.caloriesBurned > 0 }?.caloriesBurned ?: 0.0
val bestAvgHR = sortedMetrics.mapNotNull { it.avgHeartRate }.filter { it > 0 }.firstOrNull()
val bestRestingHR = sortedMetrics.mapNotNull { it.restingHeartRate }.filter { it > 0 }.firstOrNull()
val bestSleep = sortedMetrics.filter { it.sleepDurationMinutes > 0 }.firstOrNull()?.sleepDurationMinutes
val bestWeight = sortedMetrics.mapNotNull { it.weight }.filter { it > 0 }.firstOrNull()
val bestSpo2 = sortedMetrics.mapNotNull { it.spo2 }.filter { it > 0 }.firstOrNull()
Log.d(TAG, "Best values found - Steps: $bestSteps, Distance: $bestDistance, Calories: $bestCalories")
// Update tracker with best available values
metricsSyncTracker.updateFromSyncData(
steps = bestSteps,
distance = bestDistance,
calories = bestCalories,
avgHeartRate = bestAvgHR,
restingHeartRate = bestRestingHR,
sleepMinutes = bestSleep,
weight = bestWeight,
spo2 = bestSpo2,
exerciseCount = exercises.size
)
Toast.makeText(this@MainActivity, "Metrics refreshed from Health Connect", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@MainActivity, "No metrics data available", Toast.LENGTH_SHORT).show()
}
updateMetricsOverview()
} catch (e: Exception) {
Log.e(TAG, "Error refreshing metrics", e)
Toast.makeText(this@MainActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
binding.btnRefreshMetrics.isEnabled = true
binding.btnRefreshMetrics.text = "Refresh Metrics"
}
}
}
/**
* Show full metric history in a dialog
*/
private fun showMetricHistory(metricId: String) {
val progressDialog = AlertDialog.Builder(this)
.setMessage("Loading 365 days of history...")
.setCancelable(false)
.show()
lifecycleScope.launch {
try {
// Read all available data from Health Connect (last 365 days for full history)
val endDate = java.time.LocalDate.now()
val startDate = endDate.minusDays(365)
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
val exercises = if (metricId == "exercise") {
healthConnectManager.readExerciseSessions(startDate, endDate)
} else emptyList()
progressDialog.dismiss()
if (metrics.isEmpty() && exercises.isEmpty()) {
Toast.makeText(this@MainActivity, "No history data available", Toast.LENGTH_SHORT).show()
return@launch
}
// Create and show dialog
showMetricHistoryDialog(metricId, metrics, exercises)
} catch (e: Exception) {
progressDialog.dismiss()
Log.e(TAG, "Error loading history", e)
Toast.makeText(this@MainActivity, "Error loading history: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
private fun showMetricHistoryDialog(
metricId: String,
metrics: List<DailyMetrics>,
exercises: List<ExerciseData>
) {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_metric_history, null)
val tvTitle = dialogView.findViewById<TextView>(R.id.tvHistoryTitle)
val tvSubtitle = dialogView.findViewById<TextView>(R.id.tvHistorySubtitle)
val tvIcon = dialogView.findViewById<TextView>(R.id.tvHistoryIcon)
val tvDataPoints = dialogView.findViewById<TextView>(R.id.tvHistoryDataPoints)
val tvLatestValue = dialogView.findViewById<TextView>(R.id.tvHistoryLatestValue)
val tvAverage = dialogView.findViewById<TextView>(R.id.tvHistoryAverage)
val layoutList = dialogView.findViewById<LinearLayout>(R.id.layoutHistoryList)
// Get metric info
val metricInfo = MetricsSyncTracker.ALL_METRICS.find { it.id == metricId }
tvIcon.text = metricInfo?.icon ?: "📊"
tvTitle.text = "${metricInfo?.displayName ?: metricId} History"
tvSubtitle.text = "Last year from Health Connect"
// Extract values based on metric type
val historyData = mutableListOf<Pair<String, String>>()
var unit = ""
val values = mutableListOf<Double>()
when (metricId) {
"steps" -> {
unit = " steps"
metrics.forEach { m ->
if (m.steps > 0) {
historyData.add(m.date to "%,d".format(m.steps))
values.add(m.steps.toDouble())
}
}
}
"distance" -> {
unit = " m"
metrics.forEach { m ->
if (m.distanceMeters > 0) {
historyData.add(m.date to "%.1f".format(m.distanceMeters))
values.add(m.distanceMeters)
}
}
}
"calories" -> {
unit = " kcal"
metrics.forEach { m ->
if (m.caloriesBurned > 0) {
historyData.add(m.date to "%.0f".format(m.caloriesBurned))
values.add(m.caloriesBurned)
}
}
}
"heart_rate" -> {
unit = " bpm"
metrics.forEach { m ->
m.avgHeartRate?.let {
if (it > 0) {
historyData.add(m.date to it.toString())
values.add(it.toDouble())
}
}
}
}
"resting_hr" -> {
unit = " bpm"
metrics.forEach { m ->
m.restingHeartRate?.let {
if (it > 0) {
historyData.add(m.date to it.toString())
values.add(it.toDouble())
}
}
}
}
"sleep" -> {
unit = ""
metrics.forEach { m ->
if (m.sleepDurationMinutes > 0) {
val hours = m.sleepDurationMinutes / 60
val mins = m.sleepDurationMinutes % 60
historyData.add(m.date to "${hours}h ${mins}m")
values.add(m.sleepDurationMinutes.toDouble())
}
}
}
"weight" -> {
unit = " kg"
metrics.forEach { m ->
m.weight?.let {
if (it > 0) {
historyData.add(m.date to "%.1f".format(it))
values.add(it)
}
}
}
}
"spo2" -> {
unit = "%"
metrics.forEach { m ->
m.spo2?.let {
if (it > 0) {
historyData.add(m.date to "%.0f".format(it))
values.add(it)
}
}
}
}
"exercise" -> {
unit = " sessions"
// Group exercises by date - extract date safely
val byDate = exercises.groupBy { ex ->
try {
// Handle ISO timestamp format (2026-01-02T10:30:00Z)
if (ex.startTime.length >= 10) ex.startTime.substring(0, 10) else ex.startTime
} catch (e: Exception) {
ex.startTime
}
}
byDate.forEach { (date, exs) ->
val summary = if (exs.size <= 3) {
exs.joinToString { it.exerciseTypeName }
} else {
"${exs.take(2).joinToString { it.exerciseTypeName }} +${exs.size - 2} more"
}
historyData.add(date to "${exs.size} ($summary)")
values.add(exs.size.toDouble())
}
}
}
// Sort by date descending
historyData.sortByDescending { it.first }
// Update summary
tvDataPoints.text = historyData.size.toString()
tvLatestValue.text = if (historyData.isNotEmpty()) historyData.first().second + unit else "--"
tvAverage.text = if (values.isNotEmpty()) {
val avg = values.average()
if (metricId == "sleep") {
val hours = (avg / 60).toInt()
val mins = (avg % 60).toInt()
"${hours}h ${mins}m"
} else {
"%.1f".format(avg) + unit
}
} else "--"
// Populate list
val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
val parseFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
for ((dateStr, value) in historyData) {
val rowView = LayoutInflater.from(this).inflate(R.layout.item_history_row, layoutList, false)
val tvDate = rowView.findViewById<TextView>(R.id.tvHistoryDate)
val tvValue = rowView.findViewById<TextView>(R.id.tvHistoryValue)
val tvUnit = rowView.findViewById<TextView>(R.id.tvHistoryUnit)
try {
val date = parseFormat.parse(dateStr)
tvDate.text = dateFormat.format(date!!)
} catch (e: Exception) {
tvDate.text = dateStr
}
tvValue.text = value
tvUnit.text = if (metricId != "exercise") unit else ""
layoutList.addView(rowView)
// Add divider
val divider = View(this)
divider.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
1
)
divider.setBackgroundColor(android.graphics.Color.parseColor("#E5E7EB"))
layoutList.addView(divider)
}
// Show dialog
val dialog = AlertDialog.Builder(this)
.setView(dialogView)
.create()
dialogView.findViewById<View>(R.id.btnCloseHistory).setOnClickListener {
dialog.dismiss()
}
dialog.show()
}
}
@@ -0,0 +1,211 @@
package com.habitforge.companion
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.text.SimpleDateFormat
import java.util.*
/**
* Tracks sync status for individual metric types
*/
class MetricsSyncTracker(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("metrics_sync", Context.MODE_PRIVATE)
private val gson = Gson()
companion object {
private const val KEY_METRICS_STATUS = "metrics_status"
// All available metric types
val ALL_METRICS = listOf(
MetricType("steps", "Steps", "👣", "Daily step count"),
MetricType("distance", "Distance", "📏", "Distance walked/run"),
MetricType("calories", "Calories", "🔥", "Calories burned"),
MetricType("heart_rate", "Heart Rate", "❤️", "Average, min, max heart rate"),
MetricType("resting_hr", "Resting HR", "💗", "Resting heart rate"),
MetricType("sleep", "Sleep", "😴", "Sleep duration and stages"),
MetricType("weight", "Weight", "⚖️", "Body weight"),
MetricType("spo2", "SpO2", "🫁", "Blood oxygen saturation"),
MetricType("exercise", "Exercise", "🏃", "Workout sessions")
)
}
/**
* Get current status of all metrics
*/
fun getAllMetricsStatus(): List<MetricSyncStatus> {
val savedStatus = loadSavedStatus()
return ALL_METRICS.map { metricType ->
savedStatus[metricType.id] ?: MetricSyncStatus(
metricType = metricType,
lastSyncTime = null,
lastValue = null,
syncCount = 0,
isAvailable = false
)
}
}
/**
* Update sync status for a specific metric
*/
fun updateMetricStatus(
metricId: String,
lastValue: String?,
isAvailable: Boolean = true
) {
val savedStatus = loadSavedStatus().toMutableMap()
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
val existing = savedStatus[metricId]
savedStatus[metricId] = MetricSyncStatus(
metricType = metricType,
lastSyncTime = System.currentTimeMillis(),
lastValue = lastValue,
syncCount = (existing?.syncCount ?: 0) + 1,
isAvailable = isAvailable
)
saveStatus(savedStatus)
}
/**
* Bulk update metrics after a sync operation
*/
fun updateFromSyncData(
steps: Long?,
distance: Double?,
calories: Double?,
avgHeartRate: Int?,
restingHeartRate: Int?,
sleepMinutes: Int?,
weight: Double?,
spo2: Double?,
exerciseCount: Int
) {
// Steps: always mark as available if we have any value (including 0)
steps?.let { updateMetricStatus("steps", "$it steps", true) }
// Distance: available if value exists
distance?.let { updateMetricStatus("distance", String.format(Locale.US, "%.1f m", it), true) }
// Calories: available if value exists
calories?.let { updateMetricStatus("calories", String.format(Locale.US, "%.0f kcal", it), true) }
// Heart rate: available if we have a reading
avgHeartRate?.let { updateMetricStatus("heart_rate", "$it bpm", it > 0) }
restingHeartRate?.let { updateMetricStatus("resting_hr", "$it bpm", it > 0) }
// Sleep: available if we have duration
sleepMinutes?.let {
val hours = it / 60
val mins = it % 60
updateMetricStatus("sleep", "${hours}h ${mins}m", it > 0)
}
// Weight: available if we have a measurement
weight?.let { updateMetricStatus("weight", String.format(Locale.US, "%.1f kg", it), it > 0) }
// SpO2: available if we have a reading
spo2?.let { updateMetricStatus("spo2", String.format(Locale.US, "%.0f%%", it), it > 0) }
// Exercise: available if we have sessions
if (exerciseCount > 0) {
updateMetricStatus("exercise", "$exerciseCount sessions", true)
} else {
updateMetricStatus("exercise", "0 sessions", false)
}
}
/**
* Mark metric as unavailable (no data from Health Connect)
*/
fun markUnavailable(metricId: String) {
val savedStatus = loadSavedStatus().toMutableMap()
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
val existing = savedStatus[metricId]
savedStatus[metricId] = MetricSyncStatus(
metricType = metricType,
lastSyncTime = existing?.lastSyncTime,
lastValue = existing?.lastValue,
syncCount = existing?.syncCount ?: 0,
isAvailable = false
)
saveStatus(savedStatus)
}
/**
* Clear all sync history
*/
fun clearAll() {
prefs.edit().remove(KEY_METRICS_STATUS).apply()
}
private fun loadSavedStatus(): Map<String, MetricSyncStatus> {
val json = prefs.getString(KEY_METRICS_STATUS, null) ?: return emptyMap()
return try {
val type = object : TypeToken<Map<String, MetricSyncStatus>>() {}.type
gson.fromJson(json, type)
} catch (e: Exception) {
emptyMap()
}
}
private fun saveStatus(status: Map<String, MetricSyncStatus>) {
val json = gson.toJson(status)
prefs.edit().putString(KEY_METRICS_STATUS, json).apply()
}
}
/**
* Represents a type of health metric
*/
data class MetricType(
val id: String,
val displayName: String,
val icon: String,
val description: String
)
/**
* Sync status for a specific metric
*/
data class MetricSyncStatus(
val metricType: MetricType,
val lastSyncTime: Long?,
val lastValue: String?,
val syncCount: Int,
val isAvailable: Boolean
) {
fun getFormattedSyncTime(): String {
return if (lastSyncTime != null) {
val format = SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault())
format.format(Date(lastSyncTime))
} else {
"Never"
}
}
fun getStatusIcon(): String {
return when {
!isAvailable && lastSyncTime == null -> "⚪" // Never synced
!isAvailable -> "🔴" // Was synced but no data available
lastSyncTime != null -> "🟢" // Synced with data
else -> "⚪"
}
}
fun getStatusText(): String {
return when {
!isAvailable && lastSyncTime == null -> "Not synced"
!isAvailable -> "No data"
lastSyncTime != null -> lastValue ?: "Synced"
else -> "Pending"
}
}
}
@@ -0,0 +1,99 @@
package com.habitforge.companion
import android.os.Bundle
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
/**
* Privacy Policy Activity - Required by Health Connect
* Shows why we need access to health data
*/
class PrivacyPolicyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val webView = WebView(this)
setContentView(webView)
webView.loadData(
"""
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: sans-serif;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 { color: #0ea5e9; }
h2 { color: #666; margin-top: 24px; }
ul { padding-left: 20px; }
.highlight {
background: #f0f9ff;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
</style>
</head>
<body>
<h1>HabitForge Companion</h1>
<h2>Privacy Policy & Health Data Usage</h2>
<div class="highlight">
<strong>Why we need access to your health data:</strong>
<p>HabitForge uses your health data to help you track your fitness progress,
analyze your habits, and achieve your wellness goals.</p>
</div>
<h2>Data We Collect</h2>
<ul>
<li><strong>Activity Data:</strong> Steps, distance, calories burned</li>
<li><strong>Heart Rate:</strong> Average, resting, and workout heart rates</li>
<li><strong>Sleep:</strong> Duration and sleep stages</li>
<li><strong>Body Measurements:</strong> Weight, height, BMI</li>
<li><strong>Workouts:</strong> Exercise sessions and types</li>
<li><strong>Vitals:</strong> SpO2, respiratory rate</li>
</ul>
<h2>How We Use Your Data</h2>
<ul>
<li>Sync health metrics to your HabitForge account</li>
<li>Display progress charts and statistics</li>
<li>Track workout history and achievements</li>
<li>Calculate readiness and recovery scores</li>
</ul>
<h2>Data Security</h2>
<ul>
<li>Your data is encrypted during transmission (HTTPS)</li>
<li>Data is stored securely on your configured HabitForge server</li>
<li>You can delete your data at any time from the app</li>
<li>We never sell or share your health data with third parties</li>
</ul>
<h2>Your Rights</h2>
<ul>
<li>You can revoke permissions at any time</li>
<li>You can request deletion of your data</li>
<li>You control which data types are shared</li>
</ul>
<h2>Contact</h2>
<p>For questions about your data, contact your HabitForge server administrator.</p>
<p style="margin-top: 40px; color: #999; font-size: 12px;">
Last updated: January 2026
</p>
</body>
</html>
""".trimIndent(),
"text/html",
"UTF-8"
)
}
}
@@ -0,0 +1,221 @@
package com.habitforge.companion
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import java.net.SocketTimeoutException
/**
* Manages server connection tests and availability checks
*/
class ServerConnectionManager(private val context: Context) {
companion object {
private const val TAG = "ServerConnectionManager"
private const val CONNECTION_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 5000
}
/**
* Test connection to the server
* Returns a detailed result with status and response time
*/
suspend fun testConnection(): ConnectionTestResult = withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
val authToken = prefs.getString("auth_token", null)
Log.d(TAG, "Testing connection to: $serverUrl")
val startTime = System.currentTimeMillis()
try {
// First test basic connectivity
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
val healthUrl = URL("$baseUrl/api/health")
val connection = healthUrl.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = CONNECTION_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
val responseCode = connection.responseCode
val responseTime = System.currentTimeMillis() - startTime
connection.disconnect()
when (responseCode) {
200 -> {
// Now test authentication if token is provided
if (!authToken.isNullOrEmpty()) {
val authResult = testAuthentication(serverUrl, authToken)
if (authResult.authenticated) {
ConnectionTestResult(
status = ServerStatus.ONLINE,
responseTimeMs = responseTime,
message = "Server connected & authenticated",
authenticated = true
)
} else {
ConnectionTestResult(
status = ServerStatus.AUTH_ERROR,
responseTimeMs = responseTime,
message = "Server reachable but authentication failed: ${authResult.message}",
authenticated = false
)
}
} else {
ConnectionTestResult(
status = ServerStatus.ONLINE,
responseTimeMs = responseTime,
message = "Server reachable (no auth token configured)",
authenticated = false
)
}
}
401, 403 -> ConnectionTestResult(
status = ServerStatus.AUTH_ERROR,
responseTimeMs = responseTime,
message = "Authentication required (HTTP $responseCode)"
)
in 500..599 -> ConnectionTestResult(
status = ServerStatus.SERVER_ERROR,
responseTimeMs = responseTime,
message = "Server error (HTTP $responseCode)"
)
else -> ConnectionTestResult(
status = ServerStatus.UNKNOWN_ERROR,
responseTimeMs = responseTime,
message = "Unexpected response (HTTP $responseCode)"
)
}
} catch (e: SocketTimeoutException) {
Log.e(TAG, "Connection timeout", e)
ConnectionTestResult(
status = ServerStatus.TIMEOUT,
responseTimeMs = CONNECTION_TIMEOUT_MS.toLong(),
message = "Connection timeout after ${CONNECTION_TIMEOUT_MS}ms"
)
} catch (e: java.net.UnknownHostException) {
Log.e(TAG, "Unknown host", e)
ConnectionTestResult(
status = ServerStatus.UNREACHABLE,
responseTimeMs = 0,
message = "Server not found (check URL)"
)
} catch (e: java.net.ConnectException) {
Log.e(TAG, "Connection refused", e)
ConnectionTestResult(
status = ServerStatus.UNREACHABLE,
responseTimeMs = 0,
message = "Connection refused (server may be down)"
)
} catch (e: Exception) {
Log.e(TAG, "Connection test failed", e)
ConnectionTestResult(
status = ServerStatus.UNKNOWN_ERROR,
responseTimeMs = 0,
message = "Error: ${e.localizedMessage}"
)
}
}
/**
* Check if server is currently reachable (quick check)
*/
suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
try {
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
val url = URL("$baseUrl/api/health")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "HEAD"
connection.connectTimeout = 3000
connection.readTimeout = 3000
val reachable = connection.responseCode == 200
connection.disconnect()
reachable
} catch (e: Exception) {
false
}
}
/**
* Test authentication with the server
*/
private suspend fun testAuthentication(serverUrl: String, token: String): AuthTestResult = withContext(Dispatchers.IO) {
try {
val baseUrl = if (serverUrl.endsWith("/")) serverUrl else "$serverUrl/"
val api = HabitForgeApi.create(baseUrl)
val response = api.getCurrentUser("Bearer $token")
if (response.isSuccessful && response.body() != null) {
val user = response.body()!!
AuthTestResult(authenticated = true, message = "Logged in as ${user.username}")
} else {
AuthTestResult(authenticated = false, message = "HTTP ${response.code()}")
}
} catch (e: Exception) {
AuthTestResult(authenticated = false, message = e.localizedMessage ?: "Unknown error")
}
}
}
/**
* Server status enumeration
*/
enum class ServerStatus {
ONLINE,
OFFLINE,
UNREACHABLE,
TIMEOUT,
AUTH_ERROR,
SERVER_ERROR,
UNKNOWN_ERROR
}
/**
* Result of a connection test
*/
data class ConnectionTestResult(
val status: ServerStatus,
val responseTimeMs: Long = 0,
val message: String = "",
val authenticated: Boolean = false
) {
fun getStatusIcon(): String {
return when (status) {
ServerStatus.ONLINE -> if (authenticated) "✓" else "⚠"
ServerStatus.OFFLINE -> "○"
ServerStatus.UNREACHABLE -> "✗"
ServerStatus.TIMEOUT -> "⏱"
ServerStatus.AUTH_ERROR -> "🔒"
ServerStatus.SERVER_ERROR -> "⚠"
ServerStatus.UNKNOWN_ERROR -> "?"
}
}
fun getStatusColor(): Int {
return when (status) {
ServerStatus.ONLINE -> if (authenticated) android.R.color.holo_green_dark else android.R.color.holo_orange_dark
ServerStatus.OFFLINE -> android.R.color.darker_gray
ServerStatus.UNREACHABLE -> android.R.color.holo_red_dark
ServerStatus.TIMEOUT -> android.R.color.holo_orange_dark
ServerStatus.AUTH_ERROR -> android.R.color.holo_orange_dark
ServerStatus.SERVER_ERROR -> android.R.color.holo_red_dark
ServerStatus.UNKNOWN_ERROR -> android.R.color.holo_orange_dark
}
}
}
/**
* Result of authentication test
*/
data class AuthTestResult(
val authenticated: Boolean,
val message: String
)
@@ -0,0 +1,150 @@
package com.habitforge.companion
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.text.SimpleDateFormat
import java.util.*
/**
* Manages sync history for display in the UI
*/
class SyncHistoryManager(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("sync_history", Context.MODE_PRIVATE)
private val gson = Gson()
companion object {
private const val KEY_HISTORY = "sync_history_list"
private const val MAX_HISTORY_ITEMS = 50
}
/**
* Add a new sync record to history
*/
fun addSyncRecord(record: SyncRecord) {
val history = getHistory().toMutableList()
history.add(0, record) // Add to beginning
// Keep only last MAX_HISTORY_ITEMS
while (history.size > MAX_HISTORY_ITEMS) {
history.removeAt(history.size - 1)
}
saveHistory(history)
}
/**
* Get all sync history records
*/
fun getHistory(): List<SyncRecord> {
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<SyncRecord>>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
/**
* Get recent sync records (last N items)
*/
fun getRecentHistory(count: Int = 10): List<SyncRecord> {
return getHistory().take(count)
}
/**
* Clear all sync history
*/
fun clearHistory() {
prefs.edit().remove(KEY_HISTORY).apply()
}
/**
* Get sync statistics
*/
fun getStats(): SyncStats {
val history = getHistory()
val successful = history.count { it.status == SyncStatus.SUCCESS }
val failed = history.count { it.status == SyncStatus.FAILED }
val totalMetrics = history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.metricsSynced }
val totalExercises = history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.exercisesSynced }
return SyncStats(
totalSyncs = history.size,
successfulSyncs = successful,
failedSyncs = failed,
totalMetricsSynced = totalMetrics,
totalExercisesSynced = totalExercises,
lastSuccessfulSync = history.firstOrNull { it.status == SyncStatus.SUCCESS }?.timestamp
)
}
private fun saveHistory(history: List<SyncRecord>) {
val json = gson.toJson(history)
prefs.edit().putString(KEY_HISTORY, json).apply()
}
}
/**
* Represents a single sync operation record
*/
data class SyncRecord(
val timestamp: Long,
val status: SyncStatus,
val metricsSynced: Int = 0,
val exercisesSynced: Int = 0,
val errorMessage: String? = null,
val syncDurationMs: Long = 0
) {
fun getFormattedTimestamp(): String {
val dateFormat = SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault())
return dateFormat.format(Date(timestamp))
}
fun getStatusIcon(): String {
return when (status) {
SyncStatus.SUCCESS -> "✓"
SyncStatus.FAILED -> "✗"
SyncStatus.PARTIAL -> "⚠"
SyncStatus.IN_PROGRESS -> "⟳"
}
}
fun getStatusColor(): Int {
return when (status) {
SyncStatus.SUCCESS -> android.R.color.holo_green_dark
SyncStatus.FAILED -> android.R.color.holo_red_dark
SyncStatus.PARTIAL -> android.R.color.holo_orange_dark
SyncStatus.IN_PROGRESS -> android.R.color.holo_blue_dark
}
}
}
/**
* Sync status enumeration
*/
enum class SyncStatus {
SUCCESS,
FAILED,
PARTIAL,
IN_PROGRESS
}
/**
* Aggregated sync statistics
*/
data class SyncStats(
val totalSyncs: Int,
val successfulSyncs: Int,
val failedSyncs: Int,
val totalMetricsSynced: Int,
val totalExercisesSynced: Int,
val lastSuccessfulSync: Long?
) {
fun getSuccessRate(): Float {
return if (totalSyncs > 0) (successfulSyncs.toFloat() / totalSyncs) * 100 else 0f
}
}
@@ -0,0 +1,346 @@
package com.habitforge.companion
import android.content.Context
import android.provider.Settings
import android.util.Log
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.LocalDate
import java.util.concurrent.TimeUnit
/**
* Sync Service - Handles reading from Health Connect and pushing to backend
* Uses WorkManager for reliable background syncing
*/
class SyncService(
context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
companion object {
private const val TAG = "SyncService"
private const val WORK_NAME = "health_connect_sync"
private const val SYNC_DAYS = 365 // Sync last year of data to ensure history is complete
// Sync period options in minutes
const val SYNC_PERIOD_5_MINS = 5
const val SYNC_PERIOD_10_MINS = 10
const val SYNC_PERIOD_15_MINS = 15
const val SYNC_PERIOD_30_MINS = 30
const val SYNC_PERIOD_45_MINS = 45
const val SYNC_PERIOD_1_HOUR = 60
const val SYNC_PERIOD_3_HOURS = 180
const val SYNC_PERIOD_6_HOURS = 360
const val SYNC_PERIOD_12_HOURS = 720
const val SYNC_PERIOD_24_HOURS = 1440
// Preference keys
const val PREF_SYNC_PERIOD_MINUTES = "sync_period_minutes"
/**
* Schedule periodic sync with configurable interval
*/
fun schedulePeriodicSync(context: Context, periodMinutes: Int = getSyncPeriod(context)) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
// Note: WorkManager enforces a minimum interval of 15 minutes
val syncWork = PeriodicWorkRequestBuilder<SyncService>(
periodMinutes.toLong(), TimeUnit.MINUTES,
5, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE, // UPDATE to apply new period
syncWork
)
Log.i(TAG, "Periodic sync scheduled every $periodMinutes minutes")
// Save the period setting
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.edit()
.putInt(PREF_SYNC_PERIOD_MINUTES, periodMinutes)
.apply()
}
/**
* Trigger an immediate one-time sync
*/
fun syncNow(context: Context, days: Int = SYNC_DAYS) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncWork = OneTimeWorkRequestBuilder<SyncService>()
.setConstraints(constraints)
.setInputData(workDataOf("days" to days))
.build()
WorkManager.getInstance(context).enqueue(syncWork)
Log.i(TAG, "Immediate sync triggered for $days days")
}
/**
* Cancel all scheduled syncs
*/
fun cancelSync(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.i(TAG, "Sync cancelled")
}
/**
* Get the current sync period setting
*/
fun getSyncPeriod(context: Context): Int {
return context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
.getInt(PREF_SYNC_PERIOD_MINUTES, SYNC_PERIOD_6_HOURS)
}
/**
* Get available sync period options
*/
fun getSyncPeriodOptions(): List<SyncPeriodOption> {
return listOf(
SyncPeriodOption(SYNC_PERIOD_5_MINS, "Every 5 minutes (clamped to 15)"),
SyncPeriodOption(SYNC_PERIOD_10_MINS, "Every 10 minutes (clamped to 15)"),
SyncPeriodOption(SYNC_PERIOD_15_MINS, "Every 15 minutes"),
SyncPeriodOption(SYNC_PERIOD_30_MINS, "Every 30 minutes"),
SyncPeriodOption(SYNC_PERIOD_45_MINS, "Every 45 minutes"),
SyncPeriodOption(SYNC_PERIOD_1_HOUR, "Every hour"),
SyncPeriodOption(SYNC_PERIOD_3_HOURS, "Every 3 hours"),
SyncPeriodOption(SYNC_PERIOD_6_HOURS, "Every 6 hours"),
SyncPeriodOption(SYNC_PERIOD_12_HOURS, "Every 12 hours"),
SyncPeriodOption(SYNC_PERIOD_24_HOURS, "Once a day")
)
}
/**
* Get formatted period string
*/
fun getFormattedPeriod(context: Context): String {
val minutes = getSyncPeriod(context)
return getSyncPeriodOptions().find { it.minutes == minutes }?.label ?: "Every $minutes minutes"
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
Log.i(TAG, "Starting Health Connect sync...")
val startTime = System.currentTimeMillis()
val syncHistoryManager = SyncHistoryManager(applicationContext)
try {
val healthConnectManager = HabitForgeApp.instance.healthConnectManager
// Check availability
if (healthConnectManager.isAvailable() != HealthConnectAvailability.AVAILABLE) {
Log.e(TAG, "Health Connect not available")
recordSyncFailure(syncHistoryManager, startTime, "Health Connect not available")
return@withContext Result.failure()
}
// Check permissions
if (!healthConnectManager.hasAllPermissions()) {
Log.e(TAG, "Missing Health Connect permissions")
recordSyncFailure(syncHistoryManager, startTime, "Missing permissions")
return@withContext Result.retry()
}
// Get auth token from preferences
val prefs = applicationContext.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
val authToken = prefs.getString("auth_token", null)
if (authToken.isNullOrEmpty()) {
Log.e(TAG, "No auth token configured")
recordSyncFailure(syncHistoryManager, startTime, "No auth token")
return@withContext Result.failure()
}
// Determine sync range
val days = inputData.getInt("days", SYNC_DAYS)
val endDate = LocalDate.now()
val startDate = endDate.minusDays(days.toLong())
Log.i(TAG, "Reading data from $startDate to $endDate ($days days)")
// Read daily metrics
val dailyMetrics = healthConnectManager.readDailyMetrics(startDate, endDate)
Log.i(TAG, "Read ${dailyMetrics.size} days of metrics")
// Log sample data for debugging
if (dailyMetrics.isNotEmpty()) {
val sample = dailyMetrics.first()
Log.d(TAG, "Sample metric: date=${sample.date}, steps=${sample.steps}, calories=${sample.caloriesBurned}")
} else {
Log.w(TAG, "No daily metrics returned from Health Connect!")
}
// Read exercise sessions
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
Log.i(TAG, "Read ${exercises.size} exercise sessions")
if (exercises.isNotEmpty()) {
val sample = exercises.first()
Log.d(TAG, "Sample exercise: type=${sample.exerciseTypeName}, duration=${sample.durationMinutes}min")
}
// Convert to API payload
val metricsPayload = dailyMetrics.map { metric ->
DailyMetricsPayload(
date = metric.date,
step_count = metric.steps.toInt(),
distance_meters = metric.distanceMeters,
calories_burned = metric.caloriesBurned,
total_calories = metric.caloriesBurned,
avg_heart_rate = metric.avgHeartRate,
min_heart_rate = metric.minHeartRate,
max_heart_rate = metric.maxHeartRate,
resting_heart_rate = metric.restingHeartRate,
sleep_duration_minutes = metric.sleepDurationMinutes,
deep_sleep_minutes = metric.deepSleepMinutes,
light_sleep_minutes = metric.lightSleepMinutes,
rem_sleep_minutes = metric.remSleepMinutes,
awake_duration_minutes = metric.awakeMinutes,
weight = metric.weight,
avg_spo2 = metric.spo2,
min_spo2 = metric.spo2,
max_spo2 = metric.spo2
)
}
val exercisesPayload = exercises.map { exercise ->
ExerciseSessionPayload(
start_time = exercise.startTime,
end_time = exercise.endTime,
exercise_type = exercise.exerciseType,
exercise_type_name = exercise.exerciseTypeName,
duration_seconds = exercise.durationMinutes * 60,
title = exercise.title,
notes = exercise.notes
)
}
// Get device ID
val deviceId = Settings.Secure.getString(
applicationContext.contentResolver,
Settings.Secure.ANDROID_ID
)
// Build request
val request = HealthConnectSyncRequest(
device_id = deviceId,
sync_timestamp = Instant.now().toString(),
daily_metrics = metricsPayload,
exercise_sessions = exercisesPayload
)
// Log request details for debugging
Log.i(TAG, "=== Preparing Sync Request ===")
Log.i(TAG, "Payload: ${metricsPayload.size} metrics, ${exercisesPayload.size} exercises")
if (metricsPayload.isNotEmpty()) {
val sample = metricsPayload.first()
Log.d(TAG, "Metric payload sample: date=${sample.date}, steps=${sample.step_count}, calories=${sample.calories_burned}")
}
if (exercisesPayload.isNotEmpty()) {
val sample = exercisesPayload.first()
Log.d(TAG, "Exercise payload sample: type=${sample.exercise_type_name}, duration=${sample.duration_seconds}s")
}
// Send to backend
val backendUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
Log.i(TAG, "Sending to: $backendUrl/api/health-connect/sync")
val api = HabitForgeApi.create(backendUrl)
val response = api.syncHealthData("Bearer $authToken", request)
if (response.isSuccessful) {
val body = response.body()
Log.i(TAG, "=== Sync Response ===")
Log.i(TAG, "Status: ${body?.status}")
Log.i(TAG, "Sync ID: ${body?.sync_id}")
Log.i(TAG, "Metrics synced: ${body?.metrics_synced}")
Log.i(TAG, "Exercises synced: ${body?.exercises_synced}")
Log.i(TAG, "Message: ${body?.message}")
// Save last sync time
prefs.edit().putLong("last_sync", System.currentTimeMillis()).apply()
// Update metrics sync tracker with best values from last few days
if (dailyMetrics.isNotEmpty()) {
val sorted = dailyMetrics.sortedByDescending { it.date }
val stepsMetric = sorted.firstOrNull { it.steps > 0 } ?: sorted.first()
val bestDistance = sorted.firstOrNull { it.distanceMeters > 0 }?.distanceMeters ?: 0.0
val bestCalories = sorted.firstOrNull { it.caloriesBurned > 0 }?.caloriesBurned ?: 0.0
val metricsTracker = MetricsSyncTracker(applicationContext)
metricsTracker.updateFromSyncData(
steps = stepsMetric.steps,
distance = bestDistance,
calories = bestCalories,
avgHeartRate = sorted.mapNotNull { it.avgHeartRate }.filter { it > 0 }.firstOrNull(),
restingHeartRate = sorted.mapNotNull { it.restingHeartRate }.filter { it > 0 }.firstOrNull(),
sleepMinutes = sorted.filter { it.sleepDurationMinutes > 0 }.firstOrNull()?.sleepDurationMinutes,
weight = sorted.mapNotNull { it.weight }.filter { it > 0 }.firstOrNull(),
spo2 = sorted.mapNotNull { it.spo2 }.filter { it > 0 }.firstOrNull(),
exerciseCount = exercises.size
)
}
// Record successful sync in history
val duration = System.currentTimeMillis() - startTime
syncHistoryManager.addSyncRecord(
SyncRecord(
timestamp = System.currentTimeMillis(),
status = SyncStatus.SUCCESS,
metricsSynced = body?.metrics_synced ?: dailyMetrics.size,
exercisesSynced = body?.exercises_synced ?: exercises.size,
syncDurationMs = duration
)
)
return@withContext Result.success()
} else {
Log.e(TAG, "Sync failed with code: ${response.code()}")
recordSyncFailure(syncHistoryManager, startTime, "Server returned ${response.code()}")
return@withContext Result.retry()
}
} catch (e: Exception) {
Log.e(TAG, "Sync error", e)
recordSyncFailure(syncHistoryManager, startTime, e.localizedMessage ?: "Unknown error")
return@withContext Result.retry()
}
}
private fun recordSyncFailure(manager: SyncHistoryManager, startTime: Long, errorMessage: String) {
val duration = System.currentTimeMillis() - startTime
manager.addSyncRecord(
SyncRecord(
timestamp = System.currentTimeMillis(),
status = SyncStatus.FAILED,
errorMessage = errorMessage,
syncDurationMs = duration
)
)
}
}
/**
* Sync period option for UI selection
*/
data class SyncPeriodOption(
val minutes: Int,
val label: String
)
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#0ea5e9"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
<path
android:fillColor="#0ea5e9"
android:pathData="M7,13l2,2l6,-6"
android:strokeWidth="2"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0ea5e9"
android:pathData="M0,0h108v108h-108z"/>
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Heart shape -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,78.35l-2.18,-1.98C41.1,67.04 35,61.42 35,54.25c0,-5.82 4.64,-10.5 10.5,-10.5c3.26,0 6.39,1.52 8.5,3.93c2.11,-2.41 5.24,-3.93 8.5,-3.93c5.86,0 10.5,4.68 10.5,10.5c0,7.17 -6.1,12.79 -16.82,22.15L54,78.35z"/>
<!-- Pulse line -->
<path
android:strokeColor="#0ea5e9"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M35,54 L45,54 L48,48 L51,60 L54,51 L57,57 L60,54 L73,54"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F3F4F6" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#EFF6FF" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#ECFDF5" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FEF2F2" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#9CA3AF" />
<size
android:width="10dp"
android:height="10dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#10b981" />
<size
android:width="10dp"
android:height="10dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#f59e0b" />
<size
android:width="10dp"
android:height="10dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#ef4444" />
<size
android:width="10dp"
android:height="10dp" />
</shape>
@@ -0,0 +1,787 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F5F5"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="24dp">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_heart_pulse"
app:tint="#0ea5e9" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginStart="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HabitForge"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="#1F2937" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Health Connect Companion"
android:textSize="14sp"
android:textColor="#6B7280" />
</LinearLayout>
</LinearLayout>
<!-- Health Connect Status Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Health Connect"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/statusHealthConnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Checking..."
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1F2937"
android:layout_marginTop="4dp" />
<!-- Available Layout -->
<LinearLayout
android:id="@+id/layoutAvailable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<Button
android:id="@+id/btnOpenHealthConnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Open Health Connect"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.TextButton"
android:layout_marginTop="8dp" />
</LinearLayout>
<!-- Unavailable Layout -->
<LinearLayout
android:id="@+id/layoutUnavailable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvUnavailableMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Health Connect needs to be installed to sync your health data."
android:textSize="14sp"
android:textColor="#6B7280"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/btnInstallHealthConnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Install Health Connect"
android:textAllCaps="false"
android:layout_marginTop="8dp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Permissions Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Permissions"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/statusPermissions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Checking..."
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1F2937"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnRequestPermissions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Grant Permissions"
android:textAllCaps="false"
android:backgroundTint="#0ea5e9"
android:layout_marginTop="12dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Server Configuration Card with Status Indicator -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Server"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<!-- Server Availability Indicator -->
<LinearLayout
android:id="@+id/layoutServerIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="4dp">
<View
android:id="@+id/serverStatusDot"
android:layout_width="10dp"
android:layout_height="10dp"
android:background="@drawable/status_dot_gray" />
<TextView
android:id="@+id/tvServerAvailability"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unknown"
android:textSize="11sp"
android:textColor="#6B7280"
android:layout_marginStart="6dp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/statusServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Checking..."
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1F2937"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/tvServerUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#6B7280"
android:layout_marginTop="2dp" />
<!-- Server response time -->
<TextView
android:id="@+id/tvServerResponseTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="11sp"
android:textColor="#9CA3AF"
android:visibility="gone"
android:layout_marginTop="2dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="12dp">
<Button
android:id="@+id/btnConfigureServer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Login"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_marginEnd="8dp" />
<Button
android:id="@+id/btnTestConnection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Test Connection"
android:textAllCaps="false"
android:backgroundTint="#6366f1" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Sync Settings Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sync Settings"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sync Frequency:"
android:textSize="14sp"
android:textColor="#1F2937" />
<Spinner
android:id="@+id/spinnerSyncPeriod"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp" />
</LinearLayout>
<TextView
android:id="@+id/tvLastSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Never synced"
android:textSize="14sp"
android:textColor="#6B7280"
android:layout_marginTop="8dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp">
<Button
android:id="@+id/btnSyncNow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync Now"
android:textAllCaps="false"
android:backgroundTint="#10b981"
android:enabled="false" />
<ProgressBar
android:id="@+id/progressSync"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:visibility="gone"
android:indeterminateTint="#FFFFFF" />
</FrameLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Metrics Overview Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Metrics Overview"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/tvMetricsLastUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="#9CA3AF" />
</LinearLayout>
<TextView
android:id="@+id/tvMetricsSummary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="View all available health metrics"
android:textSize="14sp"
android:textColor="#6B7280"
android:layout_marginTop="4dp" />
<!-- Metrics Grid -->
<LinearLayout
android:id="@+id/layoutMetricsGrid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="12dp">
<!-- Row 1: Steps, Distance, Calories -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<include layout="@layout/item_metric_status"
android:id="@+id/metricSteps"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricDistance"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricCalories"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp" />
</LinearLayout>
<!-- Row 2: Heart Rate, Resting HR, Sleep -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<include layout="@layout/item_metric_status"
android:id="@+id/metricHeartRate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricRestingHR"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricSleep"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp" />
</LinearLayout>
<!-- Row 3: Weight, SpO2, Exercise -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<include layout="@layout/item_metric_status"
android:id="@+id/metricWeight"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricSpO2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp" />
<include layout="@layout/item_metric_status"
android:id="@+id/metricExercise"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp" />
</LinearLayout>
</LinearLayout>
<Button
android:id="@+id/btnRefreshMetrics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Refresh Metrics"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.TextButton"
android:layout_marginTop="8dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Data Compatibility Validation Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Data Compatibility"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/statusDataCompatibility"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Not validated"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1F2937"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/tvCompatibilityDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Validate that your Health Connect data can be synced with the HabitForge schema."
android:textSize="12sp"
android:textColor="#6B7280"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnValidateData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Validate Data Schema"
android:textAllCaps="false"
android:backgroundTint="#f59e0b"
android:layout_marginTop="12dp" />
<!-- Validation Results (hidden by default) -->
<LinearLayout
android:id="@+id/layoutValidationResults"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
android:layout_marginTop="12dp"
android:padding="12dp"
android:background="@drawable/rounded_background">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Validation Results"
android:textSize="12sp"
android:textStyle="bold"
android:textColor="#374151" />
<TextView
android:id="@+id/tvValidationResultsList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="#6B7280"
android:layout_marginTop="8dp"
android:fontFamily="monospace" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Sync History Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Sync History"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<TextView
android:id="@+id/tvSyncStats"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="#6B7280" />
</LinearLayout>
<!-- Stats Summary -->
<LinearLayout
android:id="@+id/layoutSyncStats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="12dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp"
android:background="@drawable/rounded_background_green">
<TextView
android:id="@+id/tvSuccessCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="#10b981" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Successful"
android:textSize="10sp"
android:textColor="#6B7280" />
</LinearLayout>
<View
android:layout_width="8dp"
android:layout_height="1dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp"
android:background="@drawable/rounded_background_red">
<TextView
android:id="@+id/tvFailedCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="#ef4444" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Failed"
android:textSize="10sp"
android:textColor="#6B7280" />
</LinearLayout>
<View
android:layout_width="8dp"
android:layout_height="1dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp"
android:background="@drawable/rounded_background_blue">
<TextView
android:id="@+id/tvMetricsSynced"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="#0ea5e9" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Metrics"
android:textSize="10sp"
android:textColor="#6B7280" />
</LinearLayout>
</LinearLayout>
<!-- Recent Sync List -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Recent Syncs"
android:textSize="11sp"
android:textColor="#6B7280"
android:layout_marginTop="16dp" />
<LinearLayout
android:id="@+id/layoutRecentSyncs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp">
<TextView
android:id="@+id/tvNoSyncHistory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No sync history yet"
android:textSize="12sp"
android:textColor="#9CA3AF"
android:gravity="center"
android:padding="16dp" />
</LinearLayout>
<Button
android:id="@+id/btnViewAllHistory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="View Full History"
android:textAllCaps="false"
style="@style/Widget.Material3.Button.TextButton"
android:visibility="gone"
android:layout_marginTop="8dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Info Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="This app reads your health data from Health Connect and syncs it to your HabitForge dashboard. Keep it installed for automatic background syncing."
android:textSize="12sp"
android:textColor="#9CA3AF"
android:textAlignment="center"
android:layout_marginTop="16dp" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/tvHistoryIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📊"
android:textSize="28sp"
android:layout_marginEnd="12dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvHistoryTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Metric History"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#1F2937" />
<TextView
android:id="@+id/tvHistorySubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="All synced data from Health Connect"
android:textSize="12sp"
android:textColor="#6B7280" />
</LinearLayout>
<ImageButton
android:id="@+id/btnCloseHistory"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:contentDescription="Close" />
</LinearLayout>
<!-- Summary Stats -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp"
android:background="#F3F4F6"
android:padding="12dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/tvHistoryDataPoints"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="#10B981" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Data Points"
android:textSize="11sp"
android:textColor="#6B7280" />
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#D1D5DB" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/tvHistoryLatestValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#3B82F6" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Latest Value"
android:textSize="11sp"
android:textColor="#6B7280" />
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#D1D5DB" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/tvHistoryAverage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#8B5CF6" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Average"
android:textSize="11sp"
android:textColor="#6B7280" />
</LinearLayout>
</LinearLayout>
<!-- History List -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:id="@+id/layoutHistoryList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</ScrollView>
</LinearLayout>
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<!-- Server URL -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Server URL"
android:textSize="12sp"
android:textColor="#666666"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etServerUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="http://192.168.x.x:8000"
android:inputType="textUri"
android:layout_marginBottom="16dp" />
<!-- Username -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Username"
android:textSize="12sp"
android:textColor="#666666"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter your username"
android:inputType="text"
android:autofillHints="username"
android:layout_marginBottom="16dp" />
<!-- Password -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Password"
android:textSize="12sp"
android:textColor="#666666"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter your password"
android:inputType="textPassword"
android:autofillHints="password"
android:layout_marginBottom="8dp" />
<!-- Helper text -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Use the same credentials as the HabitForge web app"
android:textSize="11sp"
android:textColor="#888888"
android:layout_marginTop="8dp" />
<!-- Error message (hidden by default) -->
<TextView
android:id="@+id/tvError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#D32F2F"
android:textSize="12sp"
android:visibility="gone"
android:layout_marginTop="8dp" />
<!-- Loading indicator (hidden by default) -->
<ProgressBar
android:id="@+id/progressLogin"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:visibility="gone"
android:layout_marginTop="16dp" />
</LinearLayout>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/tvHistoryDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Jan 01, 2026"
android:textSize="14sp"
android:textColor="#374151" />
<TextView
android:id="@+id/tvHistoryValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="10,000"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="#1F2937" />
<TextView
android:id="@+id/tvHistoryUnit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" steps"
android:textSize="12sp"
android:textColor="#6B7280"
android:layout_marginStart="4dp" />
</LinearLayout>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp"
android:background="@drawable/rounded_background">
<!-- Status indicator and icon -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvMetricIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="👣"
android:textSize="16sp" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvMetricStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⚪"
android:textSize="10sp" />
</LinearLayout>
<!-- Metric name -->
<TextView
android:id="@+id/tvMetricName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Steps"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="#374151"
android:layout_marginTop="4dp"
android:maxLines="1"
android:ellipsize="end" />
<!-- Last value or status -->
<TextView
android:id="@+id/tvMetricValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Not synced"
android:textSize="10sp"
android:textColor="#6B7280"
android:maxLines="1"
android:ellipsize="end" />
<!-- Last sync time -->
<TextView
android:id="@+id/tvMetricSyncTime"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="8sp"
android:textColor="#9CA3AF"
android:maxLines="1"
android:ellipsize="end"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="brand_primary">#0ea5e9</color>
<color name="brand_secondary">#10b981</color>
<color name="brand_dark">#0c4a6e</color>
<color name="background_light">#F5F5F5</color>
<color name="background_dark">#1F2937</color>
<color name="text_primary">#1F2937</color>
<color name="text_secondary">#6B7280</color>
<color name="text_muted">#9CA3AF</color>
<color name="success">#10b981</color>
<color name="warning">#f59e0b</color>
<color name="error">#ef4444</color>
<!-- Status colors used in MainActivity -->
<color name="green">#10b981</color>
<color name="orange">#f59e0b</color>
<color name="red">#ef4444</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">HabitForge-companion</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HabitForgeCompanion" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/brand_primary</item>
<item name="colorPrimaryVariant">@color/brand_dark</item>
<item name="colorSecondary">@color/brand_secondary</item>
<item name="android:statusBarColor">@color/brand_primary</item>
</style>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
Allow cleartext HTTP traffic for development.
In production, you should use HTTPS only.
-->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+3
View File
@@ -0,0 +1,3 @@
#Auto-incremented Build Number
#Sat Jan 03 11:40:28 EST 2026
build.number=18
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
+54
View File
@@ -0,0 +1,54 @@
Initialized native services in: C:\Users\bruno\.gradle\native
Initialized jansi services in: C:\Users\bruno\.gradle\native
The client will now receive all logging from the daemon (pid: 40576). The daemon log file: C:\Users\bruno\.gradle\daemon\8.13\daemon-40576.out.log
Starting 7th build in daemon [uptime: 3 mins 13.508 secs, performance: 100%, GC rate: 0.00/s, heap usage: 0% of 2 GiB]
Using 16 worker leases.
Now considering [C:\dev\git\python\HabitForge\android-companion] as hierarchies to watch
Watching the file system is configured to be enabled if available
File system watching is active
Starting Build
Settings evaluated using settings file 'C:\dev\git\python\HabitForge\android-companion\settings.gradle.kts'.
Projects loaded. Root project using build file 'C:\dev\git\python\HabitForge\android-companion\build.gradle.kts'.
Included projects: [root project 'HabitForgeCompanion', project ':app']
> Configure project :
Evaluating root project 'HabitForgeCompanion' using build file 'C:\dev\git\python\HabitForge\android-companion\build.gradle.kts'.
Resolved plugin [id: 'com.android.application', version: '8.13.2', apply: false]
Resolved plugin [id: 'org.jetbrains.kotlin.android', version: '1.9.22', apply: false]
> Configure project :app
Evaluating project ':app' using build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts'.
Resolved plugin [id: 'com.android.application']
Resolved plugin [id: 'org.jetbrains.kotlin.android']
[Incubating] Problems report is available at: file:///C:/dev/git/python/HabitForge/android-companion/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* Where:
Build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts' line: 6
* What went wrong:
An exception occurred applying plugin request [id: 'com.android.application']
> Failed to apply plugin 'com.android.internal.application'.
> Android Gradle plugin requires Java 17 to run. You are currently using Java 11.
Your current JDK is located in C:\Users\bruno\scoop\apps\temurin11-jdk\current
You can try some of the following options:
- changing the IDE settings.
- changing the JAVA_HOME environment variable.
- changing `org.gradle.java.home` in `gradle.properties`.
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
For more on this, please refer to https://docs.gradle.org/8.13/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
BUILD FAILED in 3s
Watched directory hierarchies: []
+6
View File
@@ -0,0 +1,6 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=35
Binary file not shown.
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+207
View File
@@ -0,0 +1,207 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var)}», «## {, «## }», and «## arg-)};
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" style style style style style style style style style
# style functions, like:
# foo() {
# ...
# }
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# temporary variable (which is, arguably, whose alarm alarm
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# temporary variable arguments, e.g. --show-version=demo.
#
# * Print the arguments one to a line, arguments arguments that contain
# temporary variable spaces be quoted (don't work if arguments contain newlines).
# Optionally, prepend each a "++desktop ...".
#
# * Use "set" to temporary variable
#
# arguments in a shell-escaped form by://github.com/gradle/gradle/issues/22390 .
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xeli" execution mode is enabled and for the a a a a a a
# a a a a a a a loop loop the
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "HabitForgeCompanion"
include(":app")
+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."""
+63
View File
@@ -0,0 +1,63 @@
"""Authentication endpoints for API v1."""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
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
)
return {"access_token": access_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
+215
View File
@@ -0,0 +1,215 @@
"""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)}")
@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."""
if ref_date is None:
ref_date = datetime.date.today()
if period == "day":
return db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id,
DailyHealthMetrics.date == ref_date
).all()
elif period == "week":
start_date = ref_date - datetime.timedelta(days=6)
return db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id,
DailyHealthMetrics.date.between(start_date, ref_date)
).order_by(DailyHealthMetrics.date.asc()).all()
elif period == "month":
start_date = ref_date - datetime.timedelta(days=29)
raw_data = db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id,
DailyHealthMetrics.date.between(start_date, ref_date)
).order_by(DailyHealthMetrics.date.asc()).all()
aggregated = []
import itertools
key_func = lambda x: x.date.isocalendar()[1]
for key, group in itertools.groupby(raw_data, key_func):
g = list(group)
first = g[0]
agg = {
"id": key,
"user_id": current_user.id,
"date": first.date,
"step_count": sum(x.step_count for x in g) // len(g),
"step_count_total": sum(x.step_count for x in g),
"calories_burned": sum(x.calories_burned for x in g) / len(g),
"distance_meters": sum(x.distance_meters for x in g) / len(g),
"sleep_duration_minutes": sum(x.sleep_duration_minutes for x in g) // len(g),
"deep_sleep_minutes": sum(x.deep_sleep_minutes for x in g) // len(g),
"light_sleep_minutes": sum(getattr(x, 'light_sleep_minutes', 0) for x in g) // len(g),
"rem_sleep_minutes": sum(getattr(x, 'rem_sleep_minutes', 0) for x in g) // len(g),
"awake_duration_minutes": sum(x.awake_duration_minutes for x in g) // len(g),
"pai_score": sum(x.pai_score for x in g) / len(g),
"avg_heart_rate": sum(x.avg_heart_rate or 0 for x in g) // len(g),
"avg_spo2": sum(x.avg_spo2 or 0 for x in g) / len(g),
"min_heart_rate": min((x.min_heart_rate for x in g if x.min_heart_rate), default=0),
"max_heart_rate": max((x.max_heart_rate for x in g if x.max_heart_rate), default=0)
}
aggregated.append(agg)
return aggregated
elif period == "year":
start_date = datetime.date.today() - datetime.timedelta(days=365)
raw_data = db.query(DailyHealthMetrics).filter(
DailyHealthMetrics.user_id == current_user.id,
DailyHealthMetrics.date >= start_date
).order_by(DailyHealthMetrics.date.asc()).all()
aggregated = []
import itertools
key_func = lambda x: x.date.month
for key, group in itertools.groupby(raw_data, key_func):
g = list(group)
first = g[0]
agg = {
"id": key,
"user_id": current_user.id,
"date": first.date.replace(day=1),
"step_count": sum(x.step_count for x in g) // len(g),
"step_count_total": sum(x.step_count for x in g),
"calories_burned": sum(x.calories_burned for x in g) / len(g),
"distance_meters": sum(x.distance_meters for x in g) / len(g),
"sleep_duration_minutes": sum(x.sleep_duration_minutes for x in g) // len(g),
"deep_sleep_minutes": sum(x.deep_sleep_minutes for x in g) // len(g),
"light_sleep_minutes": sum(getattr(x, 'light_sleep_minutes', 0) for x in g) // len(g),
"rem_sleep_minutes": sum(getattr(x, 'rem_sleep_minutes', 0) for x in g) // len(g),
"awake_duration_minutes": sum(x.awake_duration_minutes for x in g) // len(g),
"pai_score": sum(x.pai_score for x in g) / len(g),
"avg_heart_rate": sum(x.avg_heart_rate or 0 for x in g) // len(g),
"avg_spo2": sum(x.avg_spo2 or 0 for x in g) / len(g),
"min_heart_rate": min((x.min_heart_rate for x in g if x.min_heart_rate), default=0),
"max_heart_rate": max((x.max_heart_rate for x in g if x.max_heart_rate), default=0)
}
aggregated.append(agg)
return aggregated
return []
@router.get("/workouts")
def get_workouts(
limit: int = 50,
offset: int = 0,
activity_type: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Retrieve workout sessions for the current user."""
query = db.query(WorkoutSession).filter(
WorkoutSession.user_id == current_user.id
)
if activity_type:
query = query.filter(WorkoutSession.activity_type == activity_type)
workouts = query.order_by(
WorkoutSession.start_time.desc()
).offset(offset).limit(limit).all()
# Manually serialize to avoid Pydantic issues
return [
{
"id": 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
}
for w in workouts
]
@router.get("/workouts/summary")
def get_workout_summary(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get summary statistics for workouts."""
from sqlalchemy import func
# Total counts
total = db.query(func.count(WorkoutSession.id)).filter(
WorkoutSession.user_id == current_user.id
).scalar()
# By activity type
by_type = 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
).group_by(WorkoutSession.activity_type).all()
return {
"total_workouts": total,
"by_activity_type": [
{
"activity_type": row[0],
"count": row[1],
"total_duration_seconds": row[2] or 0,
"total_calories": row[3] or 0,
"total_distance_meters": row[4] or 0
}
for row in by_type
]
}
+367
View File
@@ -0,0 +1,367 @@
"""
Zepp API Router - Endpoints for Zepp/Amazfit data synchronization.
Provides endpoints for connecting to Zepp account and syncing health data.
"""
from datetime import date, datetime, timedelta
from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import DailyHealthMetrics, User, WorkoutSession
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
from backend.integrations.zepp_api import ZeppApiClient, ZeppApiError, sync_zepp_data
from backend import auth
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/zepp", tags=["zepp"])
# --- Request/Response Models ---
class ZeppConnectRequest(BaseModel):
"""Request to connect a Zepp account."""
email: EmailStr
password: str
class ZeppConnectResponse(BaseModel):
"""Response after connecting Zepp account."""
status: str
user_id: str
message: str
class ZeppSyncRequest(BaseModel):
"""Request to sync data from Zepp."""
days: int = 30
class ZeppSyncResponse(BaseModel):
"""Response after syncing Zepp data."""
status: str
days_synced: int
workouts_synced: int
message: str
class ZeppStatusResponse(BaseModel):
"""Response with Zepp connection status."""
connected: bool
zepp_user_id: Optional[str] = None
last_sync: Optional[datetime] = None
# --- In-memory token storage (per user) ---
# In production, this should be stored in the database
_zepp_clients: dict[int, ZeppApiClient] = {}
# --- Endpoints ---
@router.post("/connect", response_model=ZeppConnectResponse)
async def connect_zepp(
request: ZeppConnectRequest,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Connect user's Zepp account.
Authenticates with Huami servers and stores the session for data syncing.
"""
try:
client = ZeppApiClient(request.email, request.password)
auth_result = client.authenticate()
# Store client for this user
_zepp_clients[current_user.id] = client
logger.info(f"User {current_user.id} connected Zepp account: {auth_result['user_id']}")
return ZeppConnectResponse(
status="success",
user_id=auth_result["user_id"],
message="Successfully connected to Zepp account"
)
except ZeppApiError as e:
logger.warning(f"Zepp connection failed for user {current_user.id}: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e)
)
except Exception as e:
logger.error(f"Unexpected error connecting Zepp: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to connect to Zepp"
)
@router.post("/sync", response_model=ZeppSyncResponse)
async def sync_zepp(
request: ZeppSyncRequest = ZeppSyncRequest(),
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Sync health data from connected Zepp account.
Fetches daily metrics (steps, sleep, heart rate, SpO2) and workout sessions.
"""
# Check if user has connected their Zepp account
client = _zepp_clients.get(current_user.id)
if not client:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Zepp account not connected. Please connect first."
)
try:
# Fetch data from Zepp
sync_result = sync_zepp_data(client, days=request.days)
# Save daily metrics to database
metrics_saved = 0
for entry in sync_result.get("daily_metrics", []):
saved = _save_daily_metrics(db, current_user.id, entry)
if saved:
metrics_saved += 1
# Save workouts to database
workouts_saved = 0
for workout in sync_result.get("workouts", []):
saved = _save_workout(db, current_user.id, workout)
if saved:
workouts_saved += 1
db.commit()
logger.info(
f"User {current_user.id} synced {metrics_saved} days, {workouts_saved} workouts from Zepp"
)
return ZeppSyncResponse(
status="success",
days_synced=metrics_saved,
workouts_synced=workouts_saved,
message=f"Synced {metrics_saved} days of data and {workouts_saved} workouts"
)
except ZeppApiError as e:
logger.error(f"Zepp sync failed: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Zepp API error: {e}"
)
except Exception as e:
logger.error(f"Unexpected error during Zepp sync: {e}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to sync data from Zepp"
)
@router.get("/status", response_model=ZeppStatusResponse)
async def get_zepp_status(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""Get the current Zepp connection status."""
client = _zepp_clients.get(current_user.id)
if client and client.user_id:
# Get last sync time from database
last_metric = db.query(DailyHealthMetrics).filter_by(
user_id=current_user.id
).order_by(DailyHealthMetrics.date.desc()).first()
return ZeppStatusResponse(
connected=True,
zepp_user_id=client.user_id,
last_sync=last_metric.date if last_metric else None
)
return ZeppStatusResponse(connected=False)
@router.delete("/disconnect")
async def disconnect_zepp(
current_user: User = Depends(auth.get_current_user)
):
"""Disconnect Zepp account."""
if current_user.id in _zepp_clients:
del _zepp_clients[current_user.id]
logger.info(f"User {current_user.id} disconnected Zepp account")
return {"status": "success", "message": "Zepp account disconnected"}
# --- Helper Functions ---
def _save_daily_metrics(db: Session, user_id: int, entry: dict) -> bool:
"""Save or update daily health metrics from Zepp data."""
try:
date_str = entry.get("date")
if not date_str:
return False
# Parse date
if isinstance(date_str, str):
d_date = datetime.strptime(date_str[:10], "%Y-%m-%d").date()
else:
d_date = date_str
# Find or create metric record
metric = db.query(DailyHealthMetrics).filter_by(
user_id=user_id, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=user_id, date=d_date)
db.add(metric)
# Update activity metrics
if entry.get("steps"):
metric.step_count = entry["steps"]
if entry.get("distance"):
metric.distance_meters = entry["distance"]
if entry.get("calories"):
metric.calories_burned = entry["calories"]
# Update sleep metrics
sleep = entry.get("sleep", {})
if sleep:
if sleep.get("total_minutes"):
metric.sleep_duration_minutes = sleep["total_minutes"]
if sleep.get("deep_sleep_minutes"):
metric.deep_sleep_minutes = sleep["deep_sleep_minutes"]
if sleep.get("light_sleep_minutes"):
metric.light_sleep_minutes = sleep["light_sleep_minutes"]
if sleep.get("rem_sleep_minutes"):
metric.rem_sleep_minutes = sleep["rem_sleep_minutes"]
if sleep.get("awake_minutes"):
metric.awake_duration_minutes = sleep["awake_minutes"]
# Update heart rate metrics
hr = entry.get("heart_rate", {})
if hr:
if hr.get("avg"):
metric.avg_heart_rate = hr["avg"]
if hr.get("min"):
metric.min_heart_rate = hr["min"]
if hr.get("max"):
metric.max_heart_rate = hr["max"]
if hr.get("resting"):
metric.resting_heart_rate = hr["resting"]
# Update SpO2 metrics
spo2 = entry.get("spo2", {})
if spo2:
if spo2.get("avg"):
metric.avg_spo2 = spo2["avg"]
if spo2.get("min"):
metric.min_spo2 = spo2["min"]
if spo2.get("max"):
metric.max_spo2 = spo2["max"]
# Calculate simple PAI score
pai = 0.0
if metric.step_count:
pai += metric.step_count / 1000
if metric.calories_burned and metric.calories_burned > 0:
pai += metric.calories_burned / 50
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
pai += (metric.avg_heart_rate - 100) / 10
metric.pai_score = round(min(pai, 100), 1)
return True
except Exception as e:
logger.error(f"Error saving daily metrics: {e}")
return False
def _save_workout(db: Session, user_id: int, workout: dict) -> bool:
"""Save or update workout session from Zepp data."""
try:
# Extract workout data
start_time_str = workout.get("startTime") or workout.get("start_time")
if not start_time_str:
return False
# Parse start time
if isinstance(start_time_str, str):
# Handle various formats
start_time_str = start_time_str.split("+")[0].split("-0")[0]
try:
start_time = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
start_time = datetime.strptime(start_time_str[:19], "%Y-%m-%dT%H:%M:%S")
else:
start_time = start_time_str
# Check if workout already exists
existing = db.query(WorkoutSession).filter_by(
user_id=user_id, start_time=start_time
).first()
# Get workout type
type_id = int(workout.get("type", 0))
activity_name = ZEPP_ACTIVITY_TYPES.get(type_id, "Unknown")
# Duration
duration = int(workout.get("sportTime(s)", workout.get("duration", 0)))
# End time
end_time = start_time + timedelta(seconds=duration)
# Distance and calories
distance = float(workout.get("distance(m)", workout.get("distance", 0)))
calories = float(workout.get("calories(kcal)", workout.get("calories", 0)))
# Pace metrics (convert from /meter to min/km)
avg_pace = float(workout.get("avgPace(/meter)", workout.get("avg_pace", 0)))
min_pace = float(workout.get("minPace(/meter)", workout.get("min_pace", 0)))
max_pace = float(workout.get("maxPace(/meter)", workout.get("max_pace", 0)))
if existing:
# Update existing
existing.activity_type = activity_name
existing.activity_type_id = type_id
existing.duration_seconds = duration
existing.distance_meters = distance
existing.calories = calories
existing.avg_pace = avg_pace if avg_pace > 0 else None
existing.min_pace = min_pace if min_pace > 0 else None
existing.max_pace = max_pace if max_pace > 0 else None
else:
# Create new
workout_session = WorkoutSession(
user_id=user_id,
start_time=start_time,
end_time=end_time,
activity_type=activity_name,
activity_type_id=type_id,
duration_seconds=duration,
distance_meters=distance,
calories=calories,
avg_pace=avg_pace if avg_pace > 0 else None,
min_pace=min_pace if min_pace > 0 else None,
max_pace=max_pace if max_pace > 0 else None,
)
db.add(workout_session)
return True
except Exception as e:
logger.error(f"Error saving workout: {e}")
return False
+52
View File
@@ -0,0 +1,52 @@
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
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."""
+61
View File
@@ -0,0 +1,61 @@
"""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 = token_urlsafe(32)
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# 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: "google_fit", "health_connect", "dual" (both for comparison)
HEALTH_PROVIDER: str = "google_fit"
@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()
+14
View File
@@ -0,0 +1,14 @@
"""Integrations module for external APIs."""
from backend.integrations.zepp_api import (
ZeppApiClient,
ZeppApiError,
create_zepp_client,
sync_zepp_data
)
__all__ = [
"ZeppApiClient",
"ZeppApiError",
"create_zepp_client",
"sync_zepp_data",
]
+434
View File
@@ -0,0 +1,434 @@
"""
Zepp Data Import Module
Imports health data from Zepp/Mi Band/Amazfit export files into HabitForge database.
Supports: Activity, Sleep, Heart Rate, Body measurements, and Sport/Workouts.
"""
import csv
import glob
import os
import datetime
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
from backend.models import DailyHealthMetrics, User, WorkoutSession
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
from backend.database import SessionLocal, engine, Base
# --- Configuration ---
EXPORT_DIR = "export-zepp"
USER_ID = 1 # Default user ID since we are single user for now or running locally
def parse_date(date_str):
"""Parse YYYY-MM-DD date string."""
if not date_str:
return None
try:
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return None
def parse_datetime(dt_str):
"""Parse datetime string with timezone (e.g., 2025-12-28 18:25:54+0000)."""
if not dt_str:
return None
try:
# Remove timezone for simplicity
clean_str = dt_str.split('+')[0].split('-0')[0]
return datetime.datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def get_latest_export_dir(base_dir):
"""Find the numbered directory inside export-zepp."""
subdirs = glob.glob(os.path.join(base_dir, "*"))
if not subdirs:
return None
# Return the most recent one (sorted by name, which includes timestamp)
return sorted(subdirs)[-1]
def import_activity(session, export_path):
"""Import daily activity summary (steps, distance, calories)."""
activity_dir = os.path.join(export_path, "ACTIVITY")
csv_files = glob.glob(os.path.join(activity_dir, "*.csv"))
if not csv_files:
print("No Activity CSV found.")
return
count = 0
for csv_file in csv_files:
print(f"Processing Activity: {csv_file}")
with open(csv_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
d_date = parse_date(row.get('date'))
if not d_date:
continue
steps = int(row.get('steps', 0))
distance = float(row.get('distance', 0))
calories = float(row.get('calories', 0))
# Upsert
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
# Overwrite with Zepp data as it's authoritative
if steps > 0:
metric.step_count = steps
if distance > 0:
metric.distance_meters = distance
if calories > 0:
metric.calories_burned = calories
count += 1
session.commit()
print(f" Imported {count} activity records.")
def import_sleep(session, export_path):
"""Import sleep data with phases (deep, light, REM, awake)."""
sleep_dir = os.path.join(export_path, "SLEEP")
csv_files = glob.glob(os.path.join(sleep_dir, "*.csv"))
count = 0
for csv_file in csv_files:
print(f"Processing Sleep: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
d_date = parse_date(row.get('date'))
if not d_date:
continue
try:
deep = int(row.get('deepSleepTime', 0))
light = int(row.get('shallowSleepTime', 0))
wake = int(row.get('wakeTime', 0))
rem = int(row.get('REMTime', 0))
except ValueError:
continue
total_sleep = deep + light + rem
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
if total_sleep > 0:
metric.sleep_duration_minutes = total_sleep
metric.deep_sleep_minutes = deep
metric.light_sleep_minutes = light
metric.rem_sleep_minutes = rem
metric.awake_duration_minutes = wake
count += 1
session.commit()
print(f" Imported {count} sleep records.")
def import_heart_rate(session, export_path):
"""Import manual heart rate readings."""
hr_dir = os.path.join(export_path, "HEARTRATE")
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
daily_hr = {} # date -> [hr_values]
for csv_file in csv_files:
print(f"Processing Heart Rate: {csv_file}")
with open(csv_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
ts_str = row.get('time')
hr_val = int(row.get('heartRate', 0))
if not ts_str or hr_val <= 0:
continue
try:
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
d_date = dt.date()
if d_date not in daily_hr:
daily_hr[d_date] = []
daily_hr[d_date].append(hr_val)
except Exception:
pass
# Aggregate
for d_date, values in daily_hr.items():
if not values:
continue
avg_hr = sum(values) // len(values)
min_hr = min(values)
max_hr = max(values)
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.avg_heart_rate = avg_hr
metric.min_heart_rate = min_hr
metric.max_heart_rate = max_hr
session.commit()
print(f" Imported heart rate data for {len(daily_hr)} days.")
def import_heart_rate_auto(session, export_path):
"""Import automatic heart rate monitoring data."""
hr_dir = os.path.join(export_path, "HEARTRATE_AUTO")
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
if not csv_files:
print("No HEARTRATE_AUTO CSV found.")
return
daily_hr = {}
for csv_file in csv_files:
print(f"Processing Heart Rate Auto: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
date_str = row.get('date')
hr_val = int(row.get('heartRate', 0))
if not date_str or hr_val <= 0:
continue
try:
d_date = parse_date(date_str)
if not d_date:
continue
if d_date not in daily_hr:
daily_hr[d_date] = []
daily_hr[d_date].append(hr_val)
except Exception:
pass
# Aggregate and Save
for d_date, values in daily_hr.items():
if not values:
continue
avg_hr = sum(values) // len(values)
min_hr = min(values)
max_hr = max(values)
rhr = min_hr # Resting HR approximation
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.avg_heart_rate = avg_hr
metric.min_heart_rate = min_hr
metric.max_heart_rate = max_hr
metric.resting_heart_rate = rhr
session.commit()
print(f" Imported auto heart rate data for {len(daily_hr)} days.")
def import_body(session, export_path):
"""Import body measurements (weight, etc.)."""
body_dir = os.path.join(export_path, "BODY")
csv_files = glob.glob(os.path.join(body_dir, "*.csv"))
count = 0
for csv_file in csv_files:
print(f"Processing Body: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
ts_str = row.get('time')
w_str = row.get('weight', '0')
weight = float(w_str)
if not ts_str or weight <= 0:
continue
try:
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
d_date = dt.date()
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.weight = weight
count += 1
except Exception:
pass
session.commit()
print(f" Imported {count} body measurements.")
def import_sport(session, export_path):
"""Import sport/workout sessions."""
sport_dir = os.path.join(export_path, "SPORT")
csv_files = glob.glob(os.path.join(sport_dir, "*.csv"))
if not csv_files:
print("No SPORT CSV found.")
return
count = 0
for csv_file in csv_files:
print(f"Processing Sport: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
# Columns: type, startTime, sportTime(s), maxPace(/meter), minPace(/meter),
# distance(m), avgPace(/meter), calories(kcal)
for row in reader:
try:
type_id = int(row.get('type', 0))
start_time_str = row.get('startTime')
duration_seconds = int(row.get('sportTime(s)', 0))
distance = float(row.get('distance(m)', 0))
calories = float(row.get('calories(kcal)', 0))
max_pace = float(row.get('maxPace(/meter)', 0))
min_pace = float(row.get('minPace(/meter)', 0))
avg_pace = float(row.get('avgPace(/meter)', 0))
start_time = parse_datetime(start_time_str)
if not start_time:
continue
end_time = start_time + datetime.timedelta(seconds=duration_seconds)
activity_name = WorkoutSession.get_activity_name(type_id)
# Check if workout already exists (by start time and user)
existing = session.query(WorkoutSession).filter_by(
user_id=USER_ID, start_time=start_time
).first()
if existing:
# Update existing
existing.activity_type = activity_name
existing.activity_type_id = type_id
existing.duration_seconds = duration_seconds
existing.distance_meters = distance
existing.calories = calories
existing.avg_pace = avg_pace if avg_pace > 0 else None
existing.max_pace = max_pace if max_pace > 0 else None
existing.min_pace = min_pace if min_pace > 0 else None
else:
# Create new
workout = WorkoutSession(
user_id=USER_ID,
start_time=start_time,
end_time=end_time,
activity_type=activity_name,
activity_type_id=type_id,
duration_seconds=duration_seconds,
distance_meters=distance,
calories=calories,
avg_pace=avg_pace if avg_pace > 0 else None,
max_pace=max_pace if max_pace > 0 else None,
min_pace=min_pace if min_pace > 0 else None
)
session.add(workout)
count += 1
except Exception as e:
print(f" Error processing workout: {e}")
continue
session.commit()
print(f" Imported {count} workout sessions.")
def calculate_pai_scores(session):
"""Calculate PAI scores for all days based on activity and heart rate data."""
print("Calculating PAI scores...")
metrics = session.query(DailyHealthMetrics).filter_by(user_id=USER_ID).all()
for metric in metrics:
# Simple PAI calculation based on activity
# Real PAI requires continuous HR data, this is an approximation
pai = 0.0
# Base PAI from steps (rough approximation)
if metric.step_count:
pai += metric.step_count / 1000 # 1 PAI per 1000 steps
# Bonus from calories burned (beyond baseline)
if metric.calories_burned and metric.calories_burned > 0:
pai += metric.calories_burned / 50 # 1 PAI per 50 cal
# Bonus from elevated heart rate activities
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
intensity_bonus = (metric.avg_heart_rate - 100) / 10
pai += intensity_bonus
metric.pai_score = round(min(pai, 100), 1) # Cap at 100
session.commit()
print(f" Updated PAI scores for {len(metrics)} days.")
def main():
"""Main import function."""
print("=" * 60)
print("Starting Zepp Data Import...")
print("=" * 60)
session = SessionLocal()
try:
base_export = get_latest_export_dir(EXPORT_DIR)
if not base_export:
print(f"No export directory found in {EXPORT_DIR}")
return
print(f"Using export directory: {base_export}\n")
# Import all data types
import_activity(session, base_export)
import_sleep(session, base_export)
import_heart_rate(session, base_export)
import_heart_rate_auto(session, base_export)
import_body(session, base_export)
import_sport(session, base_export)
# Calculate derived metrics
calculate_pai_scores(session)
print("\n" + "=" * 60)
print("Import completed successfully!")
print("=" * 60)
except Exception as e:
print(f"\nAn error occurred: {e}")
import traceback
traceback.print_exc()
finally:
session.close()
if __name__ == "__main__":
main()
+472
View File
@@ -0,0 +1,472 @@
"""
Zepp/Huami API Client
Provides authentication and data fetching from the Zepp/Huami cloud API
for Amazfit and Mi Band devices.
Based on the Huami API technical documentation:
- Authentication: account.huami.com
- Data: api-mifit-de2.zepp.com (EU GDPR compliant)
"""
import base64
import datetime
import hashlib
import json
import logging
import uuid
from typing import Any, Dict, List, Optional, Tuple
import requests
from backend.core.config import settings
logger = logging.getLogger(__name__)
# --- Constants ---
AUTH_URL = "https://account.huami.com/v2/client/login"
API_BASE_EU = "https://api-mifit-de2.zepp.com"
API_BASE_GLOBAL = "https://api-mifit.huami.com"
# App identification (mimics official Zepp app)
APP_NAME = "com.huami.watch.hmwatchmanager"
CLIENT_ID = "HuaMi"
APP_VERSION = "6.9.3"
class ZeppApiError(Exception):
"""Custom exception for Zepp API errors."""
pass
class ZeppApiClient:
"""
Client for interacting with the Zepp/Huami API.
Handles authentication, token management, and data fetching.
"""
def __init__(self, email: str = None, password: str = None):
self.email = email
self.password = password
self.app_token: Optional[str] = None
self.login_token: Optional[str] = None
self.user_id: Optional[str] = None
self.token_expiry: Optional[datetime.datetime] = None
self.api_base = API_BASE_EU # Default to EU for GDPR
self._session = requests.Session()
self._session.headers.update({
"User-Agent": f"Zepp/{APP_VERSION} (Android)",
"Accept": "application/json",
})
def authenticate(self, email: str = None, password: str = None) -> Dict[str, Any]:
"""
Authenticate with the Huami/Zepp API.
Args:
email: Zepp account email
password: Zepp account password
Returns:
Dict containing user_id, app_token, and other session info
"""
email = email or self.email
password = password or self.password
if not email or not password:
raise ZeppApiError("Email and password are required")
# Generate unique device ID
device_id = uuid.uuid4().hex[:16].upper()
# Prepare authentication request
auth_data = {
"grant_type": "access_token",
"client_id": CLIENT_ID,
"app_name": APP_NAME,
"app_version": APP_VERSION,
"third_name": "huami",
"device_id": device_id,
"device_model": "android_phone",
"allow_registration": "false",
"dn": "api-user.huami.com,api-mifit-de2.zepp.com,app-analytics.huami.com",
"country_code": "DE",
"lang": "en",
"state": "REDIRECTION",
"redirect_uri": "https://s3-us-west-2.amazonws.com/hm-registration/successs498.html",
}
# Add credentials
auth_data["email"] = email
auth_data["password"] = password
headers = {
"Content-Type": "application/x-www-form-urlencoded",
}
try:
response = self._session.post(
AUTH_URL,
data=auth_data,
headers=headers,
timeout=30
)
response.raise_for_status()
result = response.json()
if "error_code" in result:
error_msg = result.get("message", "Authentication failed")
raise ZeppApiError(f"Auth error: {error_msg}")
# Extract tokens
token_info = result.get("token_info", {})
self.app_token = token_info.get("app_token")
self.login_token = token_info.get("login_token")
self.user_id = token_info.get("user_id")
if not self.app_token or not self.user_id:
raise ZeppApiError("Invalid authentication response: missing tokens")
# Store credentials for refresh
self.email = email
self.password = password
# Set token expiry (typically 30 days, be conservative with 7)
self.token_expiry = datetime.datetime.now() + datetime.timedelta(days=7)
logger.info(f"Successfully authenticated Zepp user: {self.user_id}")
return {
"user_id": self.user_id,
"app_token": self.app_token,
"login_token": self.login_token,
}
except requests.exceptions.RequestException as e:
logger.error(f"Zepp authentication request failed: {e}")
raise ZeppApiError(f"Network error during authentication: {e}")
def _ensure_authenticated(self):
"""Ensure we have valid tokens, re-authenticate if needed."""
if not self.app_token or not self.user_id:
if self.email and self.password:
self.authenticate()
else:
raise ZeppApiError("Not authenticated. Call authenticate() first.")
# Check token expiry
if self.token_expiry and datetime.datetime.now() > self.token_expiry:
logger.info("Token expired, re-authenticating...")
self.authenticate()
def _make_request(self, endpoint: str, params: Dict = None, method: str = "GET") -> Dict:
"""
Make an authenticated request to the Zepp API.
"""
self._ensure_authenticated()
url = f"{self.api_base}{endpoint}"
headers = {
"apptoken": self.app_token,
"Accept": "application/json",
}
try:
if method == "GET":
response = self._session.get(url, params=params, headers=headers, timeout=30)
else:
response = self._session.post(url, data=params, headers=headers, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Zepp API request failed: {e}")
raise ZeppApiError(f"API request failed: {e}")
def fetch_band_data(
self,
from_date: datetime.date,
to_date: datetime.date,
query_type: str = "summary"
) -> Dict[str, Any]:
"""
Fetch daily health data (steps, sleep, heart rate).
Args:
from_date: Start date
to_date: End date
query_type: "summary" for daily totals, "detail" for minute-by-minute
Returns:
Dict containing decoded health data
"""
params = {
"query_type": query_type,
"from_date": from_date.strftime("%Y-%m-%d"),
"to_date": to_date.strftime("%Y-%m-%d"),
"userid": self.user_id,
"device_type": "android_phone",
}
response = self._make_request("/v1/data/band_data.json", params)
if response.get("code") != 1:
raise ZeppApiError(f"band_data request failed: {response.get('message')}")
# Decode the Base64 summary data
data_list = response.get("data", [])
decoded_data = []
for entry in data_list:
decoded_entry = self._decode_band_data_entry(entry)
if decoded_entry:
decoded_data.append(decoded_entry)
return {"data": decoded_data, "raw_response": response}
def _decode_band_data_entry(self, entry: Dict) -> Optional[Dict]:
"""
Decode a single band_data entry with Base64 encoded fields.
"""
try:
date_str = entry.get("date_time", entry.get("date"))
if not date_str:
return None
result = {
"date": date_str,
"summary": {},
}
# Decode summary field (Base64 JSON)
summary_b64 = entry.get("summary")
if summary_b64:
try:
summary_json = base64.b64decode(summary_b64).decode("utf-8")
result["summary"] = json.loads(summary_json)
except Exception as e:
logger.warning(f"Failed to decode summary: {e}")
# Extract steps data
stp = result["summary"].get("stp", {})
result["steps"] = stp.get("ttl", 0)
result["distance"] = stp.get("dis", 0)
result["calories"] = stp.get("cal", 0)
# Extract sleep data
slp = result["summary"].get("slp", {})
if slp:
result["sleep"] = {
"start_time": slp.get("st"),
"end_time": slp.get("ed"),
"deep_sleep_minutes": slp.get("dp", 0),
"light_sleep_minutes": slp.get("lt", 0),
"rem_sleep_minutes": slp.get("rm", 0),
"awake_minutes": slp.get("wk", 0),
}
# Calculate total
result["sleep"]["total_minutes"] = (
result["sleep"]["deep_sleep_minutes"] +
result["sleep"]["light_sleep_minutes"] +
result["sleep"]["rem_sleep_minutes"]
)
# Decode heart rate blob if present
hr_blob = entry.get("data_hr")
if hr_blob:
hr_values = self._decode_heart_rate_blob(hr_blob)
if hr_values:
valid_hr = [v for v in hr_values if 30 < v < 220]
if valid_hr:
result["heart_rate"] = {
"avg": sum(valid_hr) // len(valid_hr),
"min": min(valid_hr),
"max": max(valid_hr),
"resting": min(valid_hr), # Approximation
}
# Decode SpO2 if present
spo2_blob = entry.get("data_spo2")
if spo2_blob:
spo2_values = self._decode_spo2_blob(spo2_blob)
if spo2_values:
valid_spo2 = [v for v in spo2_values if 70 < v <= 100]
if valid_spo2:
result["spo2"] = {
"avg": sum(valid_spo2) / len(valid_spo2),
"min": min(valid_spo2),
"max": max(valid_spo2),
}
return result
except Exception as e:
logger.error(f"Error decoding band_data entry: {e}")
return None
def _decode_heart_rate_blob(self, b64_data: str) -> List[int]:
"""
Decode Base64-encoded heart rate binary data.
Format: sequence of 1-byte or 2-byte integers.
"""
try:
raw = base64.b64decode(b64_data)
# Try single-byte format first (most common)
values = list(raw)
# Filter out invalid values (0, 255 are typically "no reading")
return [v for v in values if v > 0 and v < 255]
except Exception as e:
logger.warning(f"Failed to decode HR blob: {e}")
return []
def _decode_spo2_blob(self, b64_data: str) -> List[float]:
"""
Decode Base64-encoded SpO2 binary data.
"""
try:
raw = base64.b64decode(b64_data)
# SpO2 is typically stored as single bytes (percentage)
values = list(raw)
return [float(v) for v in values if v > 0]
except Exception as e:
logger.warning(f"Failed to decode SpO2 blob: {e}")
return []
def fetch_workout_history(self, days: int = 30) -> List[Dict]:
"""
Fetch workout/sport session history.
Args:
days: Number of days of history to fetch
Returns:
List of workout sessions
"""
from_date = datetime.date.today() - datetime.timedelta(days=days)
params = {
"from_date": from_date.strftime("%Y-%m-%d"),
"to_date": datetime.date.today().strftime("%Y-%m-%d"),
"userid": self.user_id,
}
try:
response = self._make_request("/v1/sport/run/history.json", params)
if response.get("code") != 1:
logger.warning(f"Workout history request failed: {response}")
return []
return response.get("data", {}).get("summary", [])
except ZeppApiError as e:
logger.warning(f"Failed to fetch workout history: {e}")
return []
def fetch_workout_detail(self, track_id: str) -> Optional[Dict]:
"""
Fetch detailed workout data including GPS track.
Args:
track_id: The workout track ID
Returns:
Dict with workout details and GPS data
"""
params = {
"trackid": track_id,
"source": "run.mifit.huami.com",
}
try:
response = self._make_request("/v1/sport/run/detail.json", params)
if response.get("code") != 1:
return None
data = response.get("data", {})
# Parse GPS coordinates if present
gps_str = data.get("longitude_latitude", "")
if gps_str:
data["gps_track"] = self._parse_gps_string(gps_str)
return data
except ZeppApiError as e:
logger.warning(f"Failed to fetch workout detail {track_id}: {e}")
return None
def _parse_gps_string(self, gps_str: str) -> List[Tuple[float, float]]:
"""
Parse Zepp's proprietary GPS format.
Format: lat,lon;lat,lon;lat,lon...
"""
points = []
try:
for point_str in gps_str.split(";"):
if "," in point_str:
parts = point_str.split(",")
if len(parts) >= 2:
lat = float(parts[0])
lon = float(parts[1])
points.append((lat, lon))
except Exception as e:
logger.warning(f"Failed to parse GPS string: {e}")
return points
# --- Convenience Functions ---
def create_zepp_client(email: str, password: str) -> ZeppApiClient:
"""Create and authenticate a Zepp API client."""
client = ZeppApiClient(email, password)
client.authenticate()
return client
def sync_zepp_data(
client: ZeppApiClient,
days: int = 30
) -> Dict[str, Any]:
"""
Sync all available data from Zepp.
Args:
client: Authenticated ZeppApiClient
days: Number of days to sync
Returns:
Dict with synced data counts
"""
from_date = datetime.date.today() - datetime.timedelta(days=days)
to_date = datetime.date.today()
results = {
"daily_metrics": [],
"workouts": [],
"days_synced": 0,
"workouts_synced": 0,
}
# Fetch band data (daily metrics)
try:
band_data = client.fetch_band_data(from_date, to_date)
results["daily_metrics"] = band_data.get("data", [])
results["days_synced"] = len(results["daily_metrics"])
except ZeppApiError as e:
logger.error(f"Failed to fetch band data: {e}")
# Fetch workouts
try:
workouts = client.fetch_workout_history(days)
results["workouts"] = workouts
results["workouts_synced"] = len(workouts)
except ZeppApiError as e:
logger.error(f"Failed to fetch workouts: {e}")
return results
+69
View File
@@ -0,0 +1,69 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
import os
from backend.core.config import settings
from backend.api.v1 import (
auth as auth_router,
challenges as challenges_router,
health as health_router,
zepp as zepp_router
)
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
# Rate limiting setup
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Create database tables on startup
@app.on_event("startup")
def on_startup():
from backend.database import Base, engine
from backend.models import User, Challenge, Tracking, DailyHealthMetrics, WorkoutSession
Base.metadata.create_all(bind=engine)
# CORS - Use configured origins
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=settings.CORS_ALLOW_METHODS,
allow_headers=settings.CORS_ALLOW_HEADERS,
)
# Include API v1 routers
app.include_router(auth_router.router)
app.include_router(challenges_router.router)
app.include_router(health_router.router)
app.include_router(zepp_router.router)
# Middleware to drop unexpected WebSocket connections (Fixes StaticFiles crash)
@app.middleware("http")
async def reject_websockets(request, call_next):
return await call_next(request)
from starlette.websockets import WebSocket
@app.websocket("/{path:path}")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
await websocket.close()
@app.get("/api/static/images")
def get_static_images():
"""Retrieve list of available static images."""
static_dir = os.path.join("frontend", "static")
images = []
if os.path.exists(static_dir):
for filename in os.listdir(static_dir):
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
images.append(filename)
return images
# Mount static files last to avoid intercepting API calls
app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend")
+179
View File
@@ -0,0 +1,179 @@
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, Text, Float, DateTime
from sqlalchemy.orm import relationship
from .database import Base
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
# --- SQLAlchemy Models ---
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
challenges = relationship("Challenge", back_populates="owner")
health_metrics = relationship("DailyHealthMetrics", back_populates="user")
workouts = relationship("WorkoutSession", back_populates="user")
class Challenge(Base):
__tablename__ = "challenges"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
period_type = Column(String) # 'week', 'month', 'year'
daily_target = Column(Integer)
start_date = Column(Date)
end_date = Column(Date)
created_at = Column(Date, default=date.today)
display_order = Column(Integer, default=0)
user_id = Column(Integer, ForeignKey("users.id")) # New FK
unit_type = Column(String, default="reps") # New: 'reps', 'minutes', 'pages', 'km'
rest_days = Column(String, nullable=True) # New: comma-separated list of days
icon = Column(String, default="fa-dumbbell") # New
image_url = Column(String, nullable=True) # New
description = Column(String, nullable=True) # New
frequency_count = Column(Integer, nullable=True) # New
selected_days = Column(String, nullable=True) # New
owner = relationship("User", back_populates="challenges") # New Rel
trackings = relationship("Tracking", back_populates="challenge", cascade="all, delete-orphan")
class Tracking(Base):
__tablename__ = "tracking"
id = Column(Integer, primary_key=True, index=True)
challenge_id = Column(Integer, ForeignKey("challenges.id"))
date = Column(Date)
reps = Column(Integer, default=0)
completed = Column(Boolean, default=False)
notes = Column(Text, nullable=True)
challenge = relationship("Challenge", back_populates="trackings")
class DailyHealthMetrics(Base):
__tablename__ = "daily_health_metrics"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
date = Column(Date, index=True)
step_count = Column(Integer, default=0)
distance_meters = Column(Float, default=0.0)
calories_burned = Column(Float, default=0.0)
sleep_duration_minutes = Column(Integer, default=0)
deep_sleep_minutes = Column(Integer, default=0)
light_sleep_minutes = Column(Integer, default=0) # New
rem_sleep_minutes = Column(Integer, default=0) # New
awake_duration_minutes = Column(Integer, default=0)
avg_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
avg_spo2 = Column(Float, nullable=True)
pai_score = Column(Float, default=0.0)
weight = Column(Float, nullable=True) # New
resting_heart_rate = Column(Integer, nullable=True) # New
hrv = Column(Float, nullable=True) # New
user = relationship("User", back_populates="health_metrics")
class WorkoutSession(Base):
__tablename__ = "workout_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
start_time = Column(DateTime)
end_time = Column(DateTime)
activity_type = Column(String)
calories = Column(Float, default=0.0)
avg_hr = Column(Integer, nullable=True)
user = relationship("User", back_populates="workouts")
# --- Pydantic Schemas ---
class TrackingBase(BaseModel):
date: date
reps: int
completed: bool = False
notes: Optional[str] = None
class TrackingCreate(TrackingBase):
challenge_id: int
class TrackingResponse(TrackingBase):
id: int
challenge_id: int
class Config:
from_attributes = True
class ChallengeBase(BaseModel):
name: str
period_type: str
daily_target: int
start_date: date
end_date: Optional[date] = None
unit_type: Optional[str] = "reps"
rest_days: Optional[str] = None
icon: Optional[str] = "fa-dumbbell"
image_url: Optional[str] = None
description: Optional[str] = None
frequency_count: Optional[int] = None # For Monthly frequency (X times per month)
selected_days: Optional[str] = None # For Weekly frequency (Mon,Wed,Fri)
class ChallengeCreate(ChallengeBase):
pass
class ChallengeResponse(ChallengeBase):
id: int
created_at: date
display_order: int = 0
trackings: List[TrackingResponse] = []
class Config:
from_attributes = True
class DailyHealthMetricsBase(BaseModel):
date: date
step_count: Optional[int] = 0
distance_meters: Optional[float] = 0.0
calories_burned: Optional[float] = 0.0
sleep_duration_minutes: Optional[int] = 0
deep_sleep_minutes: Optional[int] = 0
light_sleep_minutes: Optional[int] = 0
rem_sleep_minutes: Optional[int] = 0
awake_duration_minutes: Optional[int] = 0
avg_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
avg_spo2: Optional[float] = None
pai_score: Optional[float] = 0.0
weight: Optional[float] = None
resting_heart_rate: Optional[int] = None
hrv: Optional[float] = None
class DailyHealthMetricsResponse(DailyHealthMetricsBase):
id: int
user_id: int
class Config:
from_attributes = True
# Auth Schemas
class UserCreate(BaseModel):
username: str
password: str
class UserResponse(BaseModel):
id: int
username: str
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
+8
View File
@@ -0,0 +1,8 @@
"""SQLAlchemy models module."""
from backend.models.user import User
from backend.models.challenge import Challenge
from backend.models.tracking import Tracking
from backend.models.daily_health_metrics import DailyHealthMetrics
from backend.models.workout_session import WorkoutSession
__all__ = ["User", "Challenge", "Tracking", "DailyHealthMetrics", "WorkoutSession"]
+30
View File
@@ -0,0 +1,30 @@
"""Challenge SQLAlchemy model."""
from sqlalchemy import Column, Integer, String, Date, ForeignKey
from sqlalchemy.orm import relationship
from datetime import date
from backend.database import Base
class Challenge(Base):
"""Challenge model for tracking habits."""
__tablename__ = "challenges"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
period_type = Column(String) # 'week', 'month', 'year'
daily_target = Column(Integer)
start_date = Column(Date)
end_date = Column(Date)
created_at = Column(Date, default=date.today)
display_order = Column(Integer, default=0)
user_id = Column(Integer, ForeignKey("users.id"))
unit_type = Column(String, default="reps") # 'reps', 'minutes', 'pages', 'km'
rest_days = Column(String, nullable=True) # comma-separated list of days
icon = Column(String, default="fa-dumbbell")
image_url = Column(String, nullable=True)
description = Column(String, nullable=True)
frequency_count = Column(Integer, nullable=True)
selected_days = Column(String, nullable=True)
owner = relationship("User", back_populates="challenges")
trackings = relationship("Tracking", back_populates="challenge", cascade="all, delete-orphan")
+37
View File
@@ -0,0 +1,37 @@
"""DailyHealthMetrics SQLAlchemy model."""
from sqlalchemy import Column, Integer, Date, Float, ForeignKey, UniqueConstraint, String
from sqlalchemy.orm import relationship
from backend.database import Base
class DailyHealthMetrics(Base):
"""Daily health metrics model for tracking health data."""
__tablename__ = "daily_health_metrics"
__table_args__ = (
UniqueConstraint('user_id', 'date', name='uq_user_date'),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
date = Column(Date, index=True)
step_count = Column(Integer, default=0)
distance_meters = Column(Float, default=0.0)
calories_burned = Column(Float, default=0.0)
sleep_duration_minutes = Column(Integer, default=0)
deep_sleep_minutes = Column(Integer, default=0)
light_sleep_minutes = Column(Integer, default=0)
rem_sleep_minutes = Column(Integer, default=0)
awake_duration_minutes = Column(Integer, default=0)
avg_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
avg_spo2 = Column(Float, nullable=True)
pai_score = Column(Float, default=0.0)
weight = Column(Float, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
hrv = Column(Float, nullable=True)
# Data provenance tracking
data_source = Column(String(50), default="google_fit") # "google_fit", "health_connect", "manual"
user = relationship("User", back_populates="health_metrics")
+177
View File
@@ -0,0 +1,177 @@
"""
Health Connect Data Models - Storage for data pushed from Android companion app.
These models store health data received from the Health Connect Android app,
enabling the HealthConnectProvider to serve data to the API layer.
"""
from sqlalchemy import Column, Integer, String, Float, Date, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from backend.database import Base
import datetime
class HealthConnectSyncLog(Base):
"""
Tracks sync operations from the Android companion app.
Used for debugging and monitoring data flow.
"""
__tablename__ = "health_connect_sync_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
device_id = Column(String(100)) # Android device identifier
sync_timestamp = Column(DateTime, default=datetime.datetime.utcnow)
records_pushed = Column(Integer, default=0)
status = Column(String(50)) # "success", "partial", "error"
error_message = Column(Text, nullable=True)
user = relationship("User")
class HealthConnectDailyMetrics(Base):
"""
Daily health metrics received from Health Connect.
Mirrors DailyHealthMetrics but with Health Connect-specific metadata.
"""
__tablename__ = "health_connect_daily_metrics"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
date = Column(Date, index=True)
# Activity metrics
step_count = Column(Integer, default=0)
distance_meters = Column(Float, default=0.0)
calories_burned = Column(Float, default=0.0) # ActiveCaloriesBurnedRecord
total_calories = Column(Float, default=0.0) # TotalCaloriesBurnedRecord
# Sleep metrics (from SleepSessionRecord)
sleep_duration_minutes = Column(Integer, default=0)
deep_sleep_minutes = Column(Integer, default=0)
light_sleep_minutes = Column(Integer, default=0)
rem_sleep_minutes = Column(Integer, default=0)
awake_duration_minutes = Column(Integer, default=0)
# Heart rate metrics (from HeartRateRecord)
avg_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
# SpO2 (from OxygenSaturationRecord)
avg_spo2 = Column(Float, nullable=True)
min_spo2 = Column(Float, nullable=True)
max_spo2 = Column(Float, nullable=True)
# Body metrics
weight = Column(Float, nullable=True) # WeightRecord
# Metadata
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
user = relationship("User")
class HealthConnectExerciseSession(Base):
"""
Exercise sessions from Health Connect ExerciseSessionRecord.
"""
__tablename__ = "health_connect_exercise_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
# Session timing
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
# Exercise type (Health Connect ExerciseType enum)
exercise_type = Column(Integer) # Health Connect type ID
exercise_type_name = Column(String(100)) # Human-readable name
# Metrics
duration_seconds = Column(Integer, default=0)
distance_meters = Column(Float, nullable=True)
calories_burned = Column(Float, nullable=True)
# Heart rate during exercise
avg_heart_rate = Column(Integer, nullable=True)
max_heart_rate = Column(Integer, nullable=True)
min_heart_rate = Column(Integer, nullable=True)
# Additional metrics
steps = Column(Integer, nullable=True)
elevation_gained = Column(Float, nullable=True)
# Metadata
notes = Column(Text, nullable=True)
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
user = relationship("User")
# Health Connect Exercise Type mapping (subset)
HEALTH_CONNECT_EXERCISE_TYPES = {
0: "Unknown",
2: "Badminton",
4: "Baseball",
5: "Basketball",
8: "Biking",
9: "Biking Stationary",
10: "Boot Camp",
11: "Boxing",
13: "Calisthenics",
14: "Cricket",
16: "Dancing",
25: "Elliptical",
26: "Exercise Class",
27: "Fencing",
28: "Football American",
29: "Football Australian",
31: "Frisbee Disc",
32: "Golf",
33: "Guided Breathing",
34: "Gymnastics",
35: "Handball",
36: "High Intensity Interval Training",
37: "Hiking",
38: "Ice Hockey",
39: "Ice Skating",
44: "Martial Arts",
46: "Paddling",
48: "Pilates",
50: "Racquetball",
51: "Rock Climbing",
52: "Roller Hockey",
53: "Rowing",
54: "Rowing Machine",
55: "Rugby",
56: "Running",
57: "Running Treadmill",
58: "Sailing",
59: "Scuba Diving",
60: "Skating",
61: "Skiing",
62: "Snowboarding",
63: "Snowshoeing",
64: "Soccer",
65: "Softball",
67: "Squash",
68: "Stair Climbing",
69: "Stair Climbing Machine",
70: "Strength Training",
71: "Stretching",
72: "Surfing",
73: "Swimming Open Water",
74: "Swimming Pool",
75: "Table Tennis",
76: "Tennis",
78: "Volleyball",
79: "Walking",
80: "Water Polo",
81: "Weightlifting",
82: "Wheelchair",
84: "Yoga",
}
+18
View File
@@ -0,0 +1,18 @@
"""Tracking SQLAlchemy model."""
from sqlalchemy import Column, Integer, Date, Boolean, Text, ForeignKey
from sqlalchemy.orm import relationship
from backend.database import Base
class Tracking(Base):
"""Tracking model for recording challenge progress."""
__tablename__ = "tracking"
id = Column(Integer, primary_key=True, index=True)
challenge_id = Column(Integer, ForeignKey("challenges.id"))
date = Column(Date)
reps = Column(Integer, default=0)
completed = Column(Boolean, default=False)
notes = Column(Text, nullable=True)
challenge = relationship("Challenge", back_populates="trackings")
+17
View File
@@ -0,0 +1,17 @@
"""User SQLAlchemy model."""
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from backend.database import Base
class User(Base):
"""User model for authentication and ownership."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
challenges = relationship("Challenge", back_populates="owner")
health_metrics = relationship("DailyHealthMetrics", back_populates="user")
workouts = relationship("WorkoutSession", back_populates="user")
+48
View File
@@ -0,0 +1,48 @@
"""WorkoutSession SQLAlchemy model."""
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from backend.database import Base
# Zepp activity type mapping
ZEPP_ACTIVITY_TYPES = {
1: "Running",
6: "Walking",
8: "Cycling",
10: "Elliptical",
16: "Free Training",
23: "HIIT",
52: "Stretching",
60: "Yoga",
}
class WorkoutSession(Base):
"""Workout data model."""
__tablename__ = "workout_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
start_time = Column(DateTime, index=True)
end_time = Column(DateTime)
activity_type = Column(String) # Human readable name
activity_type_id = Column(Integer, nullable=True) # Original Zepp type ID
duration_seconds = Column(Integer, default=0)
distance_meters = Column(Float, default=0.0)
calories = Column(Float, default=0.0)
avg_pace = Column(Float, nullable=True) # seconds per meter
max_pace = Column(Float, nullable=True)
min_pace = Column(Float, nullable=True)
avg_hr = Column(Integer, nullable=True)
max_hr = Column(Integer, nullable=True)
min_hr = Column(Integer, nullable=True)
notes = Column(String, nullable=True)
# Data provenance tracking
data_source = Column(String(50), default="google_fit") # "google_fit", "health_connect", "manual"
user = relationship("User", back_populates="workouts")
@staticmethod
def get_activity_name(type_id: int) -> str:
return ZEPP_ACTIVITY_TYPES.get(type_id, f"Activity {type_id}")
+1
View File
@@ -0,0 +1 @@
"""Repositories module for data access layer."""
+49
View File
@@ -0,0 +1,49 @@
"""Base repository for common database operations."""
from typing import Generic, TypeVar, Type, List, Optional
from sqlalchemy.orm import Session
ModelType = TypeVar("ModelType")
class BaseRepository(Generic[ModelType]):
"""Base repository with common CRUD operations."""
def __init__(self, model: Type[ModelType], db: Session):
"""Initialize repository with model and database session."""
self.model = model
self.db = db
def get_by_id(self, id: int) -> Optional[ModelType]:
"""Get a single record by ID."""
return self.db.query(self.model).filter(self.model.id == id).first()
def get_all(self) -> List[ModelType]:
"""Get all records."""
return self.db.query(self.model).all()
def create(self, **kwargs) -> ModelType:
"""Create a new record."""
db_obj = self.model(**kwargs)
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def update(self, id: int, **kwargs) -> Optional[ModelType]:
"""Update a record by ID."""
db_obj = self.get_by_id(id)
if db_obj:
for key, value in kwargs.items():
setattr(db_obj, key, value)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def delete(self, id: int) -> bool:
"""Delete a record by ID."""
db_obj = self.get_by_id(id)
if db_obj:
self.db.delete(db_obj)
self.db.commit()
return True
return False
@@ -0,0 +1,33 @@
"""Challenge repository for data access operations."""
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from backend.models.challenge import Challenge
from backend.repositories.base import BaseRepository
class ChallengeRepository(BaseRepository[Challenge]):
"""Repository for Challenge model operations."""
def __init__(self, db: Session):
"""Initialize challenge repository."""
super().__init__(Challenge, db)
def get_by_user_id(self, user_id: int) -> List[Challenge]:
"""Get all challenges for a user ordered by display_order."""
return self.db.query(Challenge).filter(
Challenge.user_id == user_id
).order_by(Challenge.display_order.asc()).all()
def get_by_user_and_id(self, user_id: int, challenge_id: int) -> Optional[Challenge]:
"""Get a challenge by user ID and challenge ID."""
return self.db.query(Challenge).filter(
Challenge.id == challenge_id,
Challenge.user_id == user_id
).first()
def get_max_display_order(self, user_id: int) -> Optional[int]:
"""Get the maximum display_order for a user."""
return self.db.query(func.max(Challenge.display_order)).filter(
Challenge.user_id == user_id
).scalar()
+17
View File
@@ -0,0 +1,17 @@
"""User repository for data access operations."""
from typing import Optional
from sqlalchemy.orm import Session
from backend.models.user import User
from backend.repositories.base import BaseRepository
class UserRepository(BaseRepository[User]):
"""Repository for User model operations."""
def __init__(self, db: Session):
"""Initialize user repository."""
super().__init__(User, db)
def get_by_username(self, username: str) -> Optional[User]:
"""Get a user by username."""
return self.db.query(User).filter(User.username == username).first()
+16
View File
@@ -0,0 +1,16 @@
"""Pydantic schemas module."""
from backend.schemas.user import UserCreate, UserResponse
from backend.schemas.challenge import ChallengeBase, ChallengeCreate, ChallengeResponse
from backend.schemas.tracking import TrackingBase, TrackingCreate, TrackingResponse
from backend.schemas.daily_health_metrics import DailyHealthMetricsBase, DailyHealthMetricsResponse
from backend.schemas.workout_session import WorkoutSessionBase, WorkoutSessionCreate, WorkoutSessionResponse
from backend.schemas.token import Token
__all__ = [
"UserCreate", "UserResponse",
"ChallengeBase", "ChallengeCreate", "ChallengeResponse",
"TrackingBase", "TrackingCreate", "TrackingResponse",
"DailyHealthMetricsBase", "DailyHealthMetricsResponse",
"WorkoutSessionBase", "WorkoutSessionCreate", "WorkoutSessionResponse",
"Token"
]
+38
View File
@@ -0,0 +1,38 @@
"""Challenge Pydantic schemas."""
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
from backend.schemas.tracking import TrackingResponse
from backend.models.tracking import Tracking
class ChallengeBase(BaseModel):
"""Base schema for challenge."""
name: str
period_type: str
daily_target: int
start_date: date
end_date: Optional[date] = None
unit_type: Optional[str] = "reps"
rest_days: Optional[str] = None
icon: Optional[str] = "fa-dumbbell"
image_url: Optional[str] = None
description: Optional[str] = None
frequency_count: Optional[int] = None
selected_days: Optional[str] = None
class ChallengeCreate(ChallengeBase):
"""Schema for creating a challenge."""
pass
class ChallengeResponse(ChallengeBase):
"""Schema for challenge response."""
id: int
created_at: date
display_order: int = 0
trackings: List[TrackingResponse] = []
class Config:
from_attributes = True
+34
View File
@@ -0,0 +1,34 @@
"""DailyHealthMetrics Pydantic schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import date
class DailyHealthMetricsBase(BaseModel):
"""Base schema for daily health metrics."""
date: date
step_count: Optional[int] = 0
distance_meters: Optional[float] = 0.0
calories_burned: Optional[float] = 0.0
sleep_duration_minutes: Optional[int] = 0
deep_sleep_minutes: Optional[int] = 0
light_sleep_minutes: Optional[int] = 0
rem_sleep_minutes: Optional[int] = 0
awake_duration_minutes: Optional[int] = 0
avg_heart_rate: Optional[int] = None
min_heart_rate: Optional[int] = None
max_heart_rate: Optional[int] = None
avg_spo2: Optional[float] = None
pai_score: Optional[float] = 0.0
weight: Optional[float] = None
resting_heart_rate: Optional[int] = None
hrv: Optional[float] = None
class DailyHealthMetricsResponse(DailyHealthMetricsBase):
"""Schema for daily health metrics response."""
id: int
user_id: int
class Config:
from_attributes = True
+8
View File
@@ -0,0 +1,8 @@
"""Token Pydantic schema."""
from pydantic import BaseModel
class Token(BaseModel):
"""Schema for JWT token response."""
access_token: str
token_type: str
+26
View File
@@ -0,0 +1,26 @@
"""Tracking Pydantic schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import date
class TrackingBase(BaseModel):
"""Base schema for tracking."""
date: date
reps: int
completed: bool = False
notes: Optional[str] = None
class TrackingCreate(TrackingBase):
"""Schema for creating tracking."""
challenge_id: int
class TrackingResponse(TrackingBase):
"""Schema for tracking response."""
id: int
challenge_id: int
class Config:
from_attributes = True
+17
View File
@@ -0,0 +1,17 @@
"""User Pydantic schemas."""
from pydantic import BaseModel
class UserCreate(BaseModel):
"""Schema for user registration."""
username: str
password: str
class UserResponse(BaseModel):
"""Schema for user response."""
id: int
username: str
class Config:
from_attributes = True

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