Add initial project
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
frontend/static/*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
# Environment files
|
||||
.env
|
||||
.env.development
|
||||
.env.staging
|
||||
.env.production
|
||||
.env.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Database
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Secrets
|
||||
backend/client_secret*.json
|
||||
backend/credentials.json
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Node modules (if frontend uses npm)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
# Large data exports
|
||||
export-zepp/
|
||||
|
||||
# Gradle build artifacts
|
||||
android-companion/.gradle/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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)"
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 📖 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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,91 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
# Import Base and all models to ensure they're registered with metadata
|
||||
from backend.database import Base
|
||||
from backend import models # This imports all models via __init__.py
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
# Override URL from settings
|
||||
from backend.core.config import settings
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = settings.DATABASE_URL
|
||||
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add icon and image_url
|
||||
|
||||
Revision ID: 4cc2e86d9794
|
||||
Revises: 8ba5ee682bc1
|
||||
Create Date: 2025-12-27 23:38:43.414279
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4cc2e86d9794'
|
||||
down_revision: Union[str, Sequence[str], None] = '8ba5ee682bc1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('challenges', sa.Column('icon', sa.String(), nullable=True))
|
||||
op.add_column('challenges', sa.Column('image_url', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('challenges', 'image_url')
|
||||
op.drop_column('challenges', 'icon')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add description
|
||||
|
||||
Revision ID: 5ea126309171
|
||||
Revises: 4cc2e86d9794
|
||||
Create Date: 2025-12-28 00:11:14.788582
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5ea126309171'
|
||||
down_revision: Union[str, Sequence[str], None] = '4cc2e86d9794'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('challenges', sa.Column('description', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('challenges', 'description')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add awake_duration_minutes to daily health metrics
|
||||
|
||||
Revision ID: 6c3fce1dd49a
|
||||
Revises: d208fd79a572
|
||||
Create Date: 2025-12-29 13:49:31.181979
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6c3fce1dd49a'
|
||||
down_revision: Union[str, Sequence[str], None] = 'd208fd79a572'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('daily_health_metrics', sa.Column('awake_duration_minutes', sa.Integer(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('daily_health_metrics', 'awake_duration_minutes')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add unit_type and rest_days
|
||||
|
||||
Revision ID: 8ba5ee682bc1
|
||||
Revises:
|
||||
Create Date: 2025-12-27 22:29:05.909161
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8ba5ee682bc1'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('challenges', sa.Column('unit_type', sa.String(), nullable=True))
|
||||
op.add_column('challenges', sa.Column('rest_days', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('challenges', 'rest_days')
|
||||
op.drop_column('challenges', 'unit_type')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Add data_source columns for provider tracking
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 6c3fce1dd49a
|
||||
Create Date: 2026-01-01 14:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '6c3fce1dd49a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add data_source column to daily_health_metrics
|
||||
op.add_column(
|
||||
'daily_health_metrics',
|
||||
sa.Column('data_source', sa.String(50), nullable=True, server_default='google_fit')
|
||||
)
|
||||
|
||||
# Add data_source column to workout_sessions
|
||||
op.add_column(
|
||||
'workout_sessions',
|
||||
sa.Column('data_source', sa.String(50), nullable=True, server_default='google_fit')
|
||||
)
|
||||
|
||||
# Update existing rows to have default value
|
||||
op.execute("UPDATE daily_health_metrics SET data_source = 'google_fit' WHERE data_source IS NULL")
|
||||
op.execute("UPDATE workout_sessions SET data_source = 'google_fit' WHERE data_source IS NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('daily_health_metrics', 'data_source')
|
||||
op.drop_column('workout_sessions', 'data_source')
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Add Health Connect tables
|
||||
|
||||
Revision ID: b2c3d4e5f6g7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-01-01 14:35:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2c3d4e5f6g7'
|
||||
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create health_connect_sync_logs table
|
||||
op.create_table(
|
||||
'health_connect_sync_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('device_id', sa.String(100), nullable=True),
|
||||
sa.Column('sync_timestamp', sa.DateTime(), nullable=True),
|
||||
sa.Column('records_pushed', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(50), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_health_connect_sync_logs_id', 'health_connect_sync_logs', ['id'])
|
||||
op.create_index('ix_health_connect_sync_logs_user_id', 'health_connect_sync_logs', ['user_id'])
|
||||
|
||||
# Create health_connect_daily_metrics table
|
||||
op.create_table(
|
||||
'health_connect_daily_metrics',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('date', sa.Date(), nullable=True),
|
||||
sa.Column('step_count', sa.Integer(), nullable=True),
|
||||
sa.Column('distance_meters', sa.Float(), nullable=True),
|
||||
sa.Column('calories_burned', sa.Float(), nullable=True),
|
||||
sa.Column('total_calories', sa.Float(), nullable=True),
|
||||
sa.Column('sleep_duration_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('deep_sleep_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('light_sleep_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('rem_sleep_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('awake_duration_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('avg_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('min_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('max_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('resting_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('avg_spo2', sa.Float(), nullable=True),
|
||||
sa.Column('min_spo2', sa.Float(), nullable=True),
|
||||
sa.Column('max_spo2', sa.Float(), nullable=True),
|
||||
sa.Column('weight', sa.Float(), nullable=True),
|
||||
sa.Column('last_synced', sa.DateTime(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_health_connect_daily_metrics_id', 'health_connect_daily_metrics', ['id'])
|
||||
op.create_index('ix_health_connect_daily_metrics_user_id', 'health_connect_daily_metrics', ['user_id'])
|
||||
op.create_index('ix_health_connect_daily_metrics_date', 'health_connect_daily_metrics', ['date'])
|
||||
|
||||
# Create health_connect_exercise_sessions table
|
||||
op.create_table(
|
||||
'health_connect_exercise_sessions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('exercise_type', sa.Integer(), nullable=True),
|
||||
sa.Column('exercise_type_name', sa.String(100), nullable=True),
|
||||
sa.Column('duration_seconds', sa.Integer(), nullable=True),
|
||||
sa.Column('distance_meters', sa.Float(), nullable=True),
|
||||
sa.Column('calories_burned', sa.Float(), nullable=True),
|
||||
sa.Column('avg_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('max_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('min_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('steps', sa.Integer(), nullable=True),
|
||||
sa.Column('elevation_gained', sa.Float(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('last_synced', sa.DateTime(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_health_connect_exercise_sessions_id', 'health_connect_exercise_sessions', ['id'])
|
||||
op.create_index('ix_health_connect_exercise_sessions_user_id', 'health_connect_exercise_sessions', ['user_id'])
|
||||
op.create_index('ix_health_connect_exercise_sessions_start_time', 'health_connect_exercise_sessions', ['start_time'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('health_connect_exercise_sessions')
|
||||
op.drop_table('health_connect_daily_metrics')
|
||||
op.drop_table('health_connect_sync_logs')
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add Zepp fields
|
||||
|
||||
Revision ID: c3049b0cefc4
|
||||
Revises: 6c3fce1dd49a
|
||||
Create Date: 2025-12-29 17:26:48.144904
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3049b0cefc4'
|
||||
down_revision: Union[str, Sequence[str], None] = '6c3fce1dd49a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('daily_health_metrics', sa.Column('weight', sa.Float(), nullable=True))
|
||||
op.add_column('daily_health_metrics', sa.Column('resting_heart_rate', sa.Integer(), nullable=True))
|
||||
op.add_column('daily_health_metrics', sa.Column('hrv', sa.Float(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('daily_health_metrics', 'hrv')
|
||||
op.drop_column('daily_health_metrics', 'resting_heart_rate')
|
||||
op.drop_column('daily_health_metrics', 'weight')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add_frequency_fields
|
||||
|
||||
Revision ID: d208fd79a572
|
||||
Revises: 5ea126309171
|
||||
Create Date: 2025-12-28 12:59:55.198335
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd208fd79a572'
|
||||
down_revision: Union[str, Sequence[str], None] = '5ea126309171'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('challenges', sa.Column('frequency_count', sa.Integer(), nullable=True))
|
||||
op.add_column('challenges', sa.Column('selected_days', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('challenges', 'selected_days')
|
||||
op.drop_column('challenges', 'frequency_count')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Add data_source columns to health_connect_daily_metrics
|
||||
|
||||
Revision ID: e4f5g6h7i8j9
|
||||
Revises: b2c3d4e5f6g7
|
||||
Create Date: 2026-01-07 15:40:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e4f5g6h7i8j9'
|
||||
down_revision: Union[str, None] = 'b2c3d4e5f6g7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add data_source column to health_connect_daily_metrics
|
||||
op.add_column(
|
||||
'health_connect_daily_metrics',
|
||||
sa.Column('data_source', sa.String(200), nullable=True)
|
||||
)
|
||||
|
||||
# Add data_source_name column to health_connect_daily_metrics
|
||||
op.add_column(
|
||||
'health_connect_daily_metrics',
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True)
|
||||
)
|
||||
|
||||
# Add data_source column to health_connect_exercise_sessions
|
||||
op.add_column(
|
||||
'health_connect_exercise_sessions',
|
||||
sa.Column('data_source', sa.String(200), nullable=True)
|
||||
)
|
||||
|
||||
# Add data_source_name column to health_connect_exercise_sessions
|
||||
op.add_column(
|
||||
'health_connect_exercise_sessions',
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('health_connect_daily_metrics', 'data_source')
|
||||
op.drop_column('health_connect_daily_metrics', 'data_source_name')
|
||||
op.drop_column('health_connect_exercise_sessions', 'data_source')
|
||||
op.drop_column('health_connect_exercise_sessions', 'data_source_name')
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Add Health Connect raw data tables
|
||||
|
||||
Revision ID: f5g6h7i8j9k0
|
||||
Revises: e4f5g6h7i8j9
|
||||
Create Date: 2026-01-08 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f5g6h7i8j9k0'
|
||||
down_revision = 'e4f5g6h7i8j9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === Steps ===
|
||||
op.create_table(
|
||||
'hc_raw_steps',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('count', sa.Integer(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_steps_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_steps_id', 'hc_raw_steps', ['id'])
|
||||
op.create_index('ix_hc_raw_steps_user_id', 'hc_raw_steps', ['user_id'])
|
||||
op.create_index('ix_hc_raw_steps_record_id', 'hc_raw_steps', ['record_id'])
|
||||
op.create_index('ix_hc_raw_steps_start_time', 'hc_raw_steps', ['start_time'])
|
||||
op.create_index('ix_steps_user_date', 'hc_raw_steps', ['user_id', 'start_time'])
|
||||
|
||||
# === Heart Rate ===
|
||||
op.create_table(
|
||||
'hc_raw_heart_rate',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hr_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_heart_rate_id', 'hc_raw_heart_rate', ['id'])
|
||||
op.create_index('ix_hc_raw_heart_rate_user_id', 'hc_raw_heart_rate', ['user_id'])
|
||||
op.create_index('ix_hc_raw_heart_rate_record_id', 'hc_raw_heart_rate', ['record_id'])
|
||||
op.create_index('ix_hc_raw_heart_rate_start_time', 'hc_raw_heart_rate', ['start_time'])
|
||||
op.create_index('ix_hr_user_date', 'hc_raw_heart_rate', ['user_id', 'start_time'])
|
||||
|
||||
# === Heart Rate Samples ===
|
||||
op.create_table(
|
||||
'hc_raw_heart_rate_samples',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('record_id', sa.Integer(), nullable=True),
|
||||
sa.Column('time', sa.DateTime(), nullable=True),
|
||||
sa.Column('bpm', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['record_id'], ['hc_raw_heart_rate.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_hc_raw_heart_rate_samples_id', 'hc_raw_heart_rate_samples', ['id'])
|
||||
op.create_index('ix_hc_raw_heart_rate_samples_record_id', 'hc_raw_heart_rate_samples', ['record_id'])
|
||||
op.create_index('ix_hc_raw_heart_rate_samples_time', 'hc_raw_heart_rate_samples', ['time'])
|
||||
|
||||
# === Resting Heart Rate ===
|
||||
op.create_table(
|
||||
'hc_raw_resting_hr',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('bpm', sa.Integer(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_resting_hr_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_resting_hr_id', 'hc_raw_resting_hr', ['id'])
|
||||
op.create_index('ix_hc_raw_resting_hr_user_id', 'hc_raw_resting_hr', ['user_id'])
|
||||
op.create_index('ix_hc_raw_resting_hr_record_id', 'hc_raw_resting_hr', ['record_id'])
|
||||
op.create_index('ix_hc_raw_resting_hr_time', 'hc_raw_resting_hr', ['time'])
|
||||
op.create_index('ix_resting_hr_user_date', 'hc_raw_resting_hr', ['user_id', 'time'])
|
||||
|
||||
# === Sleep Sessions ===
|
||||
op.create_table(
|
||||
'hc_raw_sleep_sessions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('title', sa.String(200), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_sleep_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_sleep_sessions_id', 'hc_raw_sleep_sessions', ['id'])
|
||||
op.create_index('ix_hc_raw_sleep_sessions_user_id', 'hc_raw_sleep_sessions', ['user_id'])
|
||||
op.create_index('ix_hc_raw_sleep_sessions_record_id', 'hc_raw_sleep_sessions', ['record_id'])
|
||||
op.create_index('ix_hc_raw_sleep_sessions_start_time', 'hc_raw_sleep_sessions', ['start_time'])
|
||||
op.create_index('ix_sleep_user_date', 'hc_raw_sleep_sessions', ['user_id', 'start_time'])
|
||||
|
||||
# === Sleep Stages ===
|
||||
op.create_table(
|
||||
'hc_raw_sleep_stages',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('stage_type', sa.Integer(), nullable=True),
|
||||
sa.Column('stage_name', sa.String(50), nullable=True),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['hc_raw_sleep_sessions.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_hc_raw_sleep_stages_id', 'hc_raw_sleep_stages', ['id'])
|
||||
op.create_index('ix_hc_raw_sleep_stages_session_id', 'hc_raw_sleep_stages', ['session_id'])
|
||||
|
||||
# === Distance ===
|
||||
op.create_table(
|
||||
'hc_raw_distance',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('distance_meters', sa.Float(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_distance_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_distance_id', 'hc_raw_distance', ['id'])
|
||||
op.create_index('ix_hc_raw_distance_user_id', 'hc_raw_distance', ['user_id'])
|
||||
op.create_index('ix_hc_raw_distance_record_id', 'hc_raw_distance', ['record_id'])
|
||||
op.create_index('ix_hc_raw_distance_start_time', 'hc_raw_distance', ['start_time'])
|
||||
op.create_index('ix_distance_user_date', 'hc_raw_distance', ['user_id', 'start_time'])
|
||||
|
||||
# === Calories ===
|
||||
op.create_table(
|
||||
'hc_raw_calories',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('calories', sa.Float(), nullable=True),
|
||||
sa.Column('record_type', sa.String(20), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_calories_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_calories_id', 'hc_raw_calories', ['id'])
|
||||
op.create_index('ix_hc_raw_calories_user_id', 'hc_raw_calories', ['user_id'])
|
||||
op.create_index('ix_hc_raw_calories_record_id', 'hc_raw_calories', ['record_id'])
|
||||
op.create_index('ix_hc_raw_calories_start_time', 'hc_raw_calories', ['start_time'])
|
||||
op.create_index('ix_calories_user_date', 'hc_raw_calories', ['user_id', 'start_time'])
|
||||
|
||||
# === Oxygen Saturation (SpO2) ===
|
||||
op.create_table(
|
||||
'hc_raw_spo2',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('percentage', sa.Float(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_spo2_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_spo2_id', 'hc_raw_spo2', ['id'])
|
||||
op.create_index('ix_hc_raw_spo2_user_id', 'hc_raw_spo2', ['user_id'])
|
||||
op.create_index('ix_hc_raw_spo2_record_id', 'hc_raw_spo2', ['record_id'])
|
||||
op.create_index('ix_hc_raw_spo2_time', 'hc_raw_spo2', ['time'])
|
||||
op.create_index('ix_spo2_user_date', 'hc_raw_spo2', ['user_id', 'time'])
|
||||
|
||||
# === Weight ===
|
||||
op.create_table(
|
||||
'hc_raw_weight',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('weight_kg', sa.Float(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_weight_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_weight_id', 'hc_raw_weight', ['id'])
|
||||
op.create_index('ix_hc_raw_weight_user_id', 'hc_raw_weight', ['user_id'])
|
||||
op.create_index('ix_hc_raw_weight_record_id', 'hc_raw_weight', ['record_id'])
|
||||
op.create_index('ix_hc_raw_weight_time', 'hc_raw_weight', ['time'])
|
||||
op.create_index('ix_weight_user_date', 'hc_raw_weight', ['user_id', 'time'])
|
||||
|
||||
# === Height ===
|
||||
op.create_table(
|
||||
'hc_raw_height',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('height_meters', sa.Float(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_height_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_height_id', 'hc_raw_height', ['id'])
|
||||
op.create_index('ix_hc_raw_height_user_id', 'hc_raw_height', ['user_id'])
|
||||
op.create_index('ix_hc_raw_height_record_id', 'hc_raw_height', ['record_id'])
|
||||
op.create_index('ix_hc_raw_height_time', 'hc_raw_height', ['time'])
|
||||
op.create_index('ix_height_user_date', 'hc_raw_height', ['user_id', 'time'])
|
||||
|
||||
# === Body Fat ===
|
||||
op.create_table(
|
||||
'hc_raw_body_fat',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('percentage', sa.Float(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_body_fat_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_body_fat_id', 'hc_raw_body_fat', ['id'])
|
||||
op.create_index('ix_hc_raw_body_fat_user_id', 'hc_raw_body_fat', ['user_id'])
|
||||
op.create_index('ix_hc_raw_body_fat_record_id', 'hc_raw_body_fat', ['record_id'])
|
||||
op.create_index('ix_hc_raw_body_fat_time', 'hc_raw_body_fat', ['time'])
|
||||
op.create_index('ix_body_fat_user_date', 'hc_raw_body_fat', ['user_id', 'time'])
|
||||
|
||||
# === Exercise Sessions ===
|
||||
op.create_table(
|
||||
'hc_raw_exercise_sessions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('exercise_type', sa.Integer(), nullable=True),
|
||||
sa.Column('exercise_type_name', sa.String(100), nullable=True),
|
||||
sa.Column('title', sa.String(200), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('distance_meters', sa.Float(), nullable=True),
|
||||
sa.Column('calories', sa.Float(), nullable=True),
|
||||
sa.Column('avg_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('max_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('min_heart_rate', sa.Integer(), nullable=True),
|
||||
sa.Column('steps', sa.Integer(), nullable=True),
|
||||
sa.Column('elevation_gained_meters', sa.Float(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_exercise_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_exercise_sessions_id', 'hc_raw_exercise_sessions', ['id'])
|
||||
op.create_index('ix_hc_raw_exercise_sessions_user_id', 'hc_raw_exercise_sessions', ['user_id'])
|
||||
op.create_index('ix_hc_raw_exercise_sessions_record_id', 'hc_raw_exercise_sessions', ['record_id'])
|
||||
op.create_index('ix_hc_raw_exercise_sessions_start_time', 'hc_raw_exercise_sessions', ['start_time'])
|
||||
op.create_index('ix_exercise_user_date', 'hc_raw_exercise_sessions', ['user_id', 'start_time'])
|
||||
|
||||
# === Nutrition ===
|
||||
op.create_table(
|
||||
'hc_raw_nutrition',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('name', sa.String(200), nullable=True),
|
||||
sa.Column('meal_type', sa.Integer(), nullable=True),
|
||||
sa.Column('calories', sa.Float(), nullable=True),
|
||||
sa.Column('protein_grams', sa.Float(), nullable=True),
|
||||
sa.Column('carbohydrates_grams', sa.Float(), nullable=True),
|
||||
sa.Column('fat_grams', sa.Float(), nullable=True),
|
||||
sa.Column('fiber_grams', sa.Float(), nullable=True),
|
||||
sa.Column('sugar_grams', sa.Float(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_nutrition_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_nutrition_id', 'hc_raw_nutrition', ['id'])
|
||||
op.create_index('ix_hc_raw_nutrition_user_id', 'hc_raw_nutrition', ['user_id'])
|
||||
op.create_index('ix_hc_raw_nutrition_record_id', 'hc_raw_nutrition', ['record_id'])
|
||||
op.create_index('ix_hc_raw_nutrition_start_time', 'hc_raw_nutrition', ['start_time'])
|
||||
op.create_index('ix_nutrition_user_date', 'hc_raw_nutrition', ['user_id', 'start_time'])
|
||||
|
||||
# === Hydration ===
|
||||
op.create_table(
|
||||
'hc_raw_hydration',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('record_id', sa.String(100), nullable=True),
|
||||
sa.Column('data_source', sa.String(200), nullable=True),
|
||||
sa.Column('data_source_name', sa.String(100), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('synced_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('volume_liters', sa.Float(), nullable=True),
|
||||
sa.Column('sync_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['sync_id'], ['health_connect_sync_logs.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'record_id', 'data_source', name='uq_hydration_record')
|
||||
)
|
||||
op.create_index('ix_hc_raw_hydration_id', 'hc_raw_hydration', ['id'])
|
||||
op.create_index('ix_hc_raw_hydration_user_id', 'hc_raw_hydration', ['user_id'])
|
||||
op.create_index('ix_hc_raw_hydration_record_id', 'hc_raw_hydration', ['record_id'])
|
||||
op.create_index('ix_hc_raw_hydration_start_time', 'hc_raw_hydration', ['start_time'])
|
||||
op.create_index('ix_hydration_user_date', 'hc_raw_hydration', ['user_id', 'start_time'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop all raw data tables
|
||||
op.drop_table('hc_raw_hydration')
|
||||
op.drop_table('hc_raw_nutrition')
|
||||
op.drop_table('hc_raw_exercise_sessions')
|
||||
op.drop_table('hc_raw_body_fat')
|
||||
op.drop_table('hc_raw_height')
|
||||
op.drop_table('hc_raw_weight')
|
||||
op.drop_table('hc_raw_spo2')
|
||||
op.drop_table('hc_raw_calories')
|
||||
op.drop_table('hc_raw_distance')
|
||||
op.drop_table('hc_raw_sleep_stages')
|
||||
op.drop_table('hc_raw_sleep_sessions')
|
||||
op.drop_table('hc_raw_resting_hr')
|
||||
op.drop_table('hc_raw_heart_rate_samples')
|
||||
op.drop_table('hc_raw_heart_rate')
|
||||
op.drop_table('hc_raw_steps')
|
||||
@@ -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"
|
||||
@@ -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
@@ -0,0 +1,11 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# Keep Retrofit and Gson
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
|
||||
-keep class com.habitforge.companion.** { *; }
|
||||
-keep class retrofit2.** { *; }
|
||||
-keep class com.google.gson.** { *; }
|
||||
|
||||
# Keep Health Connect classes
|
||||
-keep class androidx.health.connect.** { *; }
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Internet permission for syncing with backend -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Foreground service permission for reliable syncing -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Health Connect Permissions -->
|
||||
<!-- Activity Data -->
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
|
||||
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
|
||||
|
||||
<!-- Heart & Vitals -->
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE_VARIABILITY" />
|
||||
<uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION" />
|
||||
<uses-permission android:name="android.permission.health.READ_RESPIRATORY_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_BLOOD_PRESSURE" />
|
||||
|
||||
<!-- Body Measurements -->
|
||||
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEIGHT" />
|
||||
<uses-permission android:name="android.permission.health.READ_BODY_FAT" />
|
||||
<uses-permission android:name="android.permission.health.READ_BODY_WATER_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_LEAN_BODY_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_BONE_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
|
||||
|
||||
<!-- Sleep -->
|
||||
<uses-permission android:name="android.permission.health.READ_SLEEP" />
|
||||
|
||||
<!-- Exercise / Workouts -->
|
||||
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
||||
<uses-permission android:name="android.permission.health.READ_POWER" />
|
||||
<uses-permission android:name="android.permission.health.READ_SPEED" />
|
||||
|
||||
<!-- Nutrition (optional) -->
|
||||
<uses-permission android:name="android.permission.health.READ_NUTRITION" />
|
||||
<uses-permission android:name="android.permission.health.READ_HYDRATION" />
|
||||
|
||||
<!-- Historical Data Access (for data older than 30 days) -->
|
||||
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY" />
|
||||
|
||||
<application
|
||||
android:name=".HabitForgeApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.HabitForgeCompanion"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.HabitForgeCompanion">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Receive Health Connect permission results -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Privacy Policy Activity (Required by Health Connect) -->
|
||||
<activity
|
||||
android:name=".PrivacyPolicyActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Raw Data Viewer Activity -->
|
||||
<activity
|
||||
android:name=".RawDataViewerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.HabitForgeCompanion" />
|
||||
|
||||
<!-- Sync Configuration Activity -->
|
||||
<activity
|
||||
android:name=".SyncConfigActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.HabitForgeCompanion" />
|
||||
|
||||
<!-- WorkManager initializer -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
<!-- Declare that this app uses Health Connect -->
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Manages user authentication and session state.
|
||||
* Handles login, logout, and secure token storage.
|
||||
*/
|
||||
class AuthManager(private val context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "habitforge_auth"
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_USER_ID = "user_id"
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_IS_LOGGED_IN = "is_logged_in"
|
||||
|
||||
// Default server URL (can be changed)
|
||||
const val DEFAULT_SERVER_URL = "http://192.168.1.100:8000"
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently logged in
|
||||
*/
|
||||
fun isLoggedIn(): Boolean {
|
||||
return prefs.getBoolean(KEY_IS_LOGGED_IN, false) &&
|
||||
!prefs.getString(KEY_ACCESS_TOKEN, null).isNullOrEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored access token with Bearer prefix
|
||||
*/
|
||||
fun getAuthorizationHeader(): String? {
|
||||
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||
return if (token != null) "Bearer $token" else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw access token (without Bearer prefix)
|
||||
*/
|
||||
fun getAccessToken(): String? {
|
||||
return prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw refresh token
|
||||
*/
|
||||
fun getRefreshToken(): String? {
|
||||
return prefs.getString(KEY_REFRESH_TOKEN, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current username
|
||||
*/
|
||||
fun getUsername(): String? {
|
||||
return prefs.getString(KEY_USERNAME, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID
|
||||
*/
|
||||
fun getUserId(): Int {
|
||||
return prefs.getInt(KEY_USER_ID, -1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured server URL
|
||||
*/
|
||||
fun getServerUrl(): String {
|
||||
return prefs.getString(KEY_SERVER_URL, BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Set server URL
|
||||
*/
|
||||
fun setServerUrl(url: String) {
|
||||
prefs.edit().putString(KEY_SERVER_URL, url).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to login with username and password
|
||||
* @return LoginResult with success/failure and message
|
||||
*/
|
||||
suspend fun login(serverUrl: String, username: String, password: String): LoginResult {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Ensure URL has proper format
|
||||
val formattedUrl = formatServerUrl(serverUrl)
|
||||
|
||||
// Create API client
|
||||
val api = HabitForgeApi.create(formattedUrl)
|
||||
|
||||
// Attempt login
|
||||
val response = api.login(username, password)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val loginResponse = response.body()!!
|
||||
val token = loginResponse.access_token
|
||||
|
||||
// Verify token by getting user info
|
||||
val userResponse = api.getCurrentUser("Bearer $token")
|
||||
|
||||
if (userResponse.isSuccessful && userResponse.body() != null) {
|
||||
val user = userResponse.body()!!
|
||||
|
||||
// Save credentials
|
||||
saveSession(
|
||||
serverUrl = formattedUrl,
|
||||
token = token,
|
||||
refreshToken = loginResponse.refresh_token,
|
||||
username = user.username,
|
||||
userId = user.id
|
||||
)
|
||||
|
||||
LoginResult(
|
||||
success = true,
|
||||
message = "Welcome back, ${user.username}!",
|
||||
username = user.username
|
||||
)
|
||||
} else {
|
||||
LoginResult(
|
||||
success = false,
|
||||
message = "Login successful but failed to get user info"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
val errorMessage = when (response.code()) {
|
||||
401 -> "Invalid username or password"
|
||||
404 -> "Server not found. Check URL."
|
||||
500 -> "Server error. Please try again."
|
||||
else -> errorBody ?: "Login failed (${response.code()})"
|
||||
}
|
||||
LoginResult(success = false, message = errorMessage)
|
||||
}
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
LoginResult(success = false, message = "Cannot reach server. Check URL and network.")
|
||||
} catch (e: java.net.ConnectException) {
|
||||
LoginResult(success = false, message = "Connection refused. Is the server running?")
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
LoginResult(success = false, message = "Connection timeout. Server may be slow.")
|
||||
} catch (e: Exception) {
|
||||
LoginResult(success = false, message = "Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session data after successful login
|
||||
*/
|
||||
private fun saveSession(serverUrl: String, token: String, refreshToken: String, username: String, userId: Int) {
|
||||
prefs.edit()
|
||||
.putString(KEY_SERVER_URL, serverUrl)
|
||||
.putString(KEY_ACCESS_TOKEN, token)
|
||||
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.putInt(KEY_USER_ID, userId)
|
||||
.putBoolean(KEY_IS_LOGGED_IN, true)
|
||||
.apply()
|
||||
|
||||
// Also update the old prefs for backward compatibility with SyncService
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("server_url", serverUrl)
|
||||
.putString("auth_token", token)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session (logout)
|
||||
*/
|
||||
fun logout() {
|
||||
prefs.edit()
|
||||
.remove(KEY_ACCESS_TOKEN)
|
||||
.remove(KEY_REFRESH_TOKEN)
|
||||
.remove(KEY_USERNAME)
|
||||
.remove(KEY_USER_ID)
|
||||
.putBoolean(KEY_IS_LOGGED_IN, false)
|
||||
.apply()
|
||||
|
||||
// Clear old prefs too
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove("auth_token")
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token if needed (for future token refresh implementation)
|
||||
*/
|
||||
/**
|
||||
* Refresh token if needed
|
||||
*/
|
||||
suspend fun refreshTokenIfNeeded(): Boolean {
|
||||
val token = getAuthorizationHeader() ?: return false
|
||||
val serverUrl = getServerUrl()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val api = HabitForgeApi.create(serverUrl)
|
||||
|
||||
// 1. Try to use current token
|
||||
try {
|
||||
val response = api.getCurrentUser(token)
|
||||
if (response.isSuccessful) {
|
||||
return@withContext true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// If it's a network error unrelated to auth, we might want to fail?
|
||||
// But let's try refresh anyway if we have a refresh token
|
||||
}
|
||||
|
||||
// 2. Token invalid, try to refresh
|
||||
val refreshToken = getRefreshToken() ?: return@withContext false
|
||||
try {
|
||||
val refreshResponse = api.refreshToken(RefreshTokenRequest(refreshToken))
|
||||
if (refreshResponse.isSuccessful && refreshResponse.body() != null) {
|
||||
val newTokens = refreshResponse.body()!!
|
||||
updateTokens(newTokens.access_token, newTokens.refresh_token)
|
||||
return@withContext true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Refresh failed
|
||||
}
|
||||
|
||||
// 3. Keep logged in locally? No, if we can't refresh, caller handles it.
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTokens(accessToken: String, refreshToken: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_ACCESS_TOKEN, accessToken)
|
||||
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
.apply()
|
||||
|
||||
// Update legacy preference for sync service compatibility if needed
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("auth_token", accessToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format server URL to ensure it has proper protocol and no trailing slash
|
||||
*/
|
||||
private fun formatServerUrl(url: String): String {
|
||||
var formatted = url.trim()
|
||||
|
||||
// Add protocol if missing
|
||||
if (!formatted.startsWith("http://") && !formatted.startsWith("https://")) {
|
||||
formatted = "http://$formatted"
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
if (formatted.endsWith("/")) {
|
||||
formatted = formatted.dropLast(1)
|
||||
}
|
||||
|
||||
// Ensure ends with proper format for Retrofit baseUrl
|
||||
if (!formatted.endsWith("/")) {
|
||||
formatted = "$formatted/"
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a login attempt
|
||||
*/
|
||||
data class LoginResult(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val username: String? = null
|
||||
)
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Validates that Health Connect data can be properly mapped to HabitForge schema
|
||||
*/
|
||||
class DataCompatibilityValidator(
|
||||
private val context: Context,
|
||||
private val healthConnectManager: HealthConnectManager
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "DataCompatibilityValidator"
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full compatibility validation
|
||||
*/
|
||||
suspend fun validateCompatibility(): ValidationResult = withContext(Dispatchers.IO) {
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
val validations = mutableListOf<ValidationItem>()
|
||||
|
||||
Log.i(TAG, "Starting data compatibility validation...")
|
||||
|
||||
// Check Health Connect availability
|
||||
val availability = healthConnectManager.isAvailable()
|
||||
validations.add(
|
||||
ValidationItem(
|
||||
name = "Health Connect Availability",
|
||||
passed = availability == HealthConnectAvailability.AVAILABLE,
|
||||
message = when (availability) {
|
||||
HealthConnectAvailability.AVAILABLE -> "Health Connect is available"
|
||||
HealthConnectAvailability.NOT_INSTALLED -> "Health Connect is not installed"
|
||||
HealthConnectAvailability.NOT_SUPPORTED -> "Health Connect is not supported on this device"
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (availability != HealthConnectAvailability.AVAILABLE) {
|
||||
return@withContext ValidationResult(
|
||||
isCompatible = false,
|
||||
validations = validations,
|
||||
issues = listOf(ValidationIssue(
|
||||
severity = IssueSeverity.ERROR,
|
||||
category = "Health Connect",
|
||||
message = "Health Connect is not available"
|
||||
)),
|
||||
dataPreview = null
|
||||
)
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
val hasPermissions = healthConnectManager.hasAllPermissions()
|
||||
val grantedPermissions = healthConnectManager.getGrantedPermissions()
|
||||
val totalPermissions = HealthConnectManager.PERMISSIONS.size
|
||||
|
||||
validations.add(
|
||||
ValidationItem(
|
||||
name = "Permissions",
|
||||
passed = hasPermissions,
|
||||
message = "Granted: ${grantedPermissions.size}/$totalPermissions"
|
||||
)
|
||||
)
|
||||
|
||||
if (!hasPermissions) {
|
||||
issues.add(ValidationIssue(
|
||||
severity = IssueSeverity.WARNING,
|
||||
category = "Permissions",
|
||||
message = "Not all permissions granted. Some data types may not sync."
|
||||
))
|
||||
}
|
||||
|
||||
// Try to read sample data to validate schema mapping
|
||||
try {
|
||||
val endDate = LocalDate.now()
|
||||
val startDate = endDate.minusDays(1)
|
||||
|
||||
// Test daily metrics reading
|
||||
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
|
||||
validations.add(
|
||||
ValidationItem(
|
||||
name = "Daily Metrics Schema",
|
||||
passed = true,
|
||||
message = "Successfully read ${metrics.size} day(s) of metrics"
|
||||
)
|
||||
)
|
||||
|
||||
// Validate each metric field
|
||||
val fieldValidations = validateDailyMetricsFields(metrics)
|
||||
validations.addAll(fieldValidations.validations)
|
||||
issues.addAll(fieldValidations.issues)
|
||||
|
||||
// Test exercise sessions reading
|
||||
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
|
||||
validations.add(
|
||||
ValidationItem(
|
||||
name = "Exercise Sessions Schema",
|
||||
passed = true,
|
||||
message = "Successfully read ${exercises.size} exercise session(s)"
|
||||
)
|
||||
)
|
||||
|
||||
// Validate exercise fields
|
||||
val exerciseValidations = validateExerciseFields(exercises)
|
||||
validations.addAll(exerciseValidations.validations)
|
||||
issues.addAll(exerciseValidations.issues)
|
||||
|
||||
// Build data preview
|
||||
val dataPreview = buildDataPreview(metrics, exercises)
|
||||
|
||||
// Final compatibility check
|
||||
val hasErrors = issues.any { it.severity == IssueSeverity.ERROR }
|
||||
|
||||
ValidationResult(
|
||||
isCompatible = !hasErrors,
|
||||
validations = validations,
|
||||
issues = issues,
|
||||
dataPreview = dataPreview
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Validation failed", e)
|
||||
issues.add(ValidationIssue(
|
||||
severity = IssueSeverity.ERROR,
|
||||
category = "Data Reading",
|
||||
message = "Failed to read data: ${e.localizedMessage}"
|
||||
))
|
||||
|
||||
ValidationResult(
|
||||
isCompatible = false,
|
||||
validations = validations,
|
||||
issues = issues,
|
||||
dataPreview = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDailyMetricsFields(metrics: List<DailyMetrics>): FieldValidationResult {
|
||||
val validations = mutableListOf<ValidationItem>()
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
if (metrics.isEmpty()) {
|
||||
issues.add(ValidationIssue(
|
||||
severity = IssueSeverity.INFO,
|
||||
category = "Daily Metrics",
|
||||
message = "No daily metrics data found for the test period"
|
||||
))
|
||||
return FieldValidationResult(validations, issues)
|
||||
}
|
||||
|
||||
val sample = metrics.first()
|
||||
|
||||
// Steps validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Steps Field",
|
||||
passed = sample.steps >= 0,
|
||||
message = "Value: ${sample.steps}"
|
||||
))
|
||||
|
||||
// Distance validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Distance Field",
|
||||
passed = sample.distanceMeters >= 0,
|
||||
message = "Value: ${String.format(Locale.US, "%.2f", sample.distanceMeters)} meters"
|
||||
))
|
||||
|
||||
// Calories validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Calories Field",
|
||||
passed = sample.caloriesBurned >= 0,
|
||||
message = "Value: ${sample.caloriesBurned} kcal"
|
||||
))
|
||||
|
||||
// Heart rate validation (optional field)
|
||||
val hrStatus = if (sample.avgHeartRate != null) {
|
||||
"Value: ${sample.avgHeartRate} bpm"
|
||||
} else {
|
||||
"No data available (optional)"
|
||||
}
|
||||
validations.add(ValidationItem(
|
||||
name = "Heart Rate Field",
|
||||
passed = true,
|
||||
message = hrStatus
|
||||
))
|
||||
|
||||
// Sleep validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Sleep Fields",
|
||||
passed = sample.sleepDurationMinutes >= 0,
|
||||
message = "Duration: ${sample.sleepDurationMinutes} min, Deep: ${sample.deepSleepMinutes} min"
|
||||
))
|
||||
|
||||
// Weight validation (optional)
|
||||
val weightStatus = if (sample.weight != null) {
|
||||
"Value: ${String.format(Locale.US, "%.1f", sample.weight)} kg"
|
||||
} else {
|
||||
"No data available (optional)"
|
||||
}
|
||||
validations.add(ValidationItem(
|
||||
name = "Weight Field",
|
||||
passed = true,
|
||||
message = weightStatus
|
||||
))
|
||||
|
||||
// SpO2 validation (optional)
|
||||
val spo2Status = if (sample.spo2 != null) {
|
||||
"Value: ${String.format(Locale.US, "%.1f", sample.spo2)}%"
|
||||
} else {
|
||||
"No data available (optional)"
|
||||
}
|
||||
validations.add(ValidationItem(
|
||||
name = "SpO2 Field",
|
||||
passed = true,
|
||||
message = spo2Status
|
||||
))
|
||||
|
||||
return FieldValidationResult(validations, issues)
|
||||
}
|
||||
|
||||
private fun validateExerciseFields(exercises: List<ExerciseData>): FieldValidationResult {
|
||||
val validations = mutableListOf<ValidationItem>()
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
if (exercises.isEmpty()) {
|
||||
issues.add(ValidationIssue(
|
||||
severity = IssueSeverity.INFO,
|
||||
category = "Exercise Sessions",
|
||||
message = "No exercise sessions found for the test period"
|
||||
))
|
||||
return FieldValidationResult(validations, issues)
|
||||
}
|
||||
|
||||
val sample = exercises.first()
|
||||
|
||||
// Time fields validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Exercise Time Fields",
|
||||
passed = sample.startTime.isNotEmpty() && sample.endTime.isNotEmpty(),
|
||||
message = "Start: ${sample.startTime.take(19)}"
|
||||
))
|
||||
|
||||
// Exercise type validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Exercise Type Field",
|
||||
passed = sample.exerciseType >= 0,
|
||||
message = "Type: ${sample.exerciseTypeName}"
|
||||
))
|
||||
|
||||
// Duration validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Duration Field",
|
||||
passed = sample.durationMinutes > 0,
|
||||
message = "Duration: ${sample.durationMinutes} minutes"
|
||||
))
|
||||
|
||||
return FieldValidationResult(validations, issues)
|
||||
}
|
||||
|
||||
private fun buildDataPreview(metrics: List<DailyMetrics>, exercises: List<ExerciseData>): DataPreview {
|
||||
val metricsPreview = if (metrics.isNotEmpty()) {
|
||||
val m = metrics.first()
|
||||
"""
|
||||
|Date: ${m.date}
|
||||
|Steps: ${m.steps}
|
||||
|Distance: ${String.format(Locale.US, "%.0f", m.distanceMeters)}m
|
||||
|Calories: ${m.caloriesBurned}
|
||||
|Heart Rate: ${m.avgHeartRate ?: "N/A"} bpm
|
||||
|Sleep: ${m.sleepDurationMinutes} min
|
||||
""".trimMargin()
|
||||
} else {
|
||||
"No metrics data available"
|
||||
}
|
||||
|
||||
val exercisesPreview = if (exercises.isNotEmpty()) {
|
||||
exercises.take(3).joinToString("\n") { e ->
|
||||
"• ${e.exerciseTypeName}: ${e.durationMinutes} min"
|
||||
}
|
||||
} else {
|
||||
"No exercise sessions available"
|
||||
}
|
||||
|
||||
return DataPreview(
|
||||
dailyMetricsPreview = metricsPreview,
|
||||
exerciseSessionsPreview = exercisesPreview,
|
||||
metricsCount = metrics.size,
|
||||
exercisesCount = exercises.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DATA CLASSES ====================
|
||||
|
||||
data class ValidationResult(
|
||||
val isCompatible: Boolean,
|
||||
val validations: List<ValidationItem>,
|
||||
val issues: List<ValidationIssue>,
|
||||
val dataPreview: DataPreview?
|
||||
) {
|
||||
fun getOverallStatus(): String {
|
||||
return if (isCompatible) {
|
||||
val warnings = issues.count { it.severity == IssueSeverity.WARNING }
|
||||
if (warnings > 0) {
|
||||
"Compatible with $warnings warning(s)"
|
||||
} else {
|
||||
"Fully compatible"
|
||||
}
|
||||
} else {
|
||||
"Incompatible - see errors"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusIcon(): String {
|
||||
return if (isCompatible) {
|
||||
if (issues.any { it.severity == IssueSeverity.WARNING }) "⚠" else "✓"
|
||||
} else {
|
||||
"✗"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ValidationItem(
|
||||
val name: String,
|
||||
val passed: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
data class ValidationIssue(
|
||||
val severity: IssueSeverity,
|
||||
val category: String,
|
||||
val message: String
|
||||
)
|
||||
|
||||
enum class IssueSeverity {
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
data class DataPreview(
|
||||
val dailyMetricsPreview: String,
|
||||
val exerciseSessionsPreview: String,
|
||||
val metricsCount: Int,
|
||||
val exercisesCount: Int
|
||||
)
|
||||
|
||||
private data class FieldValidationResult(
|
||||
val validations: List<ValidationItem>,
|
||||
val issues: List<ValidationIssue>
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
|
||||
/** Retrofit API interface for communicating with HabitForge backend */
|
||||
interface HabitForgeApi {
|
||||
|
||||
/** Login with username and password (OAuth2 form data) Returns access token on success */
|
||||
@FormUrlEncoded
|
||||
@POST("api/auth/token")
|
||||
suspend fun login(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String
|
||||
): Response<LoginResponse>
|
||||
|
||||
/** Refresh access token */
|
||||
@POST("api/auth/refresh")
|
||||
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<LoginResponse>
|
||||
|
||||
/** Get current user information */
|
||||
@GET("api/auth/me")
|
||||
suspend fun getCurrentUser(@Header("Authorization") token: String): Response<UserResponse>
|
||||
|
||||
/** Sync aggregated health data (legacy endpoint) */
|
||||
@POST("api/health-connect/sync")
|
||||
suspend fun syncHealthData(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: HealthConnectSyncRequest
|
||||
): Response<SyncResponse>
|
||||
|
||||
/** Sync raw health data (new endpoint for individual records) */
|
||||
@POST("api/health-connect/sync-raw")
|
||||
suspend fun syncRawHealthData(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: RawDataSyncRequest
|
||||
): Response<RawSyncResponse>
|
||||
|
||||
@GET("api/health-connect/status")
|
||||
suspend fun getSyncStatus(
|
||||
@Header("Authorization") token: String
|
||||
): Response<SyncStatusResponse>
|
||||
|
||||
companion object {
|
||||
fun create(baseUrl: String): HabitForgeApi {
|
||||
val logging =
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(
|
||||
120,
|
||||
TimeUnit.SECONDS
|
||||
) // Increased for large raw data
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(HabitForgeApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== LEGACY REQUEST/RESPONSE MODELS ====================
|
||||
|
||||
data class HealthConnectSyncRequest(
|
||||
val device_id: String,
|
||||
val sync_timestamp: String,
|
||||
val daily_metrics: List<DailyMetricsPayload>,
|
||||
val exercise_sessions: List<ExerciseSessionPayload>
|
||||
)
|
||||
|
||||
data class DailyMetricsPayload(
|
||||
val date: String,
|
||||
val step_count: Int,
|
||||
val distance_meters: Double,
|
||||
val calories_burned: Double,
|
||||
val total_calories: Double = 0.0,
|
||||
val avg_heart_rate: Int?,
|
||||
val min_heart_rate: Int?,
|
||||
val max_heart_rate: Int?,
|
||||
val resting_heart_rate: Int?,
|
||||
val sleep_duration_minutes: Int,
|
||||
val deep_sleep_minutes: Int,
|
||||
val light_sleep_minutes: Int,
|
||||
val rem_sleep_minutes: Int,
|
||||
val awake_duration_minutes: Int,
|
||||
val weight: Double?,
|
||||
val avg_spo2: Double?,
|
||||
val min_spo2: Double? = null,
|
||||
val max_spo2: Double? = null,
|
||||
// Data source tracking
|
||||
val data_source: String? = null, // Package name (e.g., "com.huami.watch.hmwatchmanager")
|
||||
val data_source_name: String? = null // Friendly name (e.g., "Zepp")
|
||||
)
|
||||
|
||||
data class ExerciseSessionPayload(
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val exercise_type: Int,
|
||||
val exercise_type_name: String?,
|
||||
val duration_seconds: Int,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val calories_burned: Double? = null,
|
||||
val distance_meters: Double? = null,
|
||||
val avg_heart_rate: Int? = null,
|
||||
val max_heart_rate: Int? = null,
|
||||
val min_heart_rate: Int? = null,
|
||||
val steps: Int? = null,
|
||||
val elevation_gained: Double? = null
|
||||
)
|
||||
|
||||
data class SyncResponse(
|
||||
val status: String,
|
||||
val message: String,
|
||||
val sync_id: Int,
|
||||
val metrics_synced: Int,
|
||||
val exercises_synced: Int
|
||||
)
|
||||
|
||||
data class SyncStatusResponse(
|
||||
val has_data: Boolean,
|
||||
val last_sync: String?,
|
||||
val total_records: Int
|
||||
)
|
||||
|
||||
// ==================== RAW DATA SYNC MODELS ====================
|
||||
|
||||
/** Complete raw data sync request */
|
||||
data class RawDataSyncRequest(
|
||||
val device_id: String,
|
||||
val sync_timestamp: String,
|
||||
val steps_records: List<RawStepsPayload> = emptyList(),
|
||||
val heart_rate_records: List<RawHeartRatePayload> = emptyList(),
|
||||
val resting_hr_records: List<RawRestingHRPayload> = emptyList(),
|
||||
val sleep_sessions: List<RawSleepSessionPayload> = emptyList(),
|
||||
val distance_records: List<RawDistancePayload> = emptyList(),
|
||||
val calories_records: List<RawCaloriesPayload> = emptyList(),
|
||||
val spo2_records: List<RawSpo2Payload> = emptyList(),
|
||||
val weight_records: List<RawWeightPayload> = emptyList(),
|
||||
val height_records: List<RawHeightPayload> = emptyList(),
|
||||
val body_fat_records: List<RawBodyFatPayload> = emptyList(),
|
||||
val exercise_sessions: List<RawExercisePayload> = emptyList(),
|
||||
val nutrition_records: List<RawNutritionPayload> = emptyList(),
|
||||
val hydration_records: List<RawHydrationPayload> = emptyList()
|
||||
)
|
||||
|
||||
data class RawStepsPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val count: Long
|
||||
)
|
||||
|
||||
data class HeartRateSamplePayload(val time: String, val bpm: Long)
|
||||
|
||||
data class RawHeartRatePayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val samples: List<HeartRateSamplePayload>
|
||||
)
|
||||
|
||||
data class RawRestingHRPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val bpm: Long
|
||||
)
|
||||
|
||||
data class SleepStagePayload(
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val stage_type: Int,
|
||||
val stage_name: String?
|
||||
)
|
||||
|
||||
data class RawSleepSessionPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val stages: List<SleepStagePayload>
|
||||
)
|
||||
|
||||
data class RawDistancePayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val distance_meters: Double
|
||||
)
|
||||
|
||||
data class RawCaloriesPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val calories: Double,
|
||||
val record_type: String
|
||||
)
|
||||
|
||||
data class RawSpo2Payload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val percentage: Double
|
||||
)
|
||||
|
||||
data class RawWeightPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val weight_kg: Double
|
||||
)
|
||||
|
||||
data class RawHeightPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val height_meters: Double
|
||||
)
|
||||
|
||||
data class RawBodyFatPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val percentage: Double
|
||||
)
|
||||
|
||||
data class RawExercisePayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val exercise_type: Int,
|
||||
val exercise_type_name: String?,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val distance_meters: Double?,
|
||||
val calories: Double?,
|
||||
val avg_heart_rate: Int?,
|
||||
val max_heart_rate: Int?,
|
||||
val min_heart_rate: Int?,
|
||||
val steps: Int?,
|
||||
val elevation_gained_meters: Double?
|
||||
)
|
||||
|
||||
data class RawNutritionPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val name: String?,
|
||||
val meal_type: Int?,
|
||||
val calories: Double?,
|
||||
val protein_grams: Double?,
|
||||
val carbohydrates_grams: Double?,
|
||||
val fat_grams: Double?,
|
||||
val fiber_grams: Double?,
|
||||
val sugar_grams: Double?
|
||||
)
|
||||
|
||||
data class RawHydrationPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val volume_liters: Double
|
||||
)
|
||||
|
||||
data class RawSyncResponse(
|
||||
val status: String,
|
||||
val message: String,
|
||||
val sync_id: Int,
|
||||
val records_synced: Map<String, Int>
|
||||
)
|
||||
|
||||
// ==================== AUTH MODELS ====================
|
||||
|
||||
data class RefreshTokenRequest(val refresh_token: String)
|
||||
|
||||
data class LoginResponse(val access_token: String, val refresh_token: String, val token_type: String)
|
||||
|
||||
data class UserResponse(val id: Int, val username: String)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.app.Application
|
||||
import androidx.work.Configuration
|
||||
|
||||
class HabitForgeApp : Application(), Configuration.Provider {
|
||||
|
||||
lateinit var healthConnectManager: HealthConnectManager
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
healthConnectManager = HealthConnectManager(this)
|
||||
|
||||
// Initialize periodic sync if user is logged in (ensures sync persists after app restart)
|
||||
RawDataSyncService.initializeIfLoggedIn(this)
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder().setMinimumLoggingLevel(android.util.Log.INFO).build()
|
||||
|
||||
companion object {
|
||||
lateinit var instance: HabitForgeApp
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.*
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Health Connect Manager - Handles all interactions with Health Connect API Implements: Permission
|
||||
* requests, data reading, and source filtering
|
||||
*/
|
||||
class HealthConnectManager(private val context: Context) {
|
||||
|
||||
private var healthConnectClient: HealthConnectClient? = null
|
||||
|
||||
// Data source preferences
|
||||
private val prefs by lazy {
|
||||
context.getSharedPreferences("habitforge_sources", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HealthConnectManager"
|
||||
private const val PREF_PREFERRED_SOURCE = "preferred_data_source"
|
||||
private const val PREF_SOURCE_FILTER_ENABLED = "source_filter_enabled"
|
||||
|
||||
// All permissions we need to read health data
|
||||
val PERMISSIONS =
|
||||
setOf(
|
||||
// Activity
|
||||
HealthPermission.getReadPermission(StepsRecord::class),
|
||||
HealthPermission.getReadPermission(DistanceRecord::class),
|
||||
HealthPermission.getReadPermission(
|
||||
TotalCaloriesBurnedRecord::class
|
||||
),
|
||||
HealthPermission.getReadPermission(
|
||||
ActiveCaloriesBurnedRecord::class
|
||||
),
|
||||
|
||||
// Heart & Vitals
|
||||
HealthPermission.getReadPermission(HeartRateRecord::class),
|
||||
HealthPermission.getReadPermission(RestingHeartRateRecord::class),
|
||||
HealthPermission.getReadPermission(
|
||||
HeartRateVariabilityRmssdRecord::class
|
||||
),
|
||||
HealthPermission.getReadPermission(OxygenSaturationRecord::class),
|
||||
HealthPermission.getReadPermission(RespiratoryRateRecord::class),
|
||||
|
||||
// Body Measurements
|
||||
HealthPermission.getReadPermission(WeightRecord::class),
|
||||
HealthPermission.getReadPermission(HeightRecord::class),
|
||||
HealthPermission.getReadPermission(BodyFatRecord::class),
|
||||
HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
|
||||
|
||||
// Sleep
|
||||
HealthPermission.getReadPermission(SleepSessionRecord::class),
|
||||
|
||||
// Exercise
|
||||
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
||||
HealthPermission.getReadPermission(PowerRecord::class),
|
||||
HealthPermission.getReadPermission(SpeedRecord::class),
|
||||
|
||||
// Nutrition
|
||||
HealthPermission.getReadPermission(NutritionRecord::class),
|
||||
HealthPermission.getReadPermission(HydrationRecord::class),
|
||||
|
||||
// Historical data access (for data older than 30 days)
|
||||
"android.permission.health.READ_HEALTH_DATA_HISTORY"
|
||||
)
|
||||
|
||||
// Known data sources with friendly names
|
||||
val KNOWN_SOURCES =
|
||||
mapOf(
|
||||
"com.huami.watch.hmwatchmanager" to "Zepp",
|
||||
"com.google.android.apps.fitness" to "Google Fit",
|
||||
"com.sec.android.app.shealth" to "Samsung Health",
|
||||
"com.xiaomi.wearable" to "Mi Fitness",
|
||||
"com.oneplus.health" to "OnePlus Health"
|
||||
// Add more as discovered
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== DATA SOURCE MANAGEMENT ====================
|
||||
|
||||
/** Get all available data sources from Health Connect */
|
||||
suspend fun getAvailableDataSources(): List<DataSourceInfo> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val sources = mutableSetOf<String>()
|
||||
|
||||
try {
|
||||
// Check sources from recent steps records
|
||||
val timeRange =
|
||||
TimeRangeFilter.between(
|
||||
LocalDate.now()
|
||||
.minusDays(30)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant(),
|
||||
LocalDate.now()
|
||||
.plusDays(1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
)
|
||||
|
||||
val stepsRecords =
|
||||
readAllRecordsPaginated<StepsRecord>(client, timeRange)
|
||||
stepsRecords.forEach { record ->
|
||||
sources.add(record.metadata.dataOrigin.packageName)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Found ${sources.size} data sources: $sources")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting data sources", e)
|
||||
}
|
||||
|
||||
sources
|
||||
.map { packageName ->
|
||||
DataSourceInfo(
|
||||
packageName = packageName,
|
||||
displayName = KNOWN_SOURCES[packageName]
|
||||
?: packageName.substringAfterLast(
|
||||
"."
|
||||
)
|
||||
.replaceFirstChar {
|
||||
it.uppercase()
|
||||
},
|
||||
isPreferred = packageName == getPreferredSource()
|
||||
)
|
||||
}
|
||||
.sortedBy { it.displayName }
|
||||
}
|
||||
|
||||
/** Set the preferred data source */
|
||||
fun setPreferredSource(packageName: String?) {
|
||||
prefs.edit().putString(PREF_PREFERRED_SOURCE, packageName).apply()
|
||||
Log.d(TAG, "Preferred source set to: $packageName")
|
||||
}
|
||||
|
||||
/** Get the preferred data source */
|
||||
fun getPreferredSource(): String? {
|
||||
return prefs.getString(PREF_PREFERRED_SOURCE, null)
|
||||
}
|
||||
|
||||
/** Enable/disable source filtering */
|
||||
fun setSourceFilterEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(PREF_SOURCE_FILTER_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
/** Check if source filtering is enabled */
|
||||
fun isSourceFilterEnabled(): Boolean {
|
||||
return prefs.getBoolean(PREF_SOURCE_FILTER_ENABLED, false)
|
||||
}
|
||||
|
||||
/** Get friendly name for a package */
|
||||
fun getSourceDisplayName(packageName: String): String {
|
||||
return KNOWN_SOURCES[packageName]
|
||||
?: packageName.substringAfterLast(".").replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
// ==================== AVAILABILITY & PERMISSIONS ====================
|
||||
|
||||
/** Check if Health Connect is available on this device */
|
||||
fun isAvailable(): HealthConnectAvailability {
|
||||
return when (HealthConnectClient.getSdkStatus(context)) {
|
||||
HealthConnectClient.SDK_AVAILABLE -> {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(context)
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
}
|
||||
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
|
||||
HealthConnectAvailability.NOT_INSTALLED
|
||||
}
|
||||
else -> HealthConnectAvailability.NOT_SUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
/** Check which permissions are currently granted */
|
||||
suspend fun getGrantedPermissions(): Set<String> {
|
||||
val client = healthConnectClient ?: return emptySet()
|
||||
return client.permissionController.getGrantedPermissions()
|
||||
}
|
||||
|
||||
/** Check if we have all required permissions */
|
||||
suspend fun hasAllPermissions(): Boolean {
|
||||
val granted = getGrantedPermissions()
|
||||
return PERMISSIONS.all { it in granted }
|
||||
}
|
||||
|
||||
// ==================== DATA READING METHODS ====================
|
||||
|
||||
/** Read daily metrics for a specific date range */
|
||||
suspend fun readDailyMetrics(startDate: LocalDate, endDate: LocalDate): List<DailyMetrics> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client =
|
||||
healthConnectClient
|
||||
?: run {
|
||||
if (isAvailable() ==
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
)
|
||||
healthConnectClient
|
||||
else null
|
||||
}
|
||||
?: return@withContext emptyList()
|
||||
|
||||
// Validate date range
|
||||
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
|
||||
val actualEndDate =
|
||||
if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"=== Starting readDailyMetrics from $actualStartDate to $actualEndDate ==="
|
||||
)
|
||||
|
||||
val preferredSource =
|
||||
if (isSourceFilterEnabled()) getPreferredSource() else null
|
||||
Log.d(
|
||||
TAG,
|
||||
"Source filter: ${if (preferredSource != null) "filtering by $preferredSource" else "disabled"}"
|
||||
)
|
||||
|
||||
val metrics = mutableListOf<DailyMetrics>()
|
||||
var chunkStartDate = actualStartDate
|
||||
var chunkNumber = 0
|
||||
|
||||
// Process in 30-day chunks to avoid OOM and partial failures
|
||||
while (!chunkStartDate.isAfter(actualEndDate)) {
|
||||
chunkNumber++
|
||||
val chunkEndDate =
|
||||
if (chunkStartDate.plusDays(29).isAfter(actualEndDate)) {
|
||||
actualEndDate
|
||||
} else {
|
||||
chunkStartDate.plusDays(29)
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(
|
||||
TAG,
|
||||
"=== Chunk $chunkNumber: $chunkStartDate to $chunkEndDate ==="
|
||||
)
|
||||
|
||||
val chunkStartInstant =
|
||||
chunkStartDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
val chunkEndInstant =
|
||||
chunkEndDate
|
||||
.plusDays(1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
val timeRange =
|
||||
TimeRangeFilter.between(
|
||||
chunkStartInstant,
|
||||
chunkEndInstant
|
||||
)
|
||||
|
||||
// Fetch all metrics for this chunk using pagination
|
||||
val chunkSteps =
|
||||
try {
|
||||
val allSteps =
|
||||
readAllRecordsPaginated<
|
||||
StepsRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
// Filter by source if enabled, also filter
|
||||
// out invalid records
|
||||
allSteps.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
if (!validTime) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Skipping invalid StepsRecord: startTime >= endTime"
|
||||
)
|
||||
}
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading steps",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkDistance =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
DistanceRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading distance",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkCalories =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
TotalCaloriesBurnedRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading calories",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkHeartRate =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
HeartRateRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading heart rate",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkRestingHR =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
RestingHeartRateRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter {
|
||||
preferredSource == null ||
|
||||
it.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading resting HR",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkSleep =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
SleepSessionRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading sleep",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkWeight =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
WeightRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter {
|
||||
preferredSource == null ||
|
||||
it.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading weight",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkSpo2 =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
OxygenSaturationRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter {
|
||||
preferredSource == null ||
|
||||
it.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading spo2",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Chunk $chunkNumber filtered data: Steps=${chunkSteps.size}, Distance=${chunkDistance.size}, Calories=${chunkCalories.size}, HR=${chunkHeartRate.size}, Sleep=${chunkSleep.size}"
|
||||
)
|
||||
|
||||
// Get primary source for this chunk
|
||||
val primarySource =
|
||||
chunkSteps.firstOrNull()
|
||||
?.metadata
|
||||
?.dataOrigin
|
||||
?.packageName
|
||||
?: ""
|
||||
|
||||
// Process each day in this chunk
|
||||
var currentDate = chunkStartDate
|
||||
var daysWithSteps = 0
|
||||
|
||||
while (!currentDate.isAfter(chunkEndDate)) {
|
||||
try {
|
||||
val dayStart =
|
||||
currentDate
|
||||
.atStartOfDay(
|
||||
ZoneId.systemDefault()
|
||||
)
|
||||
.toInstant()
|
||||
val dayEnd =
|
||||
currentDate
|
||||
.plusDays(1)
|
||||
.atStartOfDay(
|
||||
ZoneId.systemDefault()
|
||||
)
|
||||
.toInstant()
|
||||
|
||||
// Filter records for this specific day
|
||||
val daySteps =
|
||||
chunkSteps
|
||||
.filter {
|
||||
!it.startTime.isBefore(dayStart) &&
|
||||
it.startTime.isBefore(dayEnd)
|
||||
}
|
||||
.sumOf { it.count }
|
||||
|
||||
if (daySteps > 0) {
|
||||
daysWithSteps++
|
||||
}
|
||||
|
||||
val dayDistance =
|
||||
chunkDistance
|
||||
.filter {
|
||||
!it.startTime.isBefore(dayStart) &&
|
||||
it.startTime.isBefore(dayEnd)
|
||||
}
|
||||
.sumOf {
|
||||
it.distance.inMeters
|
||||
}
|
||||
|
||||
val dayCalories =
|
||||
chunkCalories
|
||||
.filter {
|
||||
!it.startTime.isBefore(dayStart) &&
|
||||
it.startTime.isBefore(dayEnd)
|
||||
}
|
||||
.sumOf {
|
||||
it.energy
|
||||
.inKilocalories
|
||||
}
|
||||
|
||||
val dayHRRecords =
|
||||
chunkHeartRate.filter {
|
||||
!it.startTime.isBefore(dayStart) &&
|
||||
it.startTime.isBefore(dayEnd)
|
||||
}
|
||||
val hrSamples =
|
||||
dayHRRecords.flatMap { it.samples }
|
||||
|
||||
val dayRestingHR =
|
||||
chunkRestingHR
|
||||
.filter {
|
||||
!it.time.isBefore(dayStart) &&
|
||||
it.time.isBefore(dayEnd)
|
||||
}
|
||||
.lastOrNull()
|
||||
?.beatsPerMinute
|
||||
?.toInt()
|
||||
|
||||
val dayWeight =
|
||||
chunkWeight
|
||||
.filter {
|
||||
!it.time.isBefore(dayStart) &&
|
||||
it.time.isBefore(dayEnd)
|
||||
}
|
||||
.lastOrNull()
|
||||
?.weight
|
||||
?.inKilograms
|
||||
|
||||
val daySpo2 =
|
||||
chunkSpo2
|
||||
.filter {
|
||||
!it.time.isBefore(dayStart) &&
|
||||
it.time.isBefore(dayEnd)
|
||||
}
|
||||
.lastOrNull()
|
||||
?.percentage
|
||||
?.value
|
||||
|
||||
// Sleep logic - attribute to waking day
|
||||
val daySleep =
|
||||
chunkSleep.filter {
|
||||
!it.endTime.isBefore(dayStart) &&
|
||||
!it.endTime.isAfter(dayEnd)
|
||||
}
|
||||
var totalSleep = 0
|
||||
var deep = 0
|
||||
var light = 0
|
||||
var rem = 0
|
||||
var awake = 0
|
||||
|
||||
daySleep.forEach { session ->
|
||||
totalSleep +=
|
||||
ChronoUnit.MINUTES
|
||||
.between(
|
||||
session.startTime,
|
||||
session.endTime
|
||||
)
|
||||
.toInt()
|
||||
session.stages.forEach { stage ->
|
||||
val dur =
|
||||
ChronoUnit.MINUTES
|
||||
.between(
|
||||
stage.startTime,
|
||||
stage.endTime
|
||||
)
|
||||
.toInt()
|
||||
when (stage.stage) {
|
||||
SleepSessionRecord
|
||||
.STAGE_TYPE_DEEP ->
|
||||
deep += dur
|
||||
SleepSessionRecord
|
||||
.STAGE_TYPE_LIGHT ->
|
||||
light += dur
|
||||
SleepSessionRecord
|
||||
.STAGE_TYPE_REM ->
|
||||
rem += dur
|
||||
SleepSessionRecord
|
||||
.STAGE_TYPE_AWAKE ->
|
||||
awake += dur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics.add(
|
||||
DailyMetrics(
|
||||
date =
|
||||
currentDate
|
||||
.toString(),
|
||||
steps = daySteps,
|
||||
distanceMeters =
|
||||
dayDistance,
|
||||
caloriesBurned =
|
||||
dayCalories,
|
||||
avgHeartRate =
|
||||
if (hrSamples
|
||||
.isNotEmpty()
|
||||
) {
|
||||
hrSamples
|
||||
.map {
|
||||
it.beatsPerMinute
|
||||
}
|
||||
.average()
|
||||
.toInt()
|
||||
} else null,
|
||||
minHeartRate =
|
||||
if (hrSamples
|
||||
.isNotEmpty()
|
||||
) {
|
||||
hrSamples
|
||||
.minOf {
|
||||
it.beatsPerMinute
|
||||
}
|
||||
.toInt()
|
||||
} else null,
|
||||
maxHeartRate =
|
||||
if (hrSamples
|
||||
.isNotEmpty()
|
||||
) {
|
||||
hrSamples
|
||||
.maxOf {
|
||||
it.beatsPerMinute
|
||||
}
|
||||
.toInt()
|
||||
} else null,
|
||||
restingHeartRate =
|
||||
dayRestingHR,
|
||||
sleepDurationMinutes =
|
||||
totalSleep,
|
||||
deepSleepMinutes = deep,
|
||||
lightSleepMinutes = light,
|
||||
remSleepMinutes = rem,
|
||||
awakeMinutes = awake,
|
||||
weight = dayWeight,
|
||||
spo2 = daySpo2,
|
||||
dataSource = primarySource
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Error processing day $currentDate in chunk $chunkNumber",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
currentDate = currentDate.plusDays(1)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Chunk $chunkNumber complete: $daysWithSteps days with steps data"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"=== Chunk $chunkNumber FAILED with exception ===",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
// Move to next chunk
|
||||
chunkStartDate = chunkEndDate.plusDays(1)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"=== readDailyMetrics complete: ${metrics.size} total days, ${metrics.count { it.steps > 0 }} with steps ==="
|
||||
)
|
||||
metrics
|
||||
}
|
||||
|
||||
/** Read exercise sessions for a date range with pagination support */
|
||||
suspend fun readExerciseSessions(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<ExerciseData> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client =
|
||||
healthConnectClient
|
||||
?: run {
|
||||
if (isAvailable() ==
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
)
|
||||
healthConnectClient
|
||||
else null
|
||||
}
|
||||
?: return@withContext emptyList()
|
||||
|
||||
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
|
||||
val actualEndDate =
|
||||
if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Reading exercise sessions from $actualStartDate to $actualEndDate"
|
||||
)
|
||||
|
||||
val preferredSource =
|
||||
if (isSourceFilterEnabled()) getPreferredSource() else null
|
||||
val exercises = mutableListOf<ExerciseData>()
|
||||
|
||||
try {
|
||||
val timeRange =
|
||||
TimeRangeFilter.between(
|
||||
actualStartDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant(),
|
||||
actualEndDate
|
||||
.plusDays(1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
)
|
||||
|
||||
val allRecords =
|
||||
readAllRecordsPaginated<ExerciseSessionRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
for (record in allRecords) {
|
||||
val durationMinutes =
|
||||
ChronoUnit.MINUTES
|
||||
.between(record.startTime, record.endTime)
|
||||
.toInt()
|
||||
exercises.add(
|
||||
ExerciseData(
|
||||
startTime = record.startTime.toString(),
|
||||
endTime = record.endTime.toString(),
|
||||
exerciseType = record.exerciseType,
|
||||
exerciseTypeName =
|
||||
mapExerciseType(
|
||||
record.exerciseType
|
||||
),
|
||||
durationMinutes = durationMinutes,
|
||||
title = record.title
|
||||
?: mapExerciseType(
|
||||
record.exerciseType
|
||||
),
|
||||
notes = record.notes,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading exercise sessions", e)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Total exercise sessions found: ${exercises.size}")
|
||||
exercises
|
||||
}
|
||||
|
||||
// ==================== PRIVATE HELPER METHODS ====================
|
||||
|
||||
/**
|
||||
* Generic helper to read ALL records with pagination support. Health Connect returns max
|
||||
* 1000 records per request by default.
|
||||
*/
|
||||
private suspend inline fun <reified T : Record> readAllRecordsPaginated(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var pageToken: String? = null
|
||||
var pageCount = 0
|
||||
|
||||
do {
|
||||
pageCount++
|
||||
val request =
|
||||
if (pageToken != null) {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange,
|
||||
pageToken = pageToken
|
||||
)
|
||||
} else {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.readRecords(request)
|
||||
allRecords.addAll(response.records)
|
||||
pageToken = response.pageToken
|
||||
|
||||
if (response.records.isNotEmpty()) {
|
||||
Log.d(
|
||||
TAG,
|
||||
" Page $pageCount: ${response.records.size} ${T::class.simpleName} records (total: ${allRecords.size}, hasMore: ${pageToken != null})"
|
||||
)
|
||||
}
|
||||
} while (pageToken != null)
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private fun mapExerciseType(type: Int): String {
|
||||
return when (type) {
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "Walking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING -> "Running"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "Hiking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "Biking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL -> "Swimming"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING -> "Strength Training"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "Yoga"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "Pilates"
|
||||
else -> "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DATA CLASSES ====================
|
||||
|
||||
enum class HealthConnectAvailability {
|
||||
AVAILABLE,
|
||||
NOT_INSTALLED,
|
||||
NOT_SUPPORTED
|
||||
}
|
||||
|
||||
data class DataSourceInfo(
|
||||
val packageName: String,
|
||||
val displayName: String,
|
||||
val isPreferred: Boolean
|
||||
)
|
||||
|
||||
data class DailyMetrics(
|
||||
val date: String,
|
||||
val steps: Long,
|
||||
val distanceMeters: Double,
|
||||
val caloriesBurned: Double,
|
||||
val avgHeartRate: Int?,
|
||||
val minHeartRate: Int?,
|
||||
val maxHeartRate: Int?,
|
||||
val restingHeartRate: Int?,
|
||||
val sleepDurationMinutes: Int,
|
||||
val deepSleepMinutes: Int,
|
||||
val lightSleepMinutes: Int,
|
||||
val remSleepMinutes: Int,
|
||||
val awakeMinutes: Int,
|
||||
val weight: Double?,
|
||||
val spo2: Double?,
|
||||
val dataSource: String = "" // Package name of the data source
|
||||
)
|
||||
|
||||
data class ExerciseData(
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val exerciseType: Int,
|
||||
val exerciseTypeName: String,
|
||||
val durationMinutes: Int,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val dataSource: String = "" // Package name of the data source
|
||||
)
|
||||
|
||||
data class HeartRateData(val avg: Int?, val min: Int?, val max: Int?, val resting: Int?)
|
||||
|
||||
data class SleepData(
|
||||
val totalMinutes: Int,
|
||||
val deepMinutes: Int,
|
||||
val lightMinutes: Int,
|
||||
val remSleepMinutes: Int,
|
||||
val awakeMinutes: Int
|
||||
)
|
||||
+898
@@ -0,0 +1,898 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.records.*
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Health Connect Raw Data Reader
|
||||
*
|
||||
* Reads individual health records from Health Connect without aggregation. Each record includes its
|
||||
* full metadata (record ID, timestamps, source).
|
||||
*/
|
||||
class HealthConnectRawReader(private val context: Context) {
|
||||
|
||||
private var healthConnectClient: HealthConnectClient? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HCRawReader"
|
||||
|
||||
// Sleep stage type names
|
||||
val SLEEP_STAGE_NAMES =
|
||||
mapOf(
|
||||
SleepSessionRecord.STAGE_TYPE_UNKNOWN to "unknown",
|
||||
SleepSessionRecord.STAGE_TYPE_AWAKE to "awake",
|
||||
SleepSessionRecord.STAGE_TYPE_SLEEPING to "sleeping",
|
||||
SleepSessionRecord.STAGE_TYPE_OUT_OF_BED to "out_of_bed",
|
||||
SleepSessionRecord.STAGE_TYPE_LIGHT to "light",
|
||||
SleepSessionRecord.STAGE_TYPE_DEEP to "deep",
|
||||
SleepSessionRecord.STAGE_TYPE_REM to "rem",
|
||||
SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED to "awake_in_bed"
|
||||
)
|
||||
|
||||
// Known data sources with friendly names
|
||||
val KNOWN_SOURCES =
|
||||
mapOf(
|
||||
"com.huami.watch.hmwatchmanager" to "Zepp",
|
||||
"com.google.android.apps.fitness" to "Google Fit",
|
||||
"com.sec.android.app.shealth" to "Samsung Health",
|
||||
"com.xiaomi.wearable" to "Mi Fitness",
|
||||
"com.oneplus.health" to "OnePlus Health"
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
if (HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
|
||||
) {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSourceDisplayName(packageName: String): String {
|
||||
return KNOWN_SOURCES[packageName]
|
||||
?: packageName.substringAfterLast(".").replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
private fun Instant.toIsoString(): String {
|
||||
return DateTimeFormatter.ISO_INSTANT.format(this)
|
||||
}
|
||||
|
||||
// ==================== STEPS ====================
|
||||
|
||||
suspend fun readRawStepsRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawStepsRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawStepsRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<StepsRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
// Skip invalid records
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawStepsRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
count = record.count
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw step records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw steps", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== HEART RATE ====================
|
||||
|
||||
suspend fun readRawHeartRateRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawHeartRateRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawHeartRateRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<HeartRateRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
val samples =
|
||||
record.samples.map { sample ->
|
||||
HeartRateSample(
|
||||
time = sample.time.toIsoString(),
|
||||
bpm = sample.beatsPerMinute
|
||||
)
|
||||
}
|
||||
|
||||
records.add(
|
||||
RawHeartRateRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
samples = samples
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Read ${records.size} raw heart rate records with ${records.sumOf { it.samples.size }} samples"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw heart rate", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== RESTING HEART RATE ====================
|
||||
|
||||
suspend fun readRawRestingHeartRateRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawRestingHeartRateRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawRestingHeartRateRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<RestingHeartRateRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawRestingHeartRateRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
bpm = record.beatsPerMinute
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw resting HR records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw resting HR", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== SLEEP ====================
|
||||
|
||||
suspend fun readRawSleepSessions(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawSleepSession> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawSleepSession>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<SleepSessionRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
val stages =
|
||||
record.stages.mapNotNull { stage ->
|
||||
if (stage.startTime.isBefore(stage.endTime)
|
||||
) {
|
||||
SleepStageData(
|
||||
startTime =
|
||||
stage.startTime
|
||||
.toIsoString(),
|
||||
endTime =
|
||||
stage.endTime
|
||||
.toIsoString(),
|
||||
stageType = stage.stage,
|
||||
stageName =
|
||||
SLEEP_STAGE_NAMES[
|
||||
stage.stage]
|
||||
?: "unknown"
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
records.add(
|
||||
RawSleepSession(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
title = record.title,
|
||||
notes = record.notes,
|
||||
stages = stages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw sleep sessions")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw sleep", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== DISTANCE ====================
|
||||
|
||||
suspend fun readRawDistanceRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawDistanceRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawDistanceRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<DistanceRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawDistanceRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
distanceMeters = record.distance.inMeters
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw distance records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw distance", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== CALORIES ====================
|
||||
|
||||
suspend fun readRawCaloriesRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawCaloriesRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawCaloriesRecord>()
|
||||
|
||||
try {
|
||||
|
||||
// Active calories
|
||||
val activeRecords =
|
||||
readRangeRecords<ActiveCaloriesBurnedRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
for (record in activeRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawCaloriesRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
calories = record.energy.inKilocalories,
|
||||
recordType = "active"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Total calories
|
||||
val totalRecords =
|
||||
readRangeRecords<TotalCaloriesBurnedRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
for (record in totalRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawCaloriesRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
calories = record.energy.inKilocalories,
|
||||
recordType = "total"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw calories records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw calories", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== OXYGEN SATURATION ====================
|
||||
|
||||
suspend fun readRawOxygenSaturationRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawOxygenSaturationRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawOxygenSaturationRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<OxygenSaturationRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawOxygenSaturationRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
percentage = record.percentage.value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw SpO2 records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw SpO2", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== WEIGHT ====================
|
||||
|
||||
suspend fun readRawWeightRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawWeightRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawWeightRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<WeightRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawWeightRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
weightKg = record.weight.inKilograms
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw weight records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw weight", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== HEIGHT ====================
|
||||
|
||||
suspend fun readRawHeightRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawHeightRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawHeightRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<HeightRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawHeightRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
heightMeters = record.height.inMeters
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw height records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw height", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== BODY FAT ====================
|
||||
|
||||
suspend fun readRawBodyFatRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawBodyFatRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawBodyFatRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<BodyFatRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawBodyFatRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
percentage = record.percentage.value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw body fat records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw body fat", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== EXERCISE ====================
|
||||
|
||||
suspend fun readRawExerciseSessions(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawExerciseSession> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawExerciseSession>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<ExerciseSessionRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawExerciseSession(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
exerciseType = record.exerciseType,
|
||||
exerciseTypeName =
|
||||
mapExerciseType(
|
||||
record.exerciseType
|
||||
),
|
||||
title = record.title,
|
||||
notes = record.notes,
|
||||
distanceMeters =
|
||||
null, // Would need additional
|
||||
// segment queries
|
||||
calories = null,
|
||||
avgHeartRate = null,
|
||||
maxHeartRate = null,
|
||||
minHeartRate = null,
|
||||
steps = null,
|
||||
elevationGainedMeters = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw exercise sessions")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw exercise", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== NUTRITION ====================
|
||||
|
||||
suspend fun readRawNutritionRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawNutritionRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawNutritionRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<NutritionRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawNutritionRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
name = record.name,
|
||||
mealType = record.mealType,
|
||||
calories = record.energy?.inKilocalories,
|
||||
proteinGrams = record.protein?.inGrams,
|
||||
carbohydratesGrams =
|
||||
record.totalCarbohydrate?.inGrams,
|
||||
fatGrams = record.totalFat?.inGrams,
|
||||
fiberGrams = record.dietaryFiber?.inGrams,
|
||||
sugarGrams = record.sugar?.inGrams
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw nutrition records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw nutrition", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== HYDRATION ====================
|
||||
|
||||
suspend fun readRawHydrationRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawHydrationRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawHydrationRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<HydrationRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawHydrationRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
volumeLiters = record.volume.inLiters
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw hydration records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw hydration", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== HELPER METHODS ====================
|
||||
|
||||
/**
|
||||
* OPTIMIZED: Reads records for the entire date range in a single query. Falls back to
|
||||
* day-by-day reading if the range query fails (e.g., corrupt records).
|
||||
*
|
||||
* Performance gain: ~99% fewer Health Connect queries (1 query instead of N days)
|
||||
*/
|
||||
private suspend inline fun <reified T : Record> readRangeRecords(
|
||||
client: HealthConnectClient,
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<T> {
|
||||
val typeName = T::class.simpleName ?: "Unknown"
|
||||
|
||||
return try {
|
||||
// Try reading the entire range in one query (optimal)
|
||||
val instantStart =
|
||||
startDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val instantEnd =
|
||||
endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val timeRange = TimeRangeFilter.between(instantStart, instantEnd)
|
||||
|
||||
val records = readAllPaginated<T>(client, timeRange)
|
||||
Log.d(
|
||||
TAG,
|
||||
"[$typeName] Range read success: ${records.size} records from $startDate to $endDate"
|
||||
)
|
||||
records
|
||||
} catch (e: Exception) {
|
||||
// Fallback to day-by-day reading to isolate problematic records
|
||||
Log.w(
|
||||
TAG,
|
||||
"[$typeName] Range read failed, falling back to daily: ${e.message}"
|
||||
)
|
||||
readDailyRecords(client, startDate, endDate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads records day-by-day to isolate failures (e.g. corrupt records) to a single day
|
||||
* instead of failing the entire range. Used as fallback when range reading fails.
|
||||
*/
|
||||
private suspend inline fun <reified T : Record> readDailyRecords(
|
||||
client: HealthConnectClient,
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var current = startDate
|
||||
val typeName = T::class.simpleName ?: "Unknown"
|
||||
|
||||
// Ensure we don't loop forever
|
||||
val safetyLimit = 366 * 2 // Max 2 years
|
||||
var loopCount = 0
|
||||
var failedDays = 0
|
||||
|
||||
while (!current.isAfter(endDate) && loopCount < safetyLimit) {
|
||||
try {
|
||||
val instantStart =
|
||||
current.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val instantEnd =
|
||||
current.plusDays(1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
|
||||
val timeRange = TimeRangeFilter.between(instantStart, instantEnd)
|
||||
val dayRecords = readAllPaginated<T>(client, timeRange)
|
||||
allRecords.addAll(dayRecords)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[$typeName] Failed to read for $current: ${e.message}")
|
||||
failedDays++
|
||||
// Continue to next day
|
||||
}
|
||||
current = current.plusDays(1)
|
||||
loopCount++
|
||||
}
|
||||
|
||||
if (failedDays > 0) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"[$typeName] Daily read completed with $failedDays failed days, ${allRecords.size} records recovered"
|
||||
)
|
||||
} else {
|
||||
Log.d(TAG, "[$typeName] Daily read success: ${allRecords.size} records")
|
||||
}
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Record> readAllPaginated(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var pageToken: String? = null
|
||||
|
||||
do {
|
||||
val request =
|
||||
if (pageToken != null) {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange,
|
||||
pageToken = pageToken
|
||||
)
|
||||
} else {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.readRecords(request)
|
||||
allRecords.addAll(response.records)
|
||||
pageToken = response.pageToken
|
||||
} while (pageToken != null)
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private fun mapExerciseType(type: Int): String {
|
||||
return when (type) {
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "Walking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING -> "Running"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "Hiking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "Biking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL -> "Swimming"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING -> "Strength Training"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "Yoga"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "Pilates"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_DANCING -> "Dancing"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL -> "Elliptical"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING -> "Stair Climbing"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "Rowing"
|
||||
else -> "Exercise"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
/**
|
||||
* Tracks the count of records available in Health Connect vs synced to server. This helps users
|
||||
* identify what data is available and what has been synchronized.
|
||||
*/
|
||||
class MetricsCountTracker(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("metrics_count", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MetricsCountTracker"
|
||||
private const val KEY_AVAILABLE_COUNTS = "available_counts"
|
||||
private const val KEY_SYNCED_COUNTS = "synced_counts"
|
||||
private const val KEY_LAST_COUNT_TIME = "last_count_time"
|
||||
|
||||
// All trackable metric types with their display info
|
||||
val TRACKED_METRICS =
|
||||
listOf(
|
||||
TrackedMetricType("steps", "Steps", "👣"),
|
||||
TrackedMetricType("distance", "Distance", "📏"),
|
||||
TrackedMetricType("calories", "Calories", "🔥"),
|
||||
TrackedMetricType("heart_rate", "Heart Rate", "❤️"),
|
||||
TrackedMetricType("resting_hr", "Resting HR", "💗"),
|
||||
TrackedMetricType("sleep", "Sleep", "😴"),
|
||||
TrackedMetricType("weight", "Weight", "⚖️"),
|
||||
TrackedMetricType("height", "Height", "📐"),
|
||||
TrackedMetricType("body_fat", "Body Fat", "📊"),
|
||||
TrackedMetricType("spo2", "SpO2", "🫁"),
|
||||
TrackedMetricType("exercise", "Exercise", "🏃"),
|
||||
TrackedMetricType("nutrition", "Nutrition", "🍎"),
|
||||
TrackedMetricType("hydration", "Hydration", "💧")
|
||||
)
|
||||
}
|
||||
|
||||
/** Update the count of available records from Health Connect */
|
||||
fun updateAvailableCounts(counts: Map<String, Int>) {
|
||||
val json = gson.toJson(counts)
|
||||
prefs.edit()
|
||||
.putString(KEY_AVAILABLE_COUNTS, json)
|
||||
.putLong(KEY_LAST_COUNT_TIME, System.currentTimeMillis())
|
||||
.apply()
|
||||
Log.d(TAG, "Updated available counts: $counts")
|
||||
}
|
||||
|
||||
/** Update the count of records synced to server */
|
||||
fun updateSyncedCounts(counts: Map<String, Int>) {
|
||||
val currentCounts = getSyncedCounts().toMutableMap()
|
||||
// Add new synced counts to existing totals
|
||||
for ((key, value) in counts) {
|
||||
currentCounts[key] = value // Replace with server's reported count
|
||||
}
|
||||
val json = gson.toJson(currentCounts)
|
||||
prefs.edit().putString(KEY_SYNCED_COUNTS, json).apply()
|
||||
Log.d(TAG, "Updated synced counts: $currentCounts")
|
||||
}
|
||||
|
||||
/** Set exact synced counts (used when receiving totals from server) */
|
||||
fun setSyncedCounts(counts: Map<String, Int>) {
|
||||
val json = gson.toJson(counts)
|
||||
prefs.edit().putString(KEY_SYNCED_COUNTS, json).apply()
|
||||
Log.d(TAG, "Set synced counts: $counts")
|
||||
}
|
||||
|
||||
/** Get all metric count statuses */
|
||||
fun getAllMetricCounts(): List<MetricCountStatus> {
|
||||
val availableCounts = getAvailableCounts()
|
||||
val syncedCounts = getSyncedCounts()
|
||||
|
||||
return TRACKED_METRICS.map { metricType ->
|
||||
val available = availableCounts[metricType.id] ?: 0
|
||||
val synced = syncedCounts[metricType.id] ?: 0
|
||||
MetricCountStatus(
|
||||
metricType = metricType,
|
||||
availableCount = available,
|
||||
syncedCount = synced
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Get counts summary text for a specific metric */
|
||||
fun getMetricCountSummary(metricId: String): String {
|
||||
val availableCounts = getAvailableCounts()
|
||||
val syncedCounts = getSyncedCounts()
|
||||
val available = availableCounts[metricId] ?: 0
|
||||
val synced = syncedCounts[metricId] ?: 0
|
||||
return "$synced / $available"
|
||||
}
|
||||
|
||||
/** Get the last time counts were updated */
|
||||
fun getLastCountTime(): Long {
|
||||
return prefs.getLong(KEY_LAST_COUNT_TIME, 0)
|
||||
}
|
||||
|
||||
/** Clear all count data */
|
||||
fun clearAll() {
|
||||
prefs.edit()
|
||||
.remove(KEY_AVAILABLE_COUNTS)
|
||||
.remove(KEY_SYNCED_COUNTS)
|
||||
.remove(KEY_LAST_COUNT_TIME)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getAvailableCounts(): Map<String, Int> {
|
||||
val json = prefs.getString(KEY_AVAILABLE_COUNTS, null) ?: return emptyMap()
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, Int>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyMap()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing available counts", e)
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSyncedCounts(): Map<String, Int> {
|
||||
val json = prefs.getString(KEY_SYNCED_COUNTS, null) ?: return emptyMap()
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, Int>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyMap()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing synced counts", e)
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents a trackable metric type */
|
||||
data class TrackedMetricType(val id: String, val displayName: String, val icon: String)
|
||||
|
||||
/** Status of metric counts for available vs synced */
|
||||
data class MetricCountStatus(
|
||||
val metricType: TrackedMetricType,
|
||||
val availableCount: Int,
|
||||
val syncedCount: Int
|
||||
) {
|
||||
fun getSyncPercentage(): Int {
|
||||
return if (availableCount > 0) {
|
||||
((syncedCount.toDouble() / availableCount) * 100).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun isSynced(): Boolean = availableCount > 0 && syncedCount >= availableCount
|
||||
|
||||
fun getSyncStatus(): String {
|
||||
return when {
|
||||
availableCount == 0 -> "No data"
|
||||
syncedCount >= availableCount -> "✓ Complete"
|
||||
syncedCount > 0 -> "⚠ Partial (${getSyncPercentage()}%)"
|
||||
else -> "✗ Not synced"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusColor(): Int {
|
||||
return when {
|
||||
availableCount == 0 -> android.R.color.darker_gray
|
||||
syncedCount >= availableCount -> android.R.color.holo_green_dark
|
||||
syncedCount > 0 -> android.R.color.holo_orange_dark
|
||||
else -> android.R.color.holo_red_dark
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Tracks sync status for individual metric types
|
||||
*/
|
||||
class MetricsSyncTracker(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("metrics_sync", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val KEY_METRICS_STATUS = "metrics_status"
|
||||
|
||||
// All available metric types
|
||||
val ALL_METRICS = listOf(
|
||||
MetricType("steps", "Steps", "👣", "Daily step count"),
|
||||
MetricType("distance", "Distance", "📏", "Distance walked/run"),
|
||||
MetricType("calories", "Calories", "🔥", "Calories burned"),
|
||||
MetricType("heart_rate", "Heart Rate", "❤️", "Average, min, max heart rate"),
|
||||
MetricType("resting_hr", "Resting HR", "💗", "Resting heart rate"),
|
||||
MetricType("sleep", "Sleep", "😴", "Sleep duration and stages"),
|
||||
MetricType("weight", "Weight", "⚖️", "Body weight"),
|
||||
MetricType("spo2", "SpO2", "🫁", "Blood oxygen saturation"),
|
||||
MetricType("exercise", "Exercise", "🏃", "Workout sessions")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status of all metrics
|
||||
*/
|
||||
fun getAllMetricsStatus(): List<MetricSyncStatus> {
|
||||
val savedStatus = loadSavedStatus()
|
||||
|
||||
return ALL_METRICS.map { metricType ->
|
||||
savedStatus[metricType.id] ?: MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = null,
|
||||
lastValue = null,
|
||||
syncCount = 0,
|
||||
isAvailable = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sync status for a specific metric
|
||||
*/
|
||||
fun updateMetricStatus(
|
||||
metricId: String,
|
||||
lastValue: String?,
|
||||
isAvailable: Boolean = true
|
||||
) {
|
||||
val savedStatus = loadSavedStatus().toMutableMap()
|
||||
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
|
||||
|
||||
val existing = savedStatus[metricId]
|
||||
savedStatus[metricId] = MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = System.currentTimeMillis(),
|
||||
lastValue = lastValue,
|
||||
syncCount = (existing?.syncCount ?: 0) + 1,
|
||||
isAvailable = isAvailable
|
||||
)
|
||||
|
||||
saveStatus(savedStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update metrics after a sync operation
|
||||
*/
|
||||
fun updateFromSyncData(
|
||||
steps: Long?,
|
||||
distance: Double?,
|
||||
calories: Double?,
|
||||
avgHeartRate: Int?,
|
||||
restingHeartRate: Int?,
|
||||
sleepMinutes: Int?,
|
||||
weight: Double?,
|
||||
spo2: Double?,
|
||||
exerciseCount: Int
|
||||
) {
|
||||
// Steps: always mark as available if we have any value (including 0)
|
||||
steps?.let { updateMetricStatus("steps", "$it steps", true) }
|
||||
|
||||
// Distance: available if value exists
|
||||
distance?.let { updateMetricStatus("distance", String.format(Locale.US, "%.1f m", it), true) }
|
||||
|
||||
// Calories: available if value exists
|
||||
calories?.let { updateMetricStatus("calories", String.format(Locale.US, "%.0f kcal", it), true) }
|
||||
|
||||
// Heart rate: available if we have a reading
|
||||
avgHeartRate?.let { updateMetricStatus("heart_rate", "$it bpm", it > 0) }
|
||||
restingHeartRate?.let { updateMetricStatus("resting_hr", "$it bpm", it > 0) }
|
||||
|
||||
// Sleep: available if we have duration
|
||||
sleepMinutes?.let {
|
||||
val hours = it / 60
|
||||
val mins = it % 60
|
||||
updateMetricStatus("sleep", "${hours}h ${mins}m", it > 0)
|
||||
}
|
||||
|
||||
// Weight: available if we have a measurement
|
||||
weight?.let { updateMetricStatus("weight", String.format(Locale.US, "%.1f kg", it), it > 0) }
|
||||
|
||||
// SpO2: available if we have a reading
|
||||
spo2?.let { updateMetricStatus("spo2", String.format(Locale.US, "%.0f%%", it), it > 0) }
|
||||
|
||||
// Exercise: available if we have sessions
|
||||
if (exerciseCount > 0) {
|
||||
updateMetricStatus("exercise", "$exerciseCount sessions", true)
|
||||
} else {
|
||||
updateMetricStatus("exercise", "0 sessions", false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark metric as unavailable (no data from Health Connect)
|
||||
*/
|
||||
fun markUnavailable(metricId: String) {
|
||||
val savedStatus = loadSavedStatus().toMutableMap()
|
||||
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
|
||||
|
||||
val existing = savedStatus[metricId]
|
||||
savedStatus[metricId] = MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = existing?.lastSyncTime,
|
||||
lastValue = existing?.lastValue,
|
||||
syncCount = existing?.syncCount ?: 0,
|
||||
isAvailable = false
|
||||
)
|
||||
|
||||
saveStatus(savedStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync history
|
||||
*/
|
||||
fun clearAll() {
|
||||
prefs.edit().remove(KEY_METRICS_STATUS).apply()
|
||||
}
|
||||
|
||||
private fun loadSavedStatus(): Map<String, MetricSyncStatus> {
|
||||
val json = prefs.getString(KEY_METRICS_STATUS, null) ?: return emptyMap()
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, MetricSyncStatus>>() {}.type
|
||||
gson.fromJson(json, type)
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveStatus(status: Map<String, MetricSyncStatus>) {
|
||||
val json = gson.toJson(status)
|
||||
prefs.edit().putString(KEY_METRICS_STATUS, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a type of health metric
|
||||
*/
|
||||
data class MetricType(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val icon: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync status for a specific metric
|
||||
*/
|
||||
data class MetricSyncStatus(
|
||||
val metricType: MetricType,
|
||||
val lastSyncTime: Long?,
|
||||
val lastValue: String?,
|
||||
val syncCount: Int,
|
||||
val isAvailable: Boolean
|
||||
) {
|
||||
fun getFormattedSyncTime(): String {
|
||||
return if (lastSyncTime != null) {
|
||||
val format = SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault())
|
||||
format.format(Date(lastSyncTime))
|
||||
} else {
|
||||
"Never"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusIcon(): String {
|
||||
return when {
|
||||
!isAvailable && lastSyncTime == null -> "⚪" // Never synced
|
||||
!isAvailable -> "🔴" // Was synced but no data available
|
||||
lastSyncTime != null -> "🟢" // Synced with data
|
||||
else -> "⚪"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusText(): String {
|
||||
return when {
|
||||
!isAvailable && lastSyncTime == null -> "Not synced"
|
||||
!isAvailable -> "No data"
|
||||
lastSyncTime != null -> lastValue ?: "Synced"
|
||||
else -> "Pending"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
/**
|
||||
* Privacy Policy Activity - Required by Health Connect
|
||||
* Shows why we need access to health data
|
||||
*/
|
||||
class PrivacyPolicyActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val webView = WebView(this)
|
||||
setContentView(webView)
|
||||
|
||||
webView.loadData(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
h1 { color: #0ea5e9; }
|
||||
h2 { color: #666; margin-top: 24px; }
|
||||
ul { padding-left: 20px; }
|
||||
.highlight {
|
||||
background: #f0f9ff;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HabitForge Companion</h1>
|
||||
<h2>Privacy Policy & Health Data Usage</h2>
|
||||
|
||||
<div class="highlight">
|
||||
<strong>Why we need access to your health data:</strong>
|
||||
<p>HabitForge uses your health data to help you track your fitness progress,
|
||||
analyze your habits, and achieve your wellness goals.</p>
|
||||
</div>
|
||||
|
||||
<h2>Data We Collect</h2>
|
||||
<ul>
|
||||
<li><strong>Activity Data:</strong> Steps, distance, calories burned</li>
|
||||
<li><strong>Heart Rate:</strong> Average, resting, and workout heart rates</li>
|
||||
<li><strong>Sleep:</strong> Duration and sleep stages</li>
|
||||
<li><strong>Body Measurements:</strong> Weight, height, BMI</li>
|
||||
<li><strong>Workouts:</strong> Exercise sessions and types</li>
|
||||
<li><strong>Vitals:</strong> SpO2, respiratory rate</li>
|
||||
</ul>
|
||||
|
||||
<h2>How We Use Your Data</h2>
|
||||
<ul>
|
||||
<li>Sync health metrics to your HabitForge account</li>
|
||||
<li>Display progress charts and statistics</li>
|
||||
<li>Track workout history and achievements</li>
|
||||
<li>Calculate readiness and recovery scores</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Security</h2>
|
||||
<ul>
|
||||
<li>Your data is encrypted during transmission (HTTPS)</li>
|
||||
<li>Data is stored securely on your configured HabitForge server</li>
|
||||
<li>You can delete your data at any time from the app</li>
|
||||
<li>We never sell or share your health data with third parties</li>
|
||||
</ul>
|
||||
|
||||
<h2>Your Rights</h2>
|
||||
<ul>
|
||||
<li>You can revoke permissions at any time</li>
|
||||
<li>You can request deletion of your data</li>
|
||||
<li>You control which data types are shared</li>
|
||||
</ul>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>For questions about your data, contact your HabitForge server administrator.</p>
|
||||
|
||||
<p style="margin-top: 40px; color: #999; font-size: 12px;">
|
||||
Last updated: January 2026
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
"text/html",
|
||||
"UTF-8"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
/**
|
||||
* Raw Data Models - Data classes for individual Health Connect records. These represent raw,
|
||||
* unaggregated data from Health Connect.
|
||||
*/
|
||||
|
||||
// ==================== STEPS ====================
|
||||
|
||||
data class RawStepsRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String, // ISO8601 format
|
||||
val endTime: String,
|
||||
val count: Long
|
||||
)
|
||||
|
||||
// ==================== HEART RATE ====================
|
||||
|
||||
data class HeartRateSample(
|
||||
val time: String, // ISO8601
|
||||
val bpm: Long
|
||||
)
|
||||
|
||||
data class RawHeartRateRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val samples: List<HeartRateSample>
|
||||
)
|
||||
|
||||
data class RawRestingHeartRateRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val bpm: Long
|
||||
)
|
||||
|
||||
// ==================== SLEEP ====================
|
||||
|
||||
data class SleepStageData(
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val stageType: Int,
|
||||
val stageName: String
|
||||
)
|
||||
|
||||
data class RawSleepSession(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val stages: List<SleepStageData>
|
||||
)
|
||||
|
||||
// ==================== DISTANCE ====================
|
||||
|
||||
data class RawDistanceRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val distanceMeters: Double
|
||||
)
|
||||
|
||||
// ==================== CALORIES ====================
|
||||
|
||||
data class RawCaloriesRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val calories: Double,
|
||||
val recordType: String // "active" or "total"
|
||||
)
|
||||
|
||||
// ==================== OXYGEN SATURATION ====================
|
||||
|
||||
data class RawOxygenSaturationRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val percentage: Double
|
||||
)
|
||||
|
||||
// ==================== WEIGHT ====================
|
||||
|
||||
data class RawWeightRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val weightKg: Double
|
||||
)
|
||||
|
||||
// ==================== HEIGHT ====================
|
||||
|
||||
data class RawHeightRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val heightMeters: Double
|
||||
)
|
||||
|
||||
// ==================== BODY FAT ====================
|
||||
|
||||
data class RawBodyFatRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val percentage: Double
|
||||
)
|
||||
|
||||
// ==================== EXERCISE ====================
|
||||
|
||||
data class RawExerciseSession(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val exerciseType: Int,
|
||||
val exerciseTypeName: String,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val distanceMeters: Double?,
|
||||
val calories: Double?,
|
||||
val avgHeartRate: Int?,
|
||||
val maxHeartRate: Int?,
|
||||
val minHeartRate: Int?,
|
||||
val steps: Int?,
|
||||
val elevationGainedMeters: Double?
|
||||
)
|
||||
|
||||
// ==================== NUTRITION ====================
|
||||
|
||||
data class RawNutritionRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val name: String?,
|
||||
val mealType: Int?,
|
||||
val calories: Double?,
|
||||
val proteinGrams: Double?,
|
||||
val carbohydratesGrams: Double?,
|
||||
val fatGrams: Double?,
|
||||
val fiberGrams: Double?,
|
||||
val sugarGrams: Double?
|
||||
)
|
||||
|
||||
// ==================== HYDRATION ====================
|
||||
|
||||
data class RawHydrationRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val volumeLiters: Double
|
||||
)
|
||||
@@ -0,0 +1,842 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Raw Data Sync Service - Syncs individual health records to backend.
|
||||
*
|
||||
* This service reads raw (unaggregated) health data from Health Connect and sends each record
|
||||
* individually to the backend for storage.
|
||||
*
|
||||
* OPTIMIZED: Uses 30-day batches when syncing long ranges to improve performance and throughput.
|
||||
* Uses 7-day default sync period for scheduled jobs.
|
||||
*/
|
||||
class RawDataSyncService(context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RawDataSyncService"
|
||||
private const val WORK_NAME = "health_connect_raw_sync"
|
||||
|
||||
// Default: 7 days for regular syncs (manageable volume)
|
||||
private const val SYNC_DAYS = 7
|
||||
// Full sync: 30 days (use only for initial/recovery)
|
||||
private const val FULL_SYNC_DAYS = 30
|
||||
// Max HR samples per record to limit payload size
|
||||
private const val MAX_HR_SAMPLES_PER_RECORD = 100
|
||||
|
||||
/**
|
||||
* Initialize periodic sync on app startup if user is logged in. This ensures sync
|
||||
* continues after app restart.
|
||||
*/
|
||||
fun initializeIfLoggedIn(context: Context) {
|
||||
val authPrefs =
|
||||
context.getSharedPreferences(
|
||||
"habitforge_auth",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val legacyPrefs =
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
|
||||
val authToken =
|
||||
authPrefs.getString("access_token", null)
|
||||
?: legacyPrefs.getString("auth_token", null)
|
||||
|
||||
if (!authToken.isNullOrEmpty()) {
|
||||
val periodMinutes = legacyPrefs.getInt("sync_period_minutes", 360)
|
||||
if (periodMinutes > 0) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"[INIT] User is logged in, restoring periodic sync (every ${periodMinutes}min)"
|
||||
)
|
||||
schedulePeriodicSync(context, periodMinutes)
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"[INIT] User is logged in but periodic sync is disabled"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"[INIT] No auth token found, skipping periodic sync initialization"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule periodic raw data sync */
|
||||
fun schedulePeriodicSync(context: Context, periodMinutes: Int = 360) {
|
||||
// Remove strict battery constraints to ensure it runs
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val syncWork =
|
||||
PeriodicWorkRequestBuilder<RawDataSyncService>(
|
||||
periodMinutes.toLong(),
|
||||
TimeUnit.MINUTES,
|
||||
15,
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.LINEAR,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.addTag("periodic_raw_sync")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
syncWork
|
||||
)
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"[SCHEDULE] Periodic sync scheduled every $periodMinutes minutes (Network required)"
|
||||
)
|
||||
|
||||
// Also log to shared prefs for debugging
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong("last_sync_scheduled", System.currentTimeMillis())
|
||||
.putInt("sync_period_minutes", periodMinutes)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Trigger an immediate raw data sync */
|
||||
fun syncNow(context: Context, days: Int? = null) {
|
||||
val actualDays =
|
||||
days
|
||||
?: context.getSharedPreferences(
|
||||
"habitforge",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
.getInt("sync_days", SYNC_DAYS)
|
||||
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val syncWork =
|
||||
OneTimeWorkRequestBuilder<RawDataSyncService>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(
|
||||
workDataOf(
|
||||
"days" to actualDays,
|
||||
"is_manual" to true
|
||||
)
|
||||
)
|
||||
.addTag("manual_raw_sync")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(syncWork)
|
||||
Log.i(
|
||||
TAG,
|
||||
"[MANUAL] Immediate raw data sync triggered for $actualDays days"
|
||||
)
|
||||
}
|
||||
|
||||
/** Full sync for initial setup or recovery (30 days) */
|
||||
fun syncFull(context: Context) {
|
||||
syncNow(context, FULL_SYNC_DAYS)
|
||||
}
|
||||
|
||||
/** Cancel raw data sync */
|
||||
fun cancelSync(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.i(TAG, "[CANCEL] Raw data sync cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result =
|
||||
withContext(Dispatchers.IO) {
|
||||
val isManual = inputData.getBoolean("is_manual", false)
|
||||
val syncTypeLabel = if (isManual) "MANUAL" else "SCHEDULED"
|
||||
Log.i(TAG, "========================================")
|
||||
Log.i(TAG, "[$syncTypeLabel] Starting raw data sync...")
|
||||
Log.i(TAG, "========================================")
|
||||
val overallStartTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
val authPrefs =
|
||||
applicationContext.getSharedPreferences(
|
||||
"habitforge_auth",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
var authToken = authPrefs.getString("access_token", null)
|
||||
|
||||
val legacyPrefs =
|
||||
applicationContext.getSharedPreferences(
|
||||
"habitforge",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
if (authToken == null) {
|
||||
authToken = legacyPrefs.getString("auth_token", null)
|
||||
}
|
||||
|
||||
if (authToken.isNullOrEmpty()) {
|
||||
Log.e(TAG, "No auth token configured")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// Initialize raw reader
|
||||
val rawReader = HealthConnectRawReader(applicationContext)
|
||||
|
||||
// Determine sync range
|
||||
val configuredDays = legacyPrefs.getInt("sync_days", SYNC_DAYS)
|
||||
val days = inputData.getInt("days", configuredDays)
|
||||
|
||||
// Ensure we have a valid range
|
||||
val finalEndDate = LocalDate.now()
|
||||
val overallStartDate = finalEndDate.minusDays(days.toLong())
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Syncing raw data from $overallStartDate to $finalEndDate ($days days)"
|
||||
)
|
||||
|
||||
// Configure batching
|
||||
// Use 30-day batches to improve throughput while staying within
|
||||
// safe limits
|
||||
val BATCH_DAYS = 30
|
||||
var currentStartDate = overallStartDate
|
||||
var totalRecordsSynced = 0
|
||||
var totalBatches = 0
|
||||
var failedBatches = 0
|
||||
|
||||
// Track available counts per metric type
|
||||
val availableCounts = mutableMapOf<String, Int>()
|
||||
val syncedCounts = mutableMapOf<String, Int>()
|
||||
|
||||
val historyManager = SyncHistoryManager(applicationContext)
|
||||
val metricsCountTracker = MetricsCountTracker(applicationContext)
|
||||
|
||||
var backendUrl = authPrefs.getString("server_url", null)
|
||||
if (backendUrl == null) {
|
||||
backendUrl =
|
||||
legacyPrefs.getString(
|
||||
"server_url",
|
||||
BuildConfig.BACKEND_URL
|
||||
)
|
||||
}
|
||||
backendUrl = backendUrl ?: BuildConfig.BACKEND_URL
|
||||
val api = HabitForgeApi.create(backendUrl)
|
||||
|
||||
// Get device ID once
|
||||
val deviceId =
|
||||
Settings.Secure.getString(
|
||||
applicationContext.contentResolver,
|
||||
Settings.Secure.ANDROID_ID
|
||||
)
|
||||
|
||||
// Iterate in batches
|
||||
while (!currentStartDate.isAfter(finalEndDate)) {
|
||||
val currentEndDate =
|
||||
currentStartDate.plusDays((BATCH_DAYS - 1).toLong())
|
||||
.let {
|
||||
if (it.isAfter(finalEndDate))
|
||||
finalEndDate
|
||||
else it
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Processing batch: $currentStartDate to $currentEndDate"
|
||||
)
|
||||
|
||||
try {
|
||||
// OPTIMIZED: Read all records for this batch IN
|
||||
// PARALLEL
|
||||
// Performance gain: ~70% faster by reading all 13
|
||||
// metric types concurrently
|
||||
val readStartTime = System.currentTimeMillis()
|
||||
|
||||
val (
|
||||
stepsRecords,
|
||||
heartRateRecords,
|
||||
restingHRRecords,
|
||||
sleepSessions,
|
||||
distanceRecords,
|
||||
caloriesRecords,
|
||||
spo2Records,
|
||||
weightRecords,
|
||||
heightRecords,
|
||||
bodyFatRecords,
|
||||
exerciseSessions,
|
||||
nutritionRecords,
|
||||
hydrationRecords) =
|
||||
coroutineScope {
|
||||
val stepsDeferred = async {
|
||||
rawReader
|
||||
.readRawStepsRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val heartRateDeferred = async {
|
||||
rawReader
|
||||
.readRawHeartRateRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val restingHRDeferred = async {
|
||||
rawReader
|
||||
.readRawRestingHeartRateRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val sleepDeferred = async {
|
||||
rawReader
|
||||
.readRawSleepSessions(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val distanceDeferred = async {
|
||||
rawReader
|
||||
.readRawDistanceRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val caloriesDeferred = async {
|
||||
rawReader
|
||||
.readRawCaloriesRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val spo2Deferred = async {
|
||||
rawReader
|
||||
.readRawOxygenSaturationRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val weightDeferred = async {
|
||||
rawReader
|
||||
.readRawWeightRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val heightDeferred = async {
|
||||
rawReader
|
||||
.readRawHeightRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val bodyFatDeferred = async {
|
||||
rawReader
|
||||
.readRawBodyFatRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val exerciseDeferred = async {
|
||||
rawReader
|
||||
.readRawExerciseSessions(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val nutritionDeferred = async {
|
||||
rawReader
|
||||
.readRawNutritionRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val hydrationDeferred = async {
|
||||
rawReader
|
||||
.readRawHydrationRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
|
||||
// Await all results (this is where
|
||||
// parallelism happens)
|
||||
Tuple13(
|
||||
stepsDeferred.await(),
|
||||
heartRateDeferred.await(),
|
||||
restingHRDeferred.await(),
|
||||
sleepDeferred.await(),
|
||||
distanceDeferred.await(),
|
||||
caloriesDeferred.await(),
|
||||
spo2Deferred.await(),
|
||||
weightDeferred.await(),
|
||||
heightDeferred.await(),
|
||||
bodyFatDeferred.await(),
|
||||
exerciseDeferred.await(),
|
||||
nutritionDeferred.await(),
|
||||
hydrationDeferred.await()
|
||||
)
|
||||
}
|
||||
|
||||
val readDuration =
|
||||
System.currentTimeMillis() - readStartTime
|
||||
Log.d(
|
||||
TAG,
|
||||
"[$syncTypeLabel] Parallel read completed in ${readDuration}ms for batch $currentStartDate to $currentEndDate"
|
||||
)
|
||||
|
||||
// Convert to API payloads
|
||||
val stepsPayload =
|
||||
stepsRecords.map { r ->
|
||||
RawStepsPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.count
|
||||
)
|
||||
}
|
||||
|
||||
val heartRatePayload =
|
||||
heartRateRecords.map { r ->
|
||||
RawHeartRatePayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.samples.take(
|
||||
MAX_HR_SAMPLES_PER_RECORD
|
||||
)
|
||||
.map { s ->
|
||||
HeartRateSamplePayload(
|
||||
s.time,
|
||||
s.bpm
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val restingHRPayload =
|
||||
restingHRRecords.map { r ->
|
||||
RawRestingHRPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.bpm
|
||||
)
|
||||
}
|
||||
|
||||
val sleepPayload =
|
||||
sleepSessions.map { r ->
|
||||
RawSleepSessionPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.title,
|
||||
r.notes,
|
||||
r.stages.map { s ->
|
||||
SleepStagePayload(
|
||||
s.startTime,
|
||||
s.endTime,
|
||||
s.stageType,
|
||||
s.stageName
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val distancePayload =
|
||||
distanceRecords.map { r ->
|
||||
RawDistancePayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.distanceMeters
|
||||
)
|
||||
}
|
||||
|
||||
val caloriesPayload =
|
||||
caloriesRecords.map { r: RawCaloriesRecord
|
||||
->
|
||||
RawCaloriesPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.calories,
|
||||
r.recordType
|
||||
)
|
||||
}
|
||||
|
||||
val spo2Payload =
|
||||
spo2Records.map {
|
||||
r: RawOxygenSaturationRecord ->
|
||||
RawSpo2Payload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.percentage
|
||||
)
|
||||
}
|
||||
|
||||
val weightPayload =
|
||||
weightRecords.map { r: RawWeightRecord ->
|
||||
RawWeightPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.weightKg
|
||||
)
|
||||
}
|
||||
|
||||
val heightPayload =
|
||||
heightRecords.map { r: RawHeightRecord ->
|
||||
RawHeightPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.heightMeters
|
||||
)
|
||||
}
|
||||
|
||||
val bodyFatPayload =
|
||||
bodyFatRecords.map { r: RawBodyFatRecord ->
|
||||
RawBodyFatPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.percentage
|
||||
)
|
||||
}
|
||||
|
||||
val exercisePayload =
|
||||
exerciseSessions.map { r: RawExerciseSession
|
||||
->
|
||||
RawExercisePayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.exerciseType,
|
||||
r.exerciseTypeName,
|
||||
r.title,
|
||||
r.notes,
|
||||
r.distanceMeters,
|
||||
r.calories,
|
||||
r.avgHeartRate,
|
||||
r.maxHeartRate,
|
||||
r.minHeartRate,
|
||||
r.steps,
|
||||
r.elevationGainedMeters
|
||||
)
|
||||
}
|
||||
|
||||
val nutritionPayload =
|
||||
nutritionRecords.map { r: RawNutritionRecord
|
||||
->
|
||||
RawNutritionPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.name,
|
||||
r.mealType,
|
||||
r.calories,
|
||||
r.proteinGrams,
|
||||
r.carbohydratesGrams,
|
||||
r.fatGrams,
|
||||
r.fiberGrams,
|
||||
r.sugarGrams
|
||||
)
|
||||
}
|
||||
|
||||
val hydrationPayload =
|
||||
hydrationRecords.map { r: RawHydrationRecord
|
||||
->
|
||||
RawHydrationPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.volumeLiters
|
||||
)
|
||||
}
|
||||
|
||||
val batchRecordsCount =
|
||||
stepsPayload.size +
|
||||
heartRatePayload.size +
|
||||
restingHRPayload.size +
|
||||
sleepPayload.size +
|
||||
distancePayload.size +
|
||||
caloriesPayload.size +
|
||||
spo2Payload.size +
|
||||
weightPayload.size +
|
||||
heightPayload.size +
|
||||
bodyFatPayload.size +
|
||||
exercisePayload.size +
|
||||
nutritionPayload.size +
|
||||
hydrationPayload.size
|
||||
|
||||
// Accumulate available counts for this batch
|
||||
availableCounts["steps"] =
|
||||
(availableCounts["steps"]
|
||||
?: 0) + stepsPayload.size
|
||||
availableCounts["heart_rate"] =
|
||||
(availableCounts["heart_rate"]
|
||||
?: 0) + heartRatePayload.size
|
||||
availableCounts["resting_hr"] =
|
||||
(availableCounts["resting_hr"]
|
||||
?: 0) + restingHRPayload.size
|
||||
availableCounts["sleep"] =
|
||||
(availableCounts["sleep"]
|
||||
?: 0) + sleepPayload.size
|
||||
availableCounts["distance"] =
|
||||
(availableCounts["distance"]
|
||||
?: 0) + distancePayload.size
|
||||
availableCounts["calories"] =
|
||||
(availableCounts["calories"]
|
||||
?: 0) + caloriesPayload.size
|
||||
availableCounts["spo2"] =
|
||||
(availableCounts["spo2"]
|
||||
?: 0) + spo2Payload.size
|
||||
availableCounts["weight"] =
|
||||
(availableCounts["weight"]
|
||||
?: 0) + weightPayload.size
|
||||
availableCounts["height"] =
|
||||
(availableCounts["height"]
|
||||
?: 0) + heightPayload.size
|
||||
availableCounts["body_fat"] =
|
||||
(availableCounts["body_fat"]
|
||||
?: 0) + bodyFatPayload.size
|
||||
availableCounts["exercise"] =
|
||||
(availableCounts["exercise"]
|
||||
?: 0) + exercisePayload.size
|
||||
availableCounts["nutrition"] =
|
||||
(availableCounts["nutrition"]
|
||||
?: 0) + nutritionPayload.size
|
||||
availableCounts["hydration"] =
|
||||
(availableCounts["hydration"]
|
||||
?: 0) + hydrationPayload.size
|
||||
|
||||
if (batchRecordsCount > 0) {
|
||||
// Build request
|
||||
val request =
|
||||
RawDataSyncRequest(
|
||||
device_id = deviceId,
|
||||
sync_timestamp =
|
||||
Instant.now()
|
||||
.toString(),
|
||||
steps_records =
|
||||
stepsPayload,
|
||||
heart_rate_records =
|
||||
heartRatePayload,
|
||||
resting_hr_records =
|
||||
restingHRPayload,
|
||||
sleep_sessions =
|
||||
sleepPayload,
|
||||
distance_records =
|
||||
distancePayload,
|
||||
calories_records =
|
||||
caloriesPayload,
|
||||
spo2_records = spo2Payload,
|
||||
weight_records =
|
||||
weightPayload,
|
||||
height_records =
|
||||
heightPayload,
|
||||
body_fat_records =
|
||||
bodyFatPayload,
|
||||
exercise_sessions =
|
||||
exercisePayload,
|
||||
nutrition_records =
|
||||
nutritionPayload,
|
||||
hydration_records =
|
||||
hydrationPayload
|
||||
)
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Sending batch $currentStartDate to $currentEndDate with $batchRecordsCount records..."
|
||||
)
|
||||
val response =
|
||||
api.syncRawHealthData(
|
||||
"Bearer $authToken",
|
||||
request
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val syncedInBatch =
|
||||
body?.records_synced?.values
|
||||
?.sum()
|
||||
?: batchRecordsCount
|
||||
totalRecordsSynced += syncedInBatch
|
||||
|
||||
// Track synced counts from server
|
||||
// response
|
||||
body?.records_synced?.forEach {
|
||||
(key, count) ->
|
||||
syncedCounts[key] =
|
||||
(syncedCounts[key]
|
||||
?: 0) +
|
||||
count
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"[$syncTypeLabel] Batch synced successfully: $syncedInBatch records"
|
||||
)
|
||||
} else {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Batch sync failed: ${response.code()} ${response.message()}"
|
||||
)
|
||||
failedBatches++
|
||||
}
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Empty batch for $currentStartDate to $currentEndDate, skipping upload"
|
||||
)
|
||||
}
|
||||
|
||||
totalBatches++
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Error processing batch $currentStartDate",
|
||||
e
|
||||
)
|
||||
failedBatches++
|
||||
}
|
||||
|
||||
// Move to next batch
|
||||
currentStartDate = currentEndDate.plusDays(1)
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
legacyPrefs
|
||||
.edit()
|
||||
.putLong("last_raw_sync", System.currentTimeMillis())
|
||||
.putLong("last_sync", System.currentTimeMillis())
|
||||
.apply()
|
||||
|
||||
// Update metric counts tracker
|
||||
metricsCountTracker.updateAvailableCounts(availableCounts)
|
||||
if (syncedCounts.isNotEmpty()) {
|
||||
metricsCountTracker.updateSyncedCounts(syncedCounts)
|
||||
}
|
||||
|
||||
val totalDuration = System.currentTimeMillis() - overallStartTime
|
||||
Log.i(TAG, "========================================")
|
||||
Log.i(
|
||||
TAG,
|
||||
"[$syncTypeLabel] Sync finished. Total records: $totalRecordsSynced, Batches: $totalBatches, Failed: $failedBatches"
|
||||
)
|
||||
Log.i(TAG, "[$syncTypeLabel] Duration: ${totalDuration}ms")
|
||||
Log.i(TAG, "[$syncTypeLabel] Available counts: $availableCounts")
|
||||
Log.i(TAG, "[$syncTypeLabel] Synced counts: $syncedCounts")
|
||||
Log.i(TAG, "========================================")
|
||||
|
||||
// Record result
|
||||
val syncType =
|
||||
if (inputData.getBoolean("is_manual", false))
|
||||
SyncType.MANUAL
|
||||
else SyncType.SCHEDULED
|
||||
val status =
|
||||
if (failedBatches == totalBatches && totalBatches > 0)
|
||||
SyncStatus.FAILED
|
||||
else if (failedBatches > 0) SyncStatus.PARTIAL
|
||||
else SyncStatus.SUCCESS
|
||||
|
||||
// Only log to history if:
|
||||
// 1. It was a manual sync (user feedback required)
|
||||
// 2. OR Data was actually synced (avoid noise)
|
||||
// 3. OR The sync encountered errors (visibility required)
|
||||
if (syncType == SyncType.MANUAL ||
|
||||
totalRecordsSynced > 0 ||
|
||||
status != SyncStatus.SUCCESS
|
||||
) {
|
||||
historyManager.addSyncRecord(
|
||||
SyncRecord(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
status = status,
|
||||
metricsSynced = totalRecordsSynced,
|
||||
exercisesSynced = syncedCounts["exercise"]
|
||||
?: 0,
|
||||
syncDurationMs = totalDuration,
|
||||
syncType = syncType,
|
||||
errorMessage =
|
||||
if (failedBatches > 0)
|
||||
"$failedBatches/$totalBatches batches failed"
|
||||
else null,
|
||||
details = syncedCounts
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (status == SyncStatus.FAILED) {
|
||||
return@withContext Result.retry()
|
||||
} else {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Raw sync fatal error: ${e.message}", e)
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuple class for holding 13 values to support parallel reading of all metric types. Used for
|
||||
* destructuring assignment in coroutineScope parallel reads.
|
||||
*
|
||||
* Note: Kotlin data classes auto-generate component1() through component5() for the first 5
|
||||
* properties. We manually add component6() through component13() to support full destructuring.
|
||||
*/
|
||||
data class Tuple13<A, B, C, D, E, F, G, H, I, J, K, L, M>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
val third: C,
|
||||
val fourth: D,
|
||||
val fifth: E,
|
||||
val sixth: F,
|
||||
val seventh: G,
|
||||
val eighth: H,
|
||||
val ninth: I,
|
||||
val tenth: J,
|
||||
val eleventh: K,
|
||||
val twelfth: L,
|
||||
val thirteenth: M
|
||||
)
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.records.*
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.habitforge.companion.databinding.ActivityRawDataViewerBinding
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Raw Data Viewer Activity Displays raw Health Connect records for debugging and data inspection
|
||||
* Supports filtering by data source
|
||||
*/
|
||||
class RawDataViewerActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityRawDataViewerBinding
|
||||
private lateinit var adapter: RawDataAdapter
|
||||
private lateinit var healthConnectManager: HealthConnectManager
|
||||
private var healthConnectClient: HealthConnectClient? = null
|
||||
|
||||
// Source filtering
|
||||
private var availableSources: List<DataSourceInfo> = emptyList()
|
||||
private var selectedSourcePackage: String? = null // null = all sources
|
||||
|
||||
private val dateRangeOptions =
|
||||
listOf(
|
||||
DateRangeOption("Last 7 days", 7),
|
||||
DateRangeOption("Last 14 days", 14),
|
||||
DateRangeOption("Last 30 days", 30),
|
||||
DateRangeOption("Last 60 days", 60),
|
||||
DateRangeOption("Last 90 days", 90),
|
||||
DateRangeOption("Last 180 days", 180),
|
||||
DateRangeOption("Last 365 days", 365)
|
||||
)
|
||||
|
||||
private val dataTypes =
|
||||
listOf(
|
||||
DataTypeTab("Steps", "steps", "👣"),
|
||||
DataTypeTab("Heart Rate", "heart_rate", "❤️"),
|
||||
DataTypeTab("Distance", "distance", "📏"),
|
||||
DataTypeTab("Calories", "calories", "🔥"),
|
||||
DataTypeTab("Sleep", "sleep", "😴"),
|
||||
DataTypeTab("SpO2", "spo2", "💉"),
|
||||
DataTypeTab("Weight", "weight", "⚖️"),
|
||||
DataTypeTab("Resting HR", "resting_hr", "💗")
|
||||
)
|
||||
|
||||
private var selectedDays = 30
|
||||
private var selectedDataType = "steps"
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RawDataViewer"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityRawDataViewerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
healthConnectManager = HealthConnectManager(this)
|
||||
|
||||
// Initialize Health Connect client
|
||||
if (HealthConnectClient.getSdkStatus(this) == HealthConnectClient.SDK_AVAILABLE) {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(this)
|
||||
}
|
||||
|
||||
setupToolbar()
|
||||
setupTabs()
|
||||
setupDateRangeSpinner()
|
||||
setupRecyclerView()
|
||||
loadAvailableSources()
|
||||
|
||||
binding.btnLoadData.setOnClickListener { loadData() }
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
binding.toolbar.setNavigationOnClickListener { finish() }
|
||||
|
||||
// Add menu for source selection
|
||||
binding.toolbar.inflateMenu(R.menu.menu_raw_data_viewer)
|
||||
binding.toolbar.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_select_source -> {
|
||||
showSourceSelectionDialog()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTabs() {
|
||||
dataTypes.forEach { type ->
|
||||
val tab = binding.tabLayout.newTab()
|
||||
tab.text = "${type.icon} ${type.name}"
|
||||
tab.tag = type.id
|
||||
binding.tabLayout.addTab(tab)
|
||||
}
|
||||
|
||||
binding.tabLayout.addOnTabSelectedListener(
|
||||
object : TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab?) {
|
||||
selectedDataType = tab?.tag as? String ?: "steps"
|
||||
loadData()
|
||||
}
|
||||
override fun onTabUnselected(tab: TabLayout.Tab?) {}
|
||||
override fun onTabReselected(tab: TabLayout.Tab?) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupDateRangeSpinner() {
|
||||
val spinnerAdapter =
|
||||
ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_spinner_item,
|
||||
dateRangeOptions.map { it.label }
|
||||
)
|
||||
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.spinnerDateRange.adapter = spinnerAdapter
|
||||
binding.spinnerDateRange.setSelection(2) // Default to 30 days
|
||||
|
||||
binding.spinnerDateRange.onItemSelectedListener =
|
||||
object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(
|
||||
parent: AdapterView<*>?,
|
||||
view: View?,
|
||||
position: Int,
|
||||
id: Long
|
||||
) {
|
||||
selectedDays = dateRangeOptions[position].days
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
adapter = RawDataAdapter(healthConnectManager)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
private fun loadAvailableSources() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
availableSources = healthConnectManager.getAvailableDataSources()
|
||||
Log.d(TAG, "Found ${availableSources.size} data sources")
|
||||
|
||||
// Update toolbar subtitle with current source
|
||||
updateSourceIndicator()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading sources", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSourceIndicator() {
|
||||
val sourceText =
|
||||
if (selectedSourcePackage != null) {
|
||||
val source = availableSources.find { it.packageName == selectedSourcePackage }
|
||||
"Source: ${source?.displayName ?: "Unknown"}"
|
||||
} else {
|
||||
"Source: All"
|
||||
}
|
||||
binding.toolbar.subtitle = sourceText
|
||||
}
|
||||
|
||||
private fun showSourceSelectionDialog() {
|
||||
if (availableSources.isEmpty()) {
|
||||
Toast.makeText(this, "Loading data sources...", Toast.LENGTH_SHORT).show()
|
||||
loadAvailableSources()
|
||||
return
|
||||
}
|
||||
|
||||
val options = mutableListOf("All Sources (may have conflicts)")
|
||||
options.addAll(availableSources.map { it.displayName })
|
||||
|
||||
val currentSelection =
|
||||
if (selectedSourcePackage == null) 0
|
||||
else availableSources.indexOfFirst { it.packageName == selectedSourcePackage } + 1
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Select Data Source")
|
||||
.setSingleChoiceItems(options.toTypedArray(), currentSelection) { dialog, which ->
|
||||
selectedSourcePackage =
|
||||
if (which == 0) null else availableSources[which - 1].packageName
|
||||
updateSourceIndicator()
|
||||
dialog.dismiss()
|
||||
|
||||
// Reload data with new source filter
|
||||
loadData()
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
val client =
|
||||
healthConnectClient
|
||||
?: run {
|
||||
showEmpty("Health Connect not available")
|
||||
return
|
||||
}
|
||||
|
||||
val sourceLabel =
|
||||
if (selectedSourcePackage != null) {
|
||||
availableSources.find { it.packageName == selectedSourcePackage }?.displayName
|
||||
?: "filtered source"
|
||||
} else "all sources"
|
||||
|
||||
showLoading("Loading $selectedDataType from $sourceLabel for last $selectedDays days...")
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val endDate = LocalDate.now()
|
||||
val startDate = endDate.minusDays(selectedDays.toLong())
|
||||
|
||||
Log.d(TAG, "Date range: $startDate to $endDate")
|
||||
Log.d(TAG, "Source filter: ${selectedSourcePackage ?: "All"}")
|
||||
|
||||
val startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val endInstant =
|
||||
endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
|
||||
Log.d(TAG, "Instant range: $startInstant to $endInstant")
|
||||
|
||||
// Validate time range
|
||||
if (!startInstant.isBefore(endInstant)) {
|
||||
showEmpty("Invalid date range: start must be before end")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val timeRange = TimeRangeFilter.between(startInstant, endInstant)
|
||||
|
||||
val records =
|
||||
withContext(Dispatchers.IO) {
|
||||
readRawRecords(
|
||||
client,
|
||||
timeRange,
|
||||
selectedDataType,
|
||||
selectedSourcePackage
|
||||
)
|
||||
}
|
||||
|
||||
if (records.isEmpty()) {
|
||||
showEmpty("No $selectedDataType data found for this date range and source")
|
||||
} else {
|
||||
showData(records, startDate, endDate)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "IllegalArgumentException loading data: ${e.message}", e)
|
||||
showEmpty(
|
||||
"Time range error: ${e.message}\n\nThis may be caused by corrupted Health Connect data."
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading data: ${e::class.simpleName} - ${e.message}", e)
|
||||
showEmpty("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readRawRecords(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter,
|
||||
dataType: String,
|
||||
sourceFilter: String?
|
||||
): List<RawDataRecord> {
|
||||
val records = mutableListOf<RawDataRecord>()
|
||||
|
||||
when (dataType) {
|
||||
"steps" -> {
|
||||
try {
|
||||
val response = readAllPaginated<StepsRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} StepsRecord objects (before filtering)")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName ==
|
||||
sourceFilter
|
||||
if (!validTime) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Skipping invalid: startTime=${record.startTime}, endTime=${record.endTime}"
|
||||
)
|
||||
}
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
Log.d(TAG, "After filtering: ${filtered.size} records")
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "${record.count} steps",
|
||||
numericValue = record.count.toDouble(),
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "StepsRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading StepsRecord", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
"heart_rate" -> {
|
||||
val response = readAllPaginated<HeartRateRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} HeartRateRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
val avgBpm =
|
||||
if (record.samples.isNotEmpty()) {
|
||||
record.samples.map { it.beatsPerMinute }.average().toInt()
|
||||
} else 0
|
||||
val sampleCount = record.samples.size
|
||||
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "$avgBpm bpm avg ($sampleCount samples)",
|
||||
numericValue = avgBpm.toDouble(),
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "HeartRateRecord",
|
||||
details =
|
||||
"Samples: ${record.samples.take(5).map { it.beatsPerMinute }.joinToString(", ")}${if (sampleCount > 5) "..." else ""}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"distance" -> {
|
||||
val response = readAllPaginated<DistanceRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} DistanceRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "%.2f m".format(record.distance.inMeters),
|
||||
numericValue = record.distance.inMeters,
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "DistanceRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"calories" -> {
|
||||
val response = readAllPaginated<TotalCaloriesBurnedRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} TotalCaloriesBurnedRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "%.1f kcal".format(record.energy.inKilocalories),
|
||||
numericValue = record.energy.inKilocalories,
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "TotalCaloriesBurnedRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"sleep" -> {
|
||||
val response = readAllPaginated<SleepSessionRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} SleepSessionRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
val durationMinutes =
|
||||
java.time.temporal.ChronoUnit.MINUTES.between(
|
||||
record.startTime,
|
||||
record.endTime
|
||||
)
|
||||
val hours = durationMinutes / 60
|
||||
val mins = durationMinutes % 60
|
||||
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "${hours}h ${mins}m",
|
||||
numericValue = durationMinutes.toDouble(),
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "SleepSessionRecord",
|
||||
details = "Stages: ${record.stages.size}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"spo2" -> {
|
||||
val response = readAllPaginated<OxygenSaturationRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} OxygenSaturationRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.time,
|
||||
endTime = null,
|
||||
value = "%.1f%%".format(record.percentage.value),
|
||||
numericValue = record.percentage.value,
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "OxygenSaturationRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"weight" -> {
|
||||
val response = readAllPaginated<WeightRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} WeightRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.time,
|
||||
endTime = null,
|
||||
value = "%.2f kg".format(record.weight.inKilograms),
|
||||
numericValue = record.weight.inKilograms,
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "WeightRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"resting_hr" -> {
|
||||
val response = readAllPaginated<RestingHeartRateRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} RestingHeartRateRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.time,
|
||||
endTime = null,
|
||||
value = "${record.beatsPerMinute} bpm",
|
||||
numericValue = record.beatsPerMinute.toDouble(),
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "RestingHeartRateRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
return records.sortedByDescending { it.timestamp }
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Record> readAllPaginated(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var pageToken: String? = null
|
||||
var pageCount = 0
|
||||
|
||||
try {
|
||||
do {
|
||||
pageCount++
|
||||
val request =
|
||||
if (pageToken != null) {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange,
|
||||
pageToken = pageToken
|
||||
)
|
||||
} else {
|
||||
ReadRecordsRequest(recordType = T::class, timeRangeFilter = timeRange)
|
||||
}
|
||||
|
||||
val response = client.readRecords(request)
|
||||
allRecords.addAll(response.records)
|
||||
pageToken = response.pageToken
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Page $pageCount: ${response.records.size} ${T::class.simpleName} records, total: ${allRecords.size}, hasMore: ${pageToken != null}"
|
||||
)
|
||||
} while (pageToken != null)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// This can happen if Health Connect returns invalid time ranges
|
||||
Log.e(TAG, "IllegalArgumentException reading ${T::class.simpleName}: ${e.message}", e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading ${T::class.simpleName}: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private fun showLoading(message: String) {
|
||||
binding.layoutLoading.visibility = View.VISIBLE
|
||||
binding.tvLoadingMessage.text = message
|
||||
binding.recyclerView.visibility = View.GONE
|
||||
binding.layoutEmpty.visibility = View.GONE
|
||||
binding.layoutStats.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showEmpty(message: String) {
|
||||
binding.layoutLoading.visibility = View.GONE
|
||||
binding.recyclerView.visibility = View.GONE
|
||||
binding.layoutEmpty.visibility = View.VISIBLE
|
||||
binding.tvEmptyMessage.text = message
|
||||
binding.layoutStats.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showData(records: List<RawDataRecord>, startDate: LocalDate, endDate: LocalDate) {
|
||||
binding.layoutLoading.visibility = View.GONE
|
||||
binding.layoutEmpty.visibility = View.GONE
|
||||
binding.recyclerView.visibility = View.VISIBLE
|
||||
binding.layoutStats.visibility = View.VISIBLE
|
||||
|
||||
binding.tvTotalRecords.text = "Records: ${records.size}"
|
||||
binding.tvDateRange.text = "$startDate → $endDate"
|
||||
|
||||
adapter.submitList(records)
|
||||
}
|
||||
}
|
||||
|
||||
// Data classes
|
||||
data class DateRangeOption(val label: String, val days: Int)
|
||||
|
||||
data class DataTypeTab(val name: String, val id: String, val icon: String)
|
||||
|
||||
data class RawDataRecord(
|
||||
val timestamp: Instant,
|
||||
val endTime: Instant?,
|
||||
val value: String,
|
||||
val numericValue: Double,
|
||||
val dataSource: String,
|
||||
val recordType: String,
|
||||
val details: String,
|
||||
val isSynced: Boolean = false,
|
||||
val syncTimestamp: Instant? = null
|
||||
)
|
||||
|
||||
// RecyclerView Adapter
|
||||
class RawDataAdapter(private val healthConnectManager: HealthConnectManager) :
|
||||
RecyclerView.Adapter<RawDataAdapter.ViewHolder>() {
|
||||
|
||||
private var items: List<RawDataRecord> = emptyList()
|
||||
|
||||
private val dateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm:ss").withZone(ZoneId.systemDefault())
|
||||
|
||||
private val timeFormatter =
|
||||
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault())
|
||||
|
||||
fun submitList(newItems: List<RawDataRecord>) {
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view =
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_raw_data_record, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val tvDateTime: TextView = itemView.findViewById(R.id.tvRecordDateTime)
|
||||
private val tvValue: TextView = itemView.findViewById(R.id.tvRecordValue)
|
||||
private val tvDataSource: TextView = itemView.findViewById(R.id.tvDataSource)
|
||||
private val tvDetails: TextView = itemView.findViewById(R.id.tvRecordDetails)
|
||||
private val tvDuration: TextView = itemView.findViewById(R.id.tvRecordDuration)
|
||||
private val tvSyncInfo: TextView = itemView.findViewById(R.id.tvSyncInfo)
|
||||
private val viewSyncStatus: View = itemView.findViewById(R.id.viewSyncStatus)
|
||||
|
||||
fun bind(record: RawDataRecord) {
|
||||
// Date/Time
|
||||
tvDateTime.text = dateTimeFormatter.format(record.timestamp)
|
||||
|
||||
// Value
|
||||
tvValue.text = record.value
|
||||
|
||||
// Data Source (use friendly name)
|
||||
tvDataSource.text = healthConnectManager.getSourceDisplayName(record.dataSource)
|
||||
|
||||
// Duration (if applicable)
|
||||
if (record.endTime != null) {
|
||||
val durationSeconds =
|
||||
java.time.Duration.between(record.timestamp, record.endTime).seconds
|
||||
val hours = durationSeconds / 3600
|
||||
val minutes = (durationSeconds % 3600) / 60
|
||||
val secs = durationSeconds % 60
|
||||
|
||||
tvDuration.visibility = View.VISIBLE
|
||||
tvDuration.text =
|
||||
when {
|
||||
hours > 0 -> "Duration: ${hours}h ${minutes}m"
|
||||
minutes > 0 -> "Duration: ${minutes}m ${secs}s"
|
||||
else -> "Duration: ${secs}s"
|
||||
}
|
||||
|
||||
// Show time range in details
|
||||
tvDetails.visibility = View.VISIBLE
|
||||
tvDetails.text =
|
||||
"Start: ${timeFormatter.format(record.timestamp)} → End: ${timeFormatter.format(record.endTime)}"
|
||||
} else {
|
||||
tvDuration.visibility = View.GONE
|
||||
tvDetails.visibility = if (record.details.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
tvDetails.text = record.details
|
||||
}
|
||||
|
||||
// Sync status
|
||||
if (record.isSynced && record.syncTimestamp != null) {
|
||||
viewSyncStatus.setBackgroundResource(R.drawable.status_dot_green)
|
||||
tvSyncInfo.visibility = View.VISIBLE
|
||||
tvSyncInfo.text = "✓ Synced: ${dateTimeFormatter.format(record.syncTimestamp)}"
|
||||
tvSyncInfo.setTextColor(Color.parseColor("#4CAF50"))
|
||||
} else {
|
||||
viewSyncStatus.setBackgroundResource(R.drawable.status_dot_gray)
|
||||
tvSyncInfo.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Manages server connection tests and availability checks */
|
||||
class ServerConnectionManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ServerConnectionManager"
|
||||
private const val CONNECTION_TIMEOUT_MS = 5000
|
||||
private const val READ_TIMEOUT_MS = 5000
|
||||
}
|
||||
|
||||
/** Test connection to the server Returns a detailed result with status and response time */
|
||||
suspend fun testConnection(): ConnectionTestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Try reading from AuthManager's prefs first (source of truth)
|
||||
val authPrefs =
|
||||
context.getSharedPreferences("habitforge_auth", Context.MODE_PRIVATE)
|
||||
var serverUrl = authPrefs.getString("server_url", null)
|
||||
var authToken = authPrefs.getString("access_token", null)
|
||||
|
||||
// Fallback to legacy prefs if not found
|
||||
if (serverUrl == null) {
|
||||
val legacyPrefs =
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
serverUrl = legacyPrefs.getString("server_url", BuildConfig.BACKEND_URL)
|
||||
authToken = authToken ?: legacyPrefs.getString("auth_token", null)
|
||||
}
|
||||
|
||||
serverUrl = serverUrl ?: BuildConfig.BACKEND_URL
|
||||
|
||||
Log.d(TAG, "Testing connection to: $serverUrl")
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
// First test basic connectivity
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
|
||||
val healthUrl = URL("$baseUrl/api/health")
|
||||
val connection = healthUrl.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = CONNECTION_TIMEOUT_MS
|
||||
connection.readTimeout = READ_TIMEOUT_MS
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
connection.disconnect()
|
||||
|
||||
when (responseCode) {
|
||||
200 -> {
|
||||
// Now test authentication if token is provided
|
||||
if (!authToken.isNullOrEmpty()) {
|
||||
val authResult = testAuthentication(serverUrl, authToken)
|
||||
if (authResult.authenticated) {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.ONLINE,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server connected & authenticated",
|
||||
authenticated = true
|
||||
)
|
||||
} else {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.AUTH_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message =
|
||||
"Server reachable but authentication failed: ${authResult.message}",
|
||||
authenticated = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.ONLINE,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server reachable (no auth token configured)",
|
||||
authenticated = false
|
||||
)
|
||||
}
|
||||
}
|
||||
401, 403 ->
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.AUTH_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Authentication required (HTTP $responseCode)"
|
||||
)
|
||||
in 500..599 ->
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.SERVER_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server error (HTTP $responseCode)"
|
||||
)
|
||||
else ->
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNKNOWN_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Unexpected response (HTTP $responseCode)"
|
||||
)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Log.e(TAG, "Connection timeout", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.TIMEOUT,
|
||||
responseTimeMs = CONNECTION_TIMEOUT_MS.toLong(),
|
||||
message = "Connection timeout after ${CONNECTION_TIMEOUT_MS}ms"
|
||||
)
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
Log.e(TAG, "Unknown host", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNREACHABLE,
|
||||
responseTimeMs = 0,
|
||||
message = "Server not found (check URL)"
|
||||
)
|
||||
} catch (e: java.net.ConnectException) {
|
||||
Log.e(TAG, "Connection refused", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNREACHABLE,
|
||||
responseTimeMs = 0,
|
||||
message = "Connection refused (server may be down)"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection test failed", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNKNOWN_ERROR,
|
||||
responseTimeMs = 0,
|
||||
message = "Error: ${e.localizedMessage}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if server is currently reachable (quick check) */
|
||||
suspend fun isServerReachable(): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Try reading from AuthManager's prefs first
|
||||
val authPrefs =
|
||||
context.getSharedPreferences("habitforge_auth", Context.MODE_PRIVATE)
|
||||
var serverUrl = authPrefs.getString("server_url", null)
|
||||
|
||||
// Fallback to legacy prefs
|
||||
if (serverUrl == null) {
|
||||
val legacyPrefs =
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
serverUrl = legacyPrefs.getString("server_url", BuildConfig.BACKEND_URL)
|
||||
}
|
||||
|
||||
serverUrl = serverUrl ?: BuildConfig.BACKEND_URL
|
||||
|
||||
try {
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
|
||||
val url = URL("$baseUrl/api/health")
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "HEAD"
|
||||
connection.connectTimeout = 3000
|
||||
connection.readTimeout = 3000
|
||||
|
||||
val reachable = connection.responseCode == 200
|
||||
connection.disconnect()
|
||||
reachable
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Test authentication with the server */
|
||||
private suspend fun testAuthentication(serverUrl: String, token: String): AuthTestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl else "$serverUrl/"
|
||||
val api = HabitForgeApi.create(baseUrl)
|
||||
val response = api.getCurrentUser("Bearer $token")
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val user = response.body()!!
|
||||
AuthTestResult(
|
||||
authenticated = true,
|
||||
message = "Logged in as ${user.username}"
|
||||
)
|
||||
} else {
|
||||
AuthTestResult(authenticated = false, message = "HTTP ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AuthTestResult(
|
||||
authenticated = false,
|
||||
message = e.localizedMessage ?: "Unknown error"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Server status enumeration */
|
||||
enum class ServerStatus {
|
||||
ONLINE,
|
||||
OFFLINE,
|
||||
UNREACHABLE,
|
||||
TIMEOUT,
|
||||
AUTH_ERROR,
|
||||
SERVER_ERROR,
|
||||
UNKNOWN_ERROR
|
||||
}
|
||||
|
||||
/** Result of a connection test */
|
||||
data class ConnectionTestResult(
|
||||
val status: ServerStatus,
|
||||
val responseTimeMs: Long = 0,
|
||||
val message: String = "",
|
||||
val authenticated: Boolean = false
|
||||
) {
|
||||
fun getStatusIcon(): String {
|
||||
return when (status) {
|
||||
ServerStatus.ONLINE -> if (authenticated) "✓" else "⚠"
|
||||
ServerStatus.OFFLINE -> "○"
|
||||
ServerStatus.UNREACHABLE -> "✗"
|
||||
ServerStatus.TIMEOUT -> "⏱"
|
||||
ServerStatus.AUTH_ERROR -> "🔒"
|
||||
ServerStatus.SERVER_ERROR -> "⚠"
|
||||
ServerStatus.UNKNOWN_ERROR -> "?"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusColor(): Int {
|
||||
return when (status) {
|
||||
ServerStatus.ONLINE ->
|
||||
if (authenticated) android.R.color.holo_green_dark
|
||||
else android.R.color.holo_orange_dark
|
||||
ServerStatus.OFFLINE -> android.R.color.darker_gray
|
||||
ServerStatus.UNREACHABLE -> android.R.color.holo_red_dark
|
||||
ServerStatus.TIMEOUT -> android.R.color.holo_orange_dark
|
||||
ServerStatus.AUTH_ERROR -> android.R.color.holo_orange_dark
|
||||
ServerStatus.SERVER_ERROR -> android.R.color.holo_red_dark
|
||||
ServerStatus.UNKNOWN_ERROR -> android.R.color.holo_orange_dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of authentication test */
|
||||
data class AuthTestResult(val authenticated: Boolean, val message: String)
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
||||
class SyncConfigActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var etSyncDays: TextInputEditText
|
||||
private lateinit var tvCurrentConfig: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_sync_config)
|
||||
|
||||
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { finish() }
|
||||
|
||||
etSyncDays = findViewById(R.id.etSyncDays)
|
||||
tvCurrentConfig = findViewById(R.id.tvCurrentConfig)
|
||||
val btnSave = findViewById<Button>(R.id.btnSave)
|
||||
|
||||
loadConfig()
|
||||
|
||||
btnSave.setOnClickListener { saveConfig() }
|
||||
}
|
||||
|
||||
private fun loadConfig() {
|
||||
val prefs = getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
val syncDays = prefs.getInt("sync_days", 7) // Default to 7
|
||||
|
||||
etSyncDays.setText(syncDays.toString())
|
||||
tvCurrentConfig.text = "Currently syncing: $syncDays days"
|
||||
}
|
||||
|
||||
private fun saveConfig() {
|
||||
val input = etSyncDays.text.toString()
|
||||
val days = input.toIntOrNull()
|
||||
|
||||
if (days == null || days <= 0 || days > 365) {
|
||||
Toast.makeText(this, "Please enter a valid number of days (1-365)", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
prefs.edit().putInt("sync_days", days).apply()
|
||||
|
||||
tvCurrentConfig.text = "Currently syncing: $days days"
|
||||
Toast.makeText(this, "Configuration saved", Toast.LENGTH_SHORT).show()
|
||||
|
||||
// Optionally trigger a new sync schedule if needed, but the service reads prefs on run.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/** Manages sync history for display in the UI */
|
||||
class SyncHistoryManager(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("sync_history", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val KEY_HISTORY = "sync_history_list"
|
||||
private const val MAX_HISTORY_ITEMS = 50
|
||||
}
|
||||
|
||||
/** Add a new sync record to history */
|
||||
fun addSyncRecord(record: SyncRecord) {
|
||||
val history = getHistory().toMutableList()
|
||||
history.add(0, record) // Add to beginning
|
||||
|
||||
// Keep only last MAX_HISTORY_ITEMS
|
||||
while (history.size > MAX_HISTORY_ITEMS) {
|
||||
history.removeAt(history.size - 1)
|
||||
}
|
||||
|
||||
saveHistory(history)
|
||||
}
|
||||
|
||||
/** Get all sync history records */
|
||||
fun getHistory(): List<SyncRecord> {
|
||||
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
|
||||
return try {
|
||||
val type = object : TypeToken<List<SyncRecord>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Get recent sync records (last N items) */
|
||||
fun getRecentHistory(count: Int = 10): List<SyncRecord> {
|
||||
return getHistory().take(count)
|
||||
}
|
||||
|
||||
/** Clear all sync history */
|
||||
fun clearHistory() {
|
||||
prefs.edit().remove(KEY_HISTORY).apply()
|
||||
}
|
||||
|
||||
/** Get sync statistics */
|
||||
fun getStats(): SyncStats {
|
||||
val history = getHistory()
|
||||
val successful = history.count { it.status == SyncStatus.SUCCESS }
|
||||
val failed = history.count { it.status == SyncStatus.FAILED }
|
||||
val totalMetrics =
|
||||
history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.metricsSynced }
|
||||
val totalExercises =
|
||||
history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.exercisesSynced }
|
||||
|
||||
return SyncStats(
|
||||
totalSyncs = history.size,
|
||||
successfulSyncs = successful,
|
||||
failedSyncs = failed,
|
||||
totalMetricsSynced = totalMetrics,
|
||||
totalExercisesSynced = totalExercises,
|
||||
lastSuccessfulSync =
|
||||
history.firstOrNull { it.status == SyncStatus.SUCCESS }?.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveHistory(history: List<SyncRecord>) {
|
||||
val json = gson.toJson(history)
|
||||
prefs.edit().putString(KEY_HISTORY, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents a single sync operation record */
|
||||
data class SyncRecord(
|
||||
val timestamp: Long,
|
||||
val status: SyncStatus,
|
||||
val metricsSynced: Int = 0,
|
||||
val exercisesSynced: Int = 0,
|
||||
val errorMessage: String? = null,
|
||||
val syncDurationMs: Long = 0,
|
||||
val syncType: SyncType? = SyncType.MANUAL,
|
||||
val details: Map<String, Int> = emptyMap()
|
||||
) {
|
||||
fun getSyncTypeOrDefault(): SyncType {
|
||||
return syncType ?: SyncType.MANUAL
|
||||
}
|
||||
|
||||
fun getFormattedTimestamp(): String {
|
||||
val dateFormat = SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault())
|
||||
return dateFormat.format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun getStatusIcon(): String {
|
||||
return when (status) {
|
||||
SyncStatus.SUCCESS -> "✓"
|
||||
SyncStatus.FAILED -> "✗"
|
||||
SyncStatus.PARTIAL -> "⚠"
|
||||
SyncStatus.IN_PROGRESS -> "⟳"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusColor(): Int {
|
||||
return when (status) {
|
||||
SyncStatus.SUCCESS -> android.R.color.holo_green_dark
|
||||
SyncStatus.FAILED -> android.R.color.holo_red_dark
|
||||
SyncStatus.PARTIAL -> android.R.color.holo_orange_dark
|
||||
SyncStatus.IN_PROGRESS -> android.R.color.holo_blue_dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync status enumeration */
|
||||
enum class SyncStatus {
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
PARTIAL,
|
||||
IN_PROGRESS
|
||||
}
|
||||
|
||||
/** Sync type enumeration */
|
||||
enum class SyncType {
|
||||
MANUAL,
|
||||
SCHEDULED
|
||||
}
|
||||
|
||||
/** Aggregated sync statistics */
|
||||
data class SyncStats(
|
||||
val totalSyncs: Int,
|
||||
val successfulSyncs: Int,
|
||||
val failedSyncs: Int,
|
||||
val totalMetricsSynced: Int,
|
||||
val totalExercisesSynced: Int,
|
||||
val lastSuccessfulSync: Long?
|
||||
) {
|
||||
fun getSuccessRate(): Float {
|
||||
return if (totalSyncs > 0) (successfulSyncs.toFloat() / totalSyncs) * 100 else 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Sync Service - Handles reading from Health Connect and pushing to backend Uses WorkManager for
|
||||
* reliable background syncing
|
||||
*/
|
||||
class SyncService(context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncService"
|
||||
private const val WORK_NAME = "health_connect_sync"
|
||||
private const val SYNC_DAYS =
|
||||
365 // Sync last year of data to ensure history is complete
|
||||
|
||||
// Sync period options in minutes
|
||||
const val SYNC_PERIOD_5_MINS = 5
|
||||
const val SYNC_PERIOD_10_MINS = 10
|
||||
const val SYNC_PERIOD_15_MINS = 15
|
||||
const val SYNC_PERIOD_30_MINS = 30
|
||||
const val SYNC_PERIOD_45_MINS = 45
|
||||
const val SYNC_PERIOD_1_HOUR = 60
|
||||
const val SYNC_PERIOD_3_HOURS = 180
|
||||
const val SYNC_PERIOD_6_HOURS = 360
|
||||
const val SYNC_PERIOD_12_HOURS = 720
|
||||
const val SYNC_PERIOD_24_HOURS = 1440
|
||||
|
||||
// Preference keys
|
||||
const val PREF_SYNC_PERIOD_MINUTES = "sync_period_minutes"
|
||||
|
||||
/** Schedule periodic sync with configurable interval */
|
||||
fun schedulePeriodicSync(
|
||||
context: Context,
|
||||
periodMinutes: Int = getSyncPeriod(context)
|
||||
) {
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
|
||||
// Note: WorkManager enforces a minimum interval of 15 minutes
|
||||
val syncWork =
|
||||
PeriodicWorkRequestBuilder<SyncService>(
|
||||
periodMinutes.toLong(),
|
||||
TimeUnit.MINUTES,
|
||||
5,
|
||||
TimeUnit.MINUTES // Flex interval
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy
|
||||
.UPDATE, // UPDATE to apply new period
|
||||
syncWork
|
||||
)
|
||||
|
||||
Log.i(TAG, "Periodic sync scheduled every $periodMinutes minutes")
|
||||
|
||||
// Save the period setting
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putInt(PREF_SYNC_PERIOD_MINUTES, periodMinutes)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Trigger an immediate one-time sync */
|
||||
fun syncNow(context: Context, days: Int = SYNC_DAYS) {
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val syncWork =
|
||||
OneTimeWorkRequestBuilder<SyncService>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(
|
||||
workDataOf("days" to days, "is_manual" to true)
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(syncWork)
|
||||
Log.i(TAG, "Immediate sync triggered for $days days")
|
||||
}
|
||||
|
||||
/** Cancel all scheduled syncs */
|
||||
fun cancelSync(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.i(TAG, "Sync cancelled")
|
||||
}
|
||||
|
||||
/** Get the current sync period setting */
|
||||
fun getSyncPeriod(context: Context): Int {
|
||||
return context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.getInt(PREF_SYNC_PERIOD_MINUTES, SYNC_PERIOD_6_HOURS)
|
||||
}
|
||||
|
||||
/** Get available sync period options */
|
||||
fun getSyncPeriodOptions(): List<SyncPeriodOption> {
|
||||
return listOf(
|
||||
SyncPeriodOption(
|
||||
SYNC_PERIOD_5_MINS,
|
||||
"Every 5 minutes (clamped to 15)"
|
||||
),
|
||||
SyncPeriodOption(
|
||||
SYNC_PERIOD_10_MINS,
|
||||
"Every 10 minutes (clamped to 15)"
|
||||
),
|
||||
SyncPeriodOption(SYNC_PERIOD_15_MINS, "Every 15 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_30_MINS, "Every 30 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_45_MINS, "Every 45 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_1_HOUR, "Every hour"),
|
||||
SyncPeriodOption(SYNC_PERIOD_3_HOURS, "Every 3 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_6_HOURS, "Every 6 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_12_HOURS, "Every 12 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_24_HOURS, "Once a day")
|
||||
)
|
||||
}
|
||||
|
||||
/** Get formatted period string */
|
||||
fun getFormattedPeriod(context: Context): String {
|
||||
val minutes = getSyncPeriod(context)
|
||||
return getSyncPeriodOptions().find { it.minutes == minutes }?.label
|
||||
?: "Every $minutes minutes"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result =
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.i(TAG, "Starting Health Connect sync...")
|
||||
val startTime = System.currentTimeMillis()
|
||||
val syncHistoryManager = SyncHistoryManager(applicationContext)
|
||||
|
||||
try {
|
||||
val healthConnectManager =
|
||||
HabitForgeApp.instance.healthConnectManager
|
||||
|
||||
// Check availability
|
||||
if (healthConnectManager.isAvailable() !=
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
) {
|
||||
Log.e(TAG, "Health Connect not available")
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
"Health Connect not available",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (!healthConnectManager.hasAllPermissions()) {
|
||||
Log.e(TAG, "Missing Health Connect permissions")
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
"Missing permissions",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
// Get auth token from preferences
|
||||
// Get auth token from preferences (Prioritize AuthManager's prefs)
|
||||
val authPrefs =
|
||||
applicationContext.getSharedPreferences(
|
||||
"habitforge_auth",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
var authToken = authPrefs.getString("access_token", null)
|
||||
|
||||
val legacyPrefs =
|
||||
applicationContext.getSharedPreferences(
|
||||
"habitforge",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
if (authToken == null) {
|
||||
authToken = legacyPrefs.getString("auth_token", null)
|
||||
}
|
||||
|
||||
if (authToken.isNullOrEmpty()) {
|
||||
Log.e(TAG, "No auth token configured")
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
"No auth token",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// Determine sync range
|
||||
val days = inputData.getInt("days", SYNC_DAYS)
|
||||
val endDate = LocalDate.now()
|
||||
val startDate = endDate.minusDays(days.toLong())
|
||||
|
||||
Log.i(TAG, "Reading data from $startDate to $endDate ($days days)")
|
||||
|
||||
// Read daily metrics
|
||||
val dailyMetrics =
|
||||
healthConnectManager.readDailyMetrics(startDate, endDate)
|
||||
Log.i(TAG, "Read ${dailyMetrics.size} days of metrics")
|
||||
|
||||
// Log sample data for debugging
|
||||
if (dailyMetrics.isNotEmpty()) {
|
||||
val sample = dailyMetrics.first()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Sample metric: date=${sample.date}, steps=${sample.steps}, calories=${sample.caloriesBurned}"
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "No daily metrics returned from Health Connect!")
|
||||
}
|
||||
|
||||
// Read exercise sessions
|
||||
val exercises =
|
||||
healthConnectManager.readExerciseSessions(
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
Log.i(TAG, "Read ${exercises.size} exercise sessions")
|
||||
|
||||
if (exercises.isNotEmpty()) {
|
||||
val sample = exercises.first()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Sample exercise: type=${sample.exerciseTypeName}, duration=${sample.durationMinutes}min"
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to API payload
|
||||
val metricsPayload =
|
||||
dailyMetrics.map { metric ->
|
||||
DailyMetricsPayload(
|
||||
date = metric.date,
|
||||
step_count = metric.steps.toInt(),
|
||||
distance_meters = metric.distanceMeters,
|
||||
calories_burned = metric.caloriesBurned,
|
||||
total_calories = metric.caloriesBurned,
|
||||
avg_heart_rate = metric.avgHeartRate,
|
||||
min_heart_rate = metric.minHeartRate,
|
||||
max_heart_rate = metric.maxHeartRate,
|
||||
resting_heart_rate =
|
||||
metric.restingHeartRate,
|
||||
sleep_duration_minutes =
|
||||
metric.sleepDurationMinutes,
|
||||
deep_sleep_minutes =
|
||||
metric.deepSleepMinutes,
|
||||
light_sleep_minutes =
|
||||
metric.lightSleepMinutes,
|
||||
rem_sleep_minutes = metric.remSleepMinutes,
|
||||
awake_duration_minutes =
|
||||
metric.awakeMinutes,
|
||||
weight = metric.weight,
|
||||
avg_spo2 = metric.spo2,
|
||||
min_spo2 = metric.spo2,
|
||||
max_spo2 = metric.spo2,
|
||||
data_source = metric.dataSource,
|
||||
data_source_name =
|
||||
healthConnectManager
|
||||
.getSourceDisplayName(
|
||||
metric.dataSource
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val exercisesPayload =
|
||||
exercises.map { exercise ->
|
||||
ExerciseSessionPayload(
|
||||
start_time = exercise.startTime,
|
||||
end_time = exercise.endTime,
|
||||
exercise_type = exercise.exerciseType,
|
||||
exercise_type_name =
|
||||
exercise.exerciseTypeName,
|
||||
duration_seconds =
|
||||
exercise.durationMinutes * 60,
|
||||
title = exercise.title,
|
||||
notes = exercise.notes
|
||||
)
|
||||
}
|
||||
|
||||
// Get device ID
|
||||
val deviceId =
|
||||
Settings.Secure.getString(
|
||||
applicationContext.contentResolver,
|
||||
Settings.Secure.ANDROID_ID
|
||||
)
|
||||
|
||||
// Build request
|
||||
val request =
|
||||
HealthConnectSyncRequest(
|
||||
device_id = deviceId,
|
||||
sync_timestamp = Instant.now().toString(),
|
||||
daily_metrics = metricsPayload,
|
||||
exercise_sessions = exercisesPayload
|
||||
)
|
||||
|
||||
// Log request details for debugging
|
||||
Log.i(TAG, "=== Preparing Sync Request ===")
|
||||
Log.i(
|
||||
TAG,
|
||||
"Payload: ${metricsPayload.size} metrics, ${exercisesPayload.size} exercises"
|
||||
)
|
||||
if (metricsPayload.isNotEmpty()) {
|
||||
val sample = metricsPayload.first()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Metric payload sample: date=${sample.date}, steps=${sample.step_count}, calories=${sample.calories_burned}"
|
||||
)
|
||||
}
|
||||
if (exercisesPayload.isNotEmpty()) {
|
||||
val sample = exercisesPayload.first()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Exercise payload sample: type=${sample.exercise_type_name}, duration=${sample.duration_seconds}s"
|
||||
)
|
||||
}
|
||||
|
||||
// Send to backend
|
||||
// Send to backend
|
||||
var backendUrl = authPrefs.getString("server_url", null)
|
||||
if (backendUrl == null) {
|
||||
backendUrl =
|
||||
legacyPrefs.getString(
|
||||
"server_url",
|
||||
BuildConfig.BACKEND_URL
|
||||
)
|
||||
}
|
||||
backendUrl = backendUrl ?: BuildConfig.BACKEND_URL
|
||||
Log.i(TAG, "Sending to: $backendUrl/api/health-connect/sync")
|
||||
val api = HabitForgeApi.create(backendUrl)
|
||||
|
||||
val response = api.syncHealthData("Bearer $authToken", request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
Log.i(TAG, "=== Sync Response ===")
|
||||
Log.i(TAG, "Status: ${body?.status}")
|
||||
Log.i(TAG, "Sync ID: ${body?.sync_id}")
|
||||
Log.i(TAG, "Metrics synced: ${body?.metrics_synced}")
|
||||
Log.i(TAG, "Exercises synced: ${body?.exercises_synced}")
|
||||
Log.i(TAG, "Message: ${body?.message}")
|
||||
|
||||
// Save last sync time
|
||||
legacyPrefs
|
||||
.edit()
|
||||
.putLong("last_sync", System.currentTimeMillis())
|
||||
.apply()
|
||||
|
||||
// Update metrics sync tracker with best values from last
|
||||
// few days
|
||||
if (dailyMetrics.isNotEmpty()) {
|
||||
val sorted =
|
||||
dailyMetrics.sortedByDescending { it.date }
|
||||
val stepsMetric =
|
||||
sorted.firstOrNull { it.steps > 0 }
|
||||
?: sorted.first()
|
||||
val bestDistance =
|
||||
sorted
|
||||
.firstOrNull {
|
||||
it.distanceMeters > 0
|
||||
}
|
||||
?.distanceMeters
|
||||
?: 0.0
|
||||
val bestCalories =
|
||||
sorted
|
||||
.firstOrNull {
|
||||
it.caloriesBurned > 0
|
||||
}
|
||||
?.caloriesBurned
|
||||
?: 0.0
|
||||
|
||||
val metricsTracker =
|
||||
MetricsSyncTracker(applicationContext)
|
||||
metricsTracker.updateFromSyncData(
|
||||
steps = stepsMetric.steps,
|
||||
distance = bestDistance,
|
||||
calories = bestCalories,
|
||||
avgHeartRate =
|
||||
sorted
|
||||
.mapNotNull {
|
||||
it.avgHeartRate
|
||||
}
|
||||
.filter { it > 0 }
|
||||
.firstOrNull(),
|
||||
restingHeartRate =
|
||||
sorted
|
||||
.mapNotNull {
|
||||
it.restingHeartRate
|
||||
}
|
||||
.filter { it > 0 }
|
||||
.firstOrNull(),
|
||||
sleepMinutes =
|
||||
sorted
|
||||
.filter {
|
||||
it.sleepDurationMinutes >
|
||||
0
|
||||
}
|
||||
.firstOrNull()
|
||||
?.sleepDurationMinutes,
|
||||
weight =
|
||||
sorted
|
||||
.mapNotNull { it.weight }
|
||||
.filter { it > 0 }
|
||||
.firstOrNull(),
|
||||
spo2 =
|
||||
sorted
|
||||
.mapNotNull { it.spo2 }
|
||||
.filter { it > 0 }
|
||||
.firstOrNull(),
|
||||
exerciseCount = exercises.size
|
||||
)
|
||||
}
|
||||
|
||||
// Record successful sync in history
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
val syncType =
|
||||
if (inputData.getBoolean("is_manual", false))
|
||||
SyncType.MANUAL
|
||||
else SyncType.SCHEDULED
|
||||
|
||||
syncHistoryManager.addSyncRecord(
|
||||
SyncRecord(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
status = SyncStatus.SUCCESS,
|
||||
metricsSynced = body?.metrics_synced
|
||||
?: dailyMetrics.size,
|
||||
exercisesSynced = body?.exercises_synced
|
||||
?: exercises.size,
|
||||
syncDurationMs = duration,
|
||||
syncType = syncType
|
||||
)
|
||||
)
|
||||
|
||||
return@withContext Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Sync failed with code: ${response.code()}")
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
"Server returned ${response.code()}",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Sync error", e)
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
e.localizedMessage ?: "Unknown error",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordSyncFailure(
|
||||
manager: SyncHistoryManager,
|
||||
startTime: Long,
|
||||
errorMessage: String,
|
||||
isManual: Boolean = false
|
||||
) {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
val syncType = if (isManual) SyncType.MANUAL else SyncType.SCHEDULED
|
||||
|
||||
manager.addSyncRecord(
|
||||
SyncRecord(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
status = SyncStatus.FAILED,
|
||||
errorMessage = errorMessage,
|
||||
syncDurationMs = duration,
|
||||
syncType = syncType
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync period option for UI selection */
|
||||
data class SyncPeriodOption(val minutes: Int, val label: String)
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
|
||||
<!-- Filter/source icon -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M4.25,5.61C6.27,8.2 10,13 10,13v6c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1v-6c0,0 3.72,-4.8 5.74,-7.39C20.25,4.95 19.78,4 18.95,4H5.04C4.21,4 3.74,4.95 4.25,5.61z"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M7,13l2,2l6,-6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Heart shape -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,78.35l-2.18,-1.98C41.1,67.04 35,61.42 35,54.25c0,-5.82 4.64,-10.5 10.5,-10.5c3.26,0 6.39,1.52 8.5,3.93c2.11,-2.41 5.24,-3.93 8.5,-3.93c5.86,0 10.5,4.68 10.5,10.5c0,7.17 -6.1,12.79 -16.82,22.15L54,78.35z"/>
|
||||
<!-- Pulse line -->
|
||||
<path
|
||||
android:strokeColor="#0ea5e9"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M35,54 L45,54 L48,48 L51,60 L54,51 L57,57 L60,54 L73,54"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F3F4F6" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#EFF6FF" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#ECFDF5" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FEF2F2" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<stroke android:width="1dp" android:color="#E0E0E0" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#9CA3AF" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#10b981" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#f59e0b" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#ef4444" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,874 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F5F5F5"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_heart_pulse"
|
||||
app:tint="#0ea5e9" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="HabitForge"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect Companion"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Health Connect Status Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusHealthConnect"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<!-- Available Layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutAvailable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnOpenHealthConnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Open Health Connect"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Unavailable Layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutUnavailable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvUnavailableMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect needs to be installed to sync your health data."
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnInstallHealthConnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Install Health Connect"
|
||||
android:textAllCaps="false"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Permissions Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Permissions"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusPermissions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRequestPermissions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Grant Permissions"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#0ea5e9"
|
||||
android:layout_marginTop="12dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Server Configuration Card with Status Indicator -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Server"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<!-- Server Availability Indicator -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutServerIndicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="4dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/serverStatusDot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:background="@drawable/status_dot_gray" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerAvailability"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Unknown"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginStart="6dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusServer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerUrl"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<!-- Server response time -->
|
||||
<TextView
|
||||
android:id="@+id/tvServerResponseTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="11sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnConfigureServer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Login"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTestConnection"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Test Connection"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#6366f1" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Sync Settings Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Settings"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Frequency:"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerSyncPeriod"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLastSync"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Never synced"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSyncNow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Now"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#10b981"
|
||||
android:enabled="false" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressSync"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
android:indeterminateTint="#FFFFFF" />
|
||||
</FrameLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSyncConfig"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Configure Sync Duration"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Metrics Overview Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Metrics Overview"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsLastUpdate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#9CA3AF" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsSummary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="View all available health metrics"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<!-- Metrics Grid -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMetricsGrid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<!-- Row 1: Steps, Distance, Calories -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSteps"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricDistance"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricCalories"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 2: Heart Rate, Resting HR, Sleep -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricHeartRate"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricRestingHR"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSleep"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 3: Weight, SpO2, Exercise -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricWeight"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSpO2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricExercise"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRefreshMetrics"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Refresh Metrics"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Data Compatibility Validation Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Data Compatibility"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusDataCompatibility"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Not validated"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCompatibilityDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validate that your Health Connect data can be synced with the HabitForge schema."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnValidateData"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validate Data Schema"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#f59e0b"
|
||||
android:layout_marginTop="12dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnViewRawData"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔍 View Raw Health Connect Data"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Validation Results (hidden by default) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutValidationResults"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="12dp"
|
||||
android:padding="12dp"
|
||||
android:background="@drawable/rounded_background">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validation Results"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#374151" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvValidationResultsList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Sync History Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Sync History"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSyncStats"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginEnd="8dp"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnRefreshHistory"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:src="@android:drawable/ic_popup_sync"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
app:tint="#6B7280"
|
||||
android:contentDescription="Refresh History" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutSyncStats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_green">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSuccessCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#10b981" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Successful"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_red">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFailedCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#ef4444" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Failed"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_blue">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsSynced"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#0ea5e9" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Metrics"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Recent Sync List -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Recent Syncs"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutRecentSyncs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNoSyncHistory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="No sync history yet"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:gravity="center"
|
||||
android:padding="16dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnViewAllHistory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="View Full History"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Metrics Sync Counts Section -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Metrics Sync Status"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsCountLastUpdate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="9sp"
|
||||
android:textColor="#9CA3AF" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsCountSummary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Synced / Available records per metric"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMetricsCounts"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="8dp"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNoMetricsCounts"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Run a sync to see available vs synced counts"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:gravity="center"
|
||||
android:padding="12dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Info Section -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="This app reads your health data from Health Connect and syncs it to your HabitForge dashboard. Keep it installed for automatic background syncing."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAlignment="center"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,184 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F5F5F5">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/teal_dark"
|
||||
app:title="Raw Health Data"
|
||||
app:titleTextColor="@android:color/white"
|
||||
app:navigationIcon="@drawable/ic_arrow_back" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/teal_dark"
|
||||
app:tabMode="scrollable"
|
||||
app:tabTextColor="#80FFFFFF"
|
||||
app:tabSelectedTextColor="@android:color/white"
|
||||
app:tabIndicatorColor="@android:color/white" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<!-- Date Range Selector -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📅 Date Range"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerDateRange"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/spinner_background"
|
||||
android:padding="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnLoadData"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Load"
|
||||
android:backgroundTint="@color/teal_primary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutStats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTotalRecords"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Records: 0"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDateRange"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:gravity="end" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutLoading"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:padding="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:indeterminateTint="@color/teal_primary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLoadingMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="Loading data..."
|
||||
android:textColor="#666666" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Data List -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:padding="8dp" />
|
||||
|
||||
<!-- Empty State -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutEmpty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📭"
|
||||
android:textSize="48sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvEmptyMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="No data found for this date range"
|
||||
android:textColor="#999999"
|
||||
android:textSize="14sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
tools:context=".SyncConfigActivity">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:elevation="4dp"
|
||||
app:navigationIcon="@drawable/ic_arrow_back"
|
||||
app:title="Sync Configuration"
|
||||
app:titleTextColor="@android:color/white" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Raw Data Synchronization"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Configure how many days of history to include in each raw data sync. Larger values may increase sync time and data usage."
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Sync Duration (Days)"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etSyncDays"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:text="7" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Default: 7 days. Recommended: 1-14 days."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#757575"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSave"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Save Configuration"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="#E0E0E0"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Current Status"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrentConfig"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Currently syncing: 7 days"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,233 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📊"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Metric History"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistorySubtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="All synced data from Health Connect"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnCloseHistory"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||
android:contentDescription="Close" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:background="#F3F4F6"
|
||||
android:padding="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryDataPoints"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#10B981" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Data Points"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#D1D5DB" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryLatestValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#3B82F6" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Latest Value"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#D1D5DB" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryAverage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#8B5CF6" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Average"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:weightSum="4"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnFilterRecent"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Recent"
|
||||
android:textSize="10sp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:padding="0dp"
|
||||
android:layout_marginEnd="4dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnFilterWeek"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Week"
|
||||
android:textSize="10sp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:padding="0dp"
|
||||
android:layout_marginEnd="4dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnFilterMonth"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Month"
|
||||
android:textSize="10sp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:padding="0dp"
|
||||
android:layout_marginEnd="4dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnFilterYear"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Year"
|
||||
android:textSize="10sp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:padding="0dp"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Chart Placeholder -->
|
||||
<ImageView
|
||||
android:id="@+id/chartHistory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="180dp"
|
||||
android:background="#F9FAFB"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:contentDescription="History Chart" />
|
||||
|
||||
<!-- History List -->
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutHistoryList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Server URL -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Server URL"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etServerUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="http://192.168.x.x:8000"
|
||||
android:inputType="textUri"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Username -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Username"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etUsername"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Enter your username"
|
||||
android:inputType="text"
|
||||
android:autofillHints="username"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Password -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Password"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Enter your password"
|
||||
android:inputType="textPassword"
|
||||
android:autofillHints="password"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<!-- Helper text -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Use the same credentials as the HabitForge web app"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#888888"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Error message (hidden by default) -->
|
||||
<TextView
|
||||
android:id="@+id/tvError"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#D32F2F"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Loading indicator (hidden by default) -->
|
||||
<ProgressBar
|
||||
android:id="@+id/progressLogin"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryDate"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Jan 01, 2026"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#374151" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="10,000"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryUnit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=" steps"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginStart="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background">
|
||||
|
||||
<!-- Status indicator and icon -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="👣"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚪"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Metric name -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Steps"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#374151"
|
||||
android:layout_marginTop="4dp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Last value or status -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Not synced"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Last sync time -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricSyncTime"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="8sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<!-- Header Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<!-- Sync Status Indicator -->
|
||||
<View
|
||||
android:id="@+id/viewSyncStatus"
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="8dp"
|
||||
android:background="@drawable/status_dot_gray" />
|
||||
|
||||
<!-- Date/Time -->
|
||||
<TextView
|
||||
android:id="@+id/tvRecordDateTime"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Jan 07, 2026 14:30:00"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#333333"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- Data Source App -->
|
||||
<TextView
|
||||
android:id="@+id/tvDataSource"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Zepp"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#999999"
|
||||
android:background="#F0F0F0"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:paddingVertical="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Value Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRecordValue"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="12,345 steps"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/teal_dark"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRecordDuration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Duration: 1h 30m"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#666666"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Details Row (optional) -->
|
||||
<TextView
|
||||
android:id="@+id/tvRecordDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Start: 2026-01-07T08:00:00 → End: 2026-01-07T09:30:00"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#999999"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Sync Info -->
|
||||
<TextView
|
||||
android:id="@+id/tvSyncInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="✓ Synced: Jan 07, 2026 15:00"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#4CAF50"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_select_source"
|
||||
android:icon="@drawable/ic_filter_source"
|
||||
android:title="Select Source"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
</menu>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="brand_primary">#0ea5e9</color>
|
||||
<color name="brand_secondary">#10b981</color>
|
||||
<color name="brand_dark">#0c4a6e</color>
|
||||
|
||||
<color name="background_light">#F5F5F5</color>
|
||||
<color name="background_dark">#1F2937</color>
|
||||
|
||||
<color name="text_primary">#1F2937</color>
|
||||
<color name="text_secondary">#6B7280</color>
|
||||
<color name="text_muted">#9CA3AF</color>
|
||||
|
||||
<color name="success">#10b981</color>
|
||||
<color name="warning">#f59e0b</color>
|
||||
<color name="error">#ef4444</color>
|
||||
|
||||
<!-- Status colors used in MainActivity -->
|
||||
<color name="green">#10b981</color>
|
||||
<color name="orange">#f59e0b</color>
|
||||
<color name="red">#ef4444</color>
|
||||
|
||||
<!-- Teal colors for Raw Data Viewer -->
|
||||
<color name="teal_primary">#14b8a6</color>
|
||||
<color name="teal_dark">#0d9488</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">HabitForge-companion</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.HabitForgeCompanion" parent="Theme.Material3.Light.NoActionBar">
|
||||
<item name="colorPrimary">@color/brand_primary</item>
|
||||
<item name="colorPrimaryVariant">@color/brand_dark</item>
|
||||
<item name="colorSecondary">@color/brand_secondary</item>
|
||||
<item name="android:statusBarColor">@color/brand_primary</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!--
|
||||
Allow cleartext HTTP traffic for development.
|
||||
In production, you should use HTTPS only.
|
||||
-->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,3 @@
|
||||
#Auto-incremented Build Number
|
||||
#Sun Jan 11 20:56:28 EST 2026
|
||||
build.number=56
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
[Incubating] Problems report is available at: file:///C:/dev/git/python/HabitForge/android-companion/build/reports/problems/problems-report.html
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* Where:
|
||||
Build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts' line: 6
|
||||
|
||||
* What went wrong:
|
||||
An exception occurred applying plugin request [id: 'com.android.application']
|
||||
> Failed to apply plugin 'com.android.internal.application'.
|
||||
> Android Gradle plugin requires Java 17 to run. You are currently using Java 11.
|
||||
Your current JDK is located in C:\Users\bruno\scoop\apps\temurin11-jdk\current
|
||||
You can try some of the following options:
|
||||
- changing the IDE settings.
|
||||
- changing the JAVA_HOME environment variable.
|
||||
- changing `org.gradle.java.home` in `gradle.properties`.
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> Run with --info or --debug option to get more log output.
|
||||
> Run with --scan to get full insights.
|
||||
> Get more help at https://help.gradle.org.
|
||||
|
||||
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
|
||||
|
||||
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
|
||||
|
||||
For more on this, please refer to https://docs.gradle.org/8.13/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
|
||||
|
||||
BUILD FAILED in 2s
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
[Incubating] Problems report is available at: file:///C:/dev/git/python/HabitForge/android-companion/build/reports/problems/problems-report.html
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* Where:
|
||||
Build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts' line: 6
|
||||
|
||||
* What went wrong:
|
||||
An exception occurred applying plugin request [id: 'com.android.application']
|
||||
> Failed to apply plugin 'com.android.internal.application'.
|
||||
> Android Gradle plugin requires Java 17 to run. You are currently using Java 11.
|
||||
Your current JDK is located in C:\Users\bruno\scoop\apps\temurin11-jdk\current
|
||||
You can try some of the following options:
|
||||
- changing the IDE settings.
|
||||
- changing the JAVA_HOME environment variable.
|
||||
- changing `org.gradle.java.home` in `gradle.properties`.
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> Run with --info or --debug option to get more log output.
|
||||
> Run with --scan to get full insights.
|
||||
> Get more help at https://help.gradle.org.
|
||||
|
||||
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
|
||||
|
||||
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
|
||||
|
||||
For more on this, please refer to https://docs.gradle.org/8.13/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
|
||||
|
||||
BUILD FAILED in 2s
|
||||
@@ -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
|
||||
Vendored
+207
@@ -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" "$@"
|
||||
Vendored
+92
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
## This file must *NOT* be checked into Version Control Systems,
|
||||
# as it contains information specific to your local configuration.
|
||||
#
|
||||
# Location of the SDK. This is only used by Gradle.
|
||||
# For customization when using a Version Control System, please read the
|
||||
# header note.
|
||||
#Thu Jan 01 22:08:58 EST 2026
|
||||
sdk.dir=C\:\\Users\\bruno\\scoop\\apps\\android-clt\\13114758
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""API module for HabitForge."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API v1 module for HabitForge."""
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Authentication endpoints for API v1."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Body
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from backend.models.user import User
|
||||
from backend import auth
|
||||
from backend.schemas.user import UserCreate, UserResponse
|
||||
from backend.schemas.token import Token
|
||||
from backend.database import get_db
|
||||
from backend.core.config import settings
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
|
||||
def register(request: Request, user: UserCreate, db: Session = Depends(get_db)):
|
||||
"""Register a new user."""
|
||||
db_user = db.query(User).filter(User.username == user.username).first()
|
||||
if db_user:
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
|
||||
hashed_password = auth.get_password_hash(user.password)
|
||||
new_user = User(username=user.username, hashed_password=hashed_password)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
|
||||
def login_for_access_token(
|
||||
request: Request,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Login and get access token."""
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
if not user or not auth.verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
from datetime import timedelta
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
refresh_token_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
refresh_token = auth.create_refresh_token(
|
||||
data={"sub": user.username}, expires_delta=refresh_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
|
||||
def refresh_token(
|
||||
request: Request,
|
||||
refresh_token: str = Body(..., embed=True),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Refresh access token using refresh token."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
from datetime import timedelta
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
refresh_token_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
new_refresh_token = auth.create_refresh_token(
|
||||
data={"sub": user.username}, expires_delta=refresh_token_expires
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "refresh_token": new_refresh_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def read_users_me(current_user: User = Depends(auth.get_current_user)):
|
||||
"""Get current user information."""
|
||||
return current_user
|
||||
@@ -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
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Health check and metric endpoints for API v1."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Optional
|
||||
import datetime
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models.daily_health_metrics import DailyHealthMetrics
|
||||
from backend.models.user import User
|
||||
from backend.models.workout_session import WorkoutSession
|
||||
from backend.schemas.daily_health_metrics import DailyHealthMetricsResponse
|
||||
from backend import auth
|
||||
|
||||
router = APIRouter(prefix="/api/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get("", status_code=status.HTTP_200_OK)
|
||||
def health_check(db: Session = Depends(get_db)):
|
||||
"""Check if the application and database are healthy."""
|
||||
try:
|
||||
# Check database connection
|
||||
db.execute(text("SELECT 1"))
|
||||
return {
|
||||
"status": "healthy",
|
||||
"database": "connected"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
|
||||
|
||||
|
||||
|
||||
# Valid endpoints start here
|
||||
|
||||
|
||||
|
||||
@router.get("/stats", response_model=List[DailyHealthMetricsResponse])
|
||||
def get_health_stats(
|
||||
period: str = "week",
|
||||
ref_date: Optional[datetime.date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Retrieve health statistics for various periods."""
|
||||
|
||||
# Imports for Raw Data
|
||||
from backend.models.health_connect_raw import HCRawWeight
|
||||
from sqlalchemy import func
|
||||
|
||||
# Helper to fetch raw weights
|
||||
def get_raw_weights(start, end):
|
||||
return db.query(HCRawWeight).filter(
|
||||
HCRawWeight.user_id == current_user.id,
|
||||
HCRawWeight.time >= start,
|
||||
HCRawWeight.time <= end
|
||||
).order_by(HCRawWeight.time.asc()).all()
|
||||
|
||||
if ref_date is None:
|
||||
ref_date = datetime.date.today()
|
||||
|
||||
start_date = None
|
||||
end_date = datetime.datetime.combine(ref_date, datetime.time.max) # End of ref day
|
||||
|
||||
if period == "day":
|
||||
start_date = datetime.datetime.combine(ref_date, datetime.time.min)
|
||||
elif period == "week":
|
||||
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=6), datetime.time.min)
|
||||
elif period == "month":
|
||||
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=29), datetime.time.min)
|
||||
elif period == "year":
|
||||
start_date = datetime.datetime.combine(ref_date - datetime.timedelta(days=365), datetime.time.min)
|
||||
|
||||
# Base Metrics Query
|
||||
metrics_query = db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id
|
||||
)
|
||||
|
||||
if period == "day":
|
||||
metrics_query = metrics_query.filter(DailyHealthMetrics.date == ref_date)
|
||||
else:
|
||||
metrics_query = metrics_query.filter(DailyHealthMetrics.date.between(start_date.date(), ref_date))
|
||||
|
||||
metrics_data = metrics_query.order_by(DailyHealthMetrics.date.asc()).all()
|
||||
|
||||
# If Year, handle aggregation specially
|
||||
if period == "year":
|
||||
# Raw weights for the year
|
||||
raw_weights = get_raw_weights(start_date, end_date)
|
||||
|
||||
# Month-based aggregation for metrics
|
||||
aggregated = []
|
||||
import itertools
|
||||
from statistics import mean
|
||||
|
||||
# Merge raw weights into metrics_data conceptually?
|
||||
# Actually easier to aggregate metrics_data first, then overlay weight.
|
||||
|
||||
metrics_by_month = {}
|
||||
for m in metrics_data:
|
||||
key = (m.date.year, m.date.month)
|
||||
if key not in metrics_by_month: metrics_by_month[key] = []
|
||||
metrics_by_month[key].append(m)
|
||||
|
||||
weights_by_month = {}
|
||||
for w in raw_weights:
|
||||
key = (w.time.year, w.time.month)
|
||||
if key not in weights_by_month: weights_by_month[key] = []
|
||||
weights_by_month[key].append(w.weight_kg)
|
||||
|
||||
# distinct months from both sources
|
||||
all_months = set(metrics_by_month.keys()) | set(weights_by_month.keys())
|
||||
|
||||
for key in sorted(all_months):
|
||||
year, month = key
|
||||
g_metrics = metrics_by_month.get(key, [])
|
||||
g_weights = weights_by_month.get(key, [])
|
||||
|
||||
# Use first metric date or construct one
|
||||
if g_metrics:
|
||||
base_date = g_metrics[0].date.replace(day=1)
|
||||
else:
|
||||
base_date = datetime.date(year, month, 1)
|
||||
|
||||
agg = {
|
||||
"id": int(f"{year}{month:02d}"), # Fake ID
|
||||
"user_id": current_user.id,
|
||||
"date": base_date,
|
||||
# Defaults if no metrics
|
||||
"step_count": (sum(x.step_count for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
|
||||
"step_count_total": sum(x.step_count for x in g_metrics) if g_metrics else 0,
|
||||
"calories_burned": (sum(x.calories_burned for x in g_metrics) / len(g_metrics)) if g_metrics else 0,
|
||||
"distance_meters": (sum(x.distance_meters for x in g_metrics) / len(g_metrics)) if g_metrics else 0,
|
||||
"sleep_duration_minutes": (sum(x.sleep_duration_minutes for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
|
||||
"avg_heart_rate": (sum(x.avg_heart_rate or 0 for x in g_metrics) // len(g_metrics)) if g_metrics else 0,
|
||||
# ... skip other fields for brevity unless critical ...
|
||||
"min_heart_rate": min((x.min_heart_rate for x in g_metrics if x.min_heart_rate), default=0) if g_metrics else 0,
|
||||
"max_heart_rate": max((x.max_heart_rate for x in g_metrics if x.max_heart_rate), default=0) if g_metrics else 0,
|
||||
|
||||
# WEIGHT from RAW (schema uses 'weight')
|
||||
"weight": mean(g_weights) if g_weights else 0
|
||||
}
|
||||
# If weight missing in raw but present in aggregated (unlikely but possible)
|
||||
if agg["weight"] == 0 and g_metrics:
|
||||
# Fallback to avg of metrics weight if available
|
||||
w_vals = [x.weight for x in g_metrics if x.weight]
|
||||
if w_vals: agg["weight"] = mean(w_vals)
|
||||
|
||||
aggregated.append(agg)
|
||||
|
||||
return aggregated
|
||||
|
||||
# For Day/Week/Month
|
||||
# Fetch Raw Weights
|
||||
raw_weights = get_raw_weights(start_date, end_date)
|
||||
|
||||
# Map raw weights to date (take latest per date)
|
||||
weights_by_date = {}
|
||||
for w in raw_weights:
|
||||
d = w.time.date()
|
||||
weights_by_date[d] = w.weight_kg # overwrites with latest
|
||||
|
||||
# Merge into metrics_data
|
||||
# 1. Update existing
|
||||
processed_dates = set()
|
||||
final_list = []
|
||||
|
||||
for m in metrics_data:
|
||||
processed_dates.add(m.date)
|
||||
# Overlay raw weight if available
|
||||
if m.date in weights_by_date:
|
||||
m.weight = weights_by_date[m.date] # set attribute dynamically
|
||||
final_list.append(m)
|
||||
|
||||
# 2. Add missing dates (if we have weight but no daily metric)
|
||||
for d, w in weights_by_date.items():
|
||||
if d not in processed_dates:
|
||||
# Create dummy object
|
||||
dummy = DailyHealthMetricsResponse(
|
||||
id=0,
|
||||
user_id=current_user.id,
|
||||
date=d,
|
||||
step_count=0,
|
||||
calories_burned=0,
|
||||
distance_meters=0,
|
||||
sleep_duration_minutes=0,
|
||||
weight=w, # Our raw weight
|
||||
# defaults
|
||||
deep_sleep_minutes=0, light_sleep_minutes=0, rem_sleep_minutes=0, awake_duration_minutes=0,
|
||||
pai_score=0, avg_heart_rate=0, min_heart_rate=0, max_heart_rate=0, avg_spo2=0, resting_heart_rate=0, hrv=0
|
||||
)
|
||||
final_list.append(dummy)
|
||||
|
||||
# Sort
|
||||
final_list.sort(key=lambda x: x.date)
|
||||
|
||||
return final_list
|
||||
|
||||
|
||||
@router.get("/workouts")
|
||||
def get_workouts(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
activity_type: Optional[str] = None,
|
||||
start_date: Optional[datetime.date] = None,
|
||||
end_date: Optional[datetime.date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Retrieve workout sessions for the current user (merges manual and raw data)."""
|
||||
from backend.models.workout_session import WorkoutSession
|
||||
from backend.models.health_connect_raw import HCRawExerciseSession
|
||||
|
||||
start_dt = datetime.datetime.combine(start_date, datetime.time.min) if start_date else None
|
||||
end_dt = datetime.datetime.combine(end_date, datetime.time.max) if end_date else None
|
||||
|
||||
# 1. Fetch from WorkoutSession
|
||||
query_ws = db.query(WorkoutSession).filter(WorkoutSession.user_id == current_user.id)
|
||||
if activity_type:
|
||||
query_ws = query_ws.filter(WorkoutSession.activity_type == activity_type)
|
||||
if start_dt:
|
||||
query_ws = query_ws.filter(WorkoutSession.start_time >= start_dt)
|
||||
if end_dt:
|
||||
query_ws = query_ws.filter(WorkoutSession.start_time <= end_dt)
|
||||
|
||||
workouts_ws = query_ws.order_by(WorkoutSession.start_time.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# 2. Fetch from HCRawExerciseSession
|
||||
query_raw = db.query(HCRawExerciseSession).filter(HCRawExerciseSession.user_id == current_user.id)
|
||||
if activity_type:
|
||||
query_raw = query_raw.filter(HCRawExerciseSession.exercise_type_name == activity_type)
|
||||
if start_dt:
|
||||
query_raw = query_raw.filter(HCRawExerciseSession.start_time >= start_dt)
|
||||
if end_dt:
|
||||
query_raw = query_raw.filter(HCRawExerciseSession.start_time <= end_dt)
|
||||
|
||||
workouts_raw = query_raw.order_by(HCRawExerciseSession.start_time.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# Combine and Map
|
||||
combined = []
|
||||
|
||||
for w in workouts_ws:
|
||||
combined.append({
|
||||
"id": f"ws_{w.id}",
|
||||
"user_id": w.user_id,
|
||||
"start_time": w.start_time.isoformat() if w.start_time else None,
|
||||
"end_time": w.end_time.isoformat() if w.end_time else None,
|
||||
"activity_type": w.activity_type,
|
||||
"activity_type_id": w.activity_type_id,
|
||||
"duration_seconds": w.duration_seconds or 0,
|
||||
"distance_meters": w.distance_meters or 0.0,
|
||||
"calories": w.calories or 0.0,
|
||||
"avg_pace": w.avg_pace,
|
||||
"max_pace": w.max_pace,
|
||||
"min_pace": w.min_pace,
|
||||
"avg_hr": w.avg_hr,
|
||||
"max_hr": w.max_hr,
|
||||
"min_hr": w.min_hr,
|
||||
"notes": w.notes,
|
||||
"source": w.data_source
|
||||
})
|
||||
|
||||
for w in workouts_raw:
|
||||
duration = 0
|
||||
if w.start_time and w.end_time:
|
||||
duration = int((w.end_time - w.start_time).total_seconds())
|
||||
|
||||
combined.append({
|
||||
"id": f"raw_{w.id}",
|
||||
"user_id": w.user_id,
|
||||
"start_time": w.start_time.isoformat() if w.start_time else None,
|
||||
"end_time": w.end_time.isoformat() if w.end_time else None,
|
||||
"activity_type": w.exercise_type_name or f"Activity {w.exercise_type}",
|
||||
"activity_type_id": w.exercise_type,
|
||||
"duration_seconds": duration,
|
||||
"distance_meters": w.distance_meters or 0.0,
|
||||
"calories": w.calories or 0.0,
|
||||
"avg_pace": None,
|
||||
"max_pace": None,
|
||||
"min_pace": None,
|
||||
"avg_hr": w.avg_heart_rate,
|
||||
"max_hr": w.max_heart_rate,
|
||||
"min_hr": w.min_heart_rate,
|
||||
"notes": w.notes,
|
||||
"source": "health_connect"
|
||||
})
|
||||
|
||||
# Sort combined list by start_time DESC
|
||||
combined.sort(key=lambda x: x["start_time"] if x["start_time"] else "", reverse=True)
|
||||
|
||||
return combined[:limit]
|
||||
|
||||
|
||||
@router.get("/workouts/summary")
|
||||
def get_workout_summary(
|
||||
start_date: Optional[datetime.date] = None,
|
||||
end_date: Optional[datetime.date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get summary statistics for workouts (combines manual and raw data)."""
|
||||
from backend.models.workout_session import WorkoutSession
|
||||
from backend.models.health_connect_raw import HCRawExerciseSession
|
||||
from sqlalchemy import func
|
||||
|
||||
start_dt = datetime.datetime.combine(start_date, datetime.time.min) if start_date else None
|
||||
end_dt = datetime.datetime.combine(end_date, datetime.time.max) if end_date else None
|
||||
|
||||
# 1. Stats from WorkoutSession
|
||||
query_ws = db.query(
|
||||
WorkoutSession.activity_type,
|
||||
func.count(WorkoutSession.id).label('count'),
|
||||
func.sum(WorkoutSession.duration_seconds).label('total_duration'),
|
||||
func.sum(WorkoutSession.calories).label('total_calories'),
|
||||
func.sum(WorkoutSession.distance_meters).label('total_distance')
|
||||
).filter(WorkoutSession.user_id == current_user.id)
|
||||
|
||||
if start_dt:
|
||||
query_ws = query_ws.filter(WorkoutSession.start_time >= start_dt)
|
||||
if end_dt:
|
||||
query_ws = query_ws.filter(WorkoutSession.start_time <= end_dt)
|
||||
|
||||
by_type_ws = query_ws.group_by(WorkoutSession.activity_type).all()
|
||||
|
||||
# 2. Stats from HCRawExerciseSession
|
||||
# We fetch the sessions and aggregate in Python to avoid SQLite date extraction issues
|
||||
query_raw = db.query(HCRawExerciseSession).filter(HCRawExerciseSession.user_id == current_user.id)
|
||||
|
||||
if start_dt:
|
||||
query_raw = query_raw.filter(HCRawExerciseSession.start_time >= start_dt)
|
||||
if end_dt:
|
||||
query_raw = query_raw.filter(HCRawExerciseSession.start_time <= end_dt)
|
||||
|
||||
sessions_raw = query_raw.all()
|
||||
|
||||
# Merge results
|
||||
summary_map = {}
|
||||
|
||||
# Process manual sessions
|
||||
for row in by_type_ws:
|
||||
name = row[0] or "Unknown"
|
||||
summary_map[name] = {
|
||||
"count": row[1],
|
||||
"duration": row[2] or 0,
|
||||
"calories": row[3] or 0,
|
||||
"distance": row[4] or 0
|
||||
}
|
||||
|
||||
# Process raw sessions
|
||||
for w in sessions_raw:
|
||||
name = w.exercise_type_name or f"Activity {w.exercise_type}"
|
||||
duration = 0
|
||||
if w.start_time and w.end_time:
|
||||
duration = (w.end_time - w.start_time).total_seconds()
|
||||
|
||||
if name in summary_map:
|
||||
summary_map[name]["count"] += 1
|
||||
summary_map[name]["duration"] += duration
|
||||
summary_map[name]["calories"] += (w.calories or 0)
|
||||
summary_map[name]["distance"] += (w.distance_meters or 0)
|
||||
else:
|
||||
summary_map[name] = {
|
||||
"count": 1,
|
||||
"duration": duration,
|
||||
"calories": w.calories or 0,
|
||||
"distance": w.distance_meters or 0
|
||||
}
|
||||
|
||||
total_workouts = sum(s["count"] for s in summary_map.values())
|
||||
|
||||
return {
|
||||
"total_workouts": total_workouts,
|
||||
"by_activity_type": [
|
||||
{
|
||||
"activity_type": name,
|
||||
"count": stats["count"],
|
||||
"total_duration_seconds": int(stats["duration"]),
|
||||
"total_calories": round(stats["calories"], 1),
|
||||
"total_distance_meters": round(stats["distance"], 1)
|
||||
}
|
||||
for name, stats in summary_map.items()
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc, distinct
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, date, timedelta
|
||||
import logging
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models.user import User
|
||||
from backend.models.health_connect_raw import (
|
||||
HCRawSteps,
|
||||
HCRawHeartRate,
|
||||
HCRawHeartRateSample,
|
||||
HCRawRestingHeartRate,
|
||||
HCRawSleepSession,
|
||||
HCRawSleepStage,
|
||||
HCRawDistance,
|
||||
HCRawCalories,
|
||||
HCRawOxygenSaturation,
|
||||
HCRawWeight,
|
||||
HCRawExerciseSession,
|
||||
HCRawHeartRateVariabilityRmssd,
|
||||
SLEEP_STAGE_TYPES
|
||||
)
|
||||
from backend.models.daily_context import DailyContext
|
||||
from pydantic import BaseModel
|
||||
from backend import auth
|
||||
|
||||
router = APIRouter(prefix="/api/analytics", tags=["health-analytics"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.get("/sources", response_model=List[Dict[str, str]])
|
||||
async def get_available_sources(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Get unique data sources available in the raw data.
|
||||
Checks Steps and Sleep as primary indicators.
|
||||
"""
|
||||
# Query distinct sources from Steps
|
||||
step_sources = db.query(
|
||||
HCRawSteps.data_source,
|
||||
HCRawSteps.data_source_name
|
||||
).filter(
|
||||
HCRawSteps.user_id == current_user.id
|
||||
).distinct().all()
|
||||
|
||||
# Query distinct sources from Sleep
|
||||
sleep_sources = db.query(
|
||||
HCRawSleepSession.data_source,
|
||||
HCRawSleepSession.data_source_name
|
||||
).filter(
|
||||
HCRawSleepSession.user_id == current_user.id
|
||||
).distinct().all()
|
||||
|
||||
# Combine and Dedupe
|
||||
sources_map = {}
|
||||
|
||||
for src, name in step_sources:
|
||||
if src not in sources_map:
|
||||
sources_map[src] = name or src
|
||||
|
||||
for src, name in sleep_sources:
|
||||
if src not in sources_map:
|
||||
sources_map[src] = name or src
|
||||
|
||||
return [
|
||||
{"package": k, "name": v}
|
||||
for k, v in sources_map.items()
|
||||
]
|
||||
|
||||
@router.get("/daily", response_model=Dict[str, Any])
|
||||
async def get_daily_analytics(
|
||||
date_str: str, # YYYY-MM-DD
|
||||
source: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Get aggregated daily heath metrics from raw data for a specific date.
|
||||
"""
|
||||
try:
|
||||
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
start_dt = datetime.combine(target_date, datetime.min.time())
|
||||
end_dt = datetime.combine(target_date, datetime.max.time())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
stats = {}
|
||||
|
||||
# --- STEPS ---
|
||||
steps_query = db.query(func.sum(HCRawSteps.count)).filter(
|
||||
HCRawSteps.user_id == current_user.id,
|
||||
HCRawSteps.start_time >= start_dt,
|
||||
HCRawSteps.start_time <= end_dt
|
||||
)
|
||||
if source:
|
||||
steps_query = steps_query.filter(HCRawSteps.data_source == source)
|
||||
|
||||
stats["steps"] = steps_query.scalar() or 0
|
||||
|
||||
# --- CALORIES ---
|
||||
# Sum total calories
|
||||
cals_query = db.query(func.sum(HCRawCalories.calories)).filter(
|
||||
HCRawCalories.user_id == current_user.id,
|
||||
HCRawCalories.start_time >= start_dt,
|
||||
HCRawCalories.start_time <= end_dt
|
||||
)
|
||||
if source:
|
||||
cals_query = cals_query.filter(HCRawCalories.data_source == source)
|
||||
|
||||
stats["calories"] = round(cals_query.scalar() or 0, 1)
|
||||
|
||||
# --- DISTANCE ---
|
||||
dist_query = db.query(func.sum(HCRawDistance.distance_meters)).filter(
|
||||
HCRawDistance.user_id == current_user.id,
|
||||
HCRawDistance.start_time >= start_dt,
|
||||
HCRawDistance.start_time <= end_dt
|
||||
)
|
||||
if source:
|
||||
dist_query = dist_query.filter(HCRawDistance.data_source == source)
|
||||
|
||||
stats["distance_meters"] = round(dist_query.scalar() or 0, 1)
|
||||
|
||||
# --- SLEEP (Previous Night) ---
|
||||
# Usually sleep for "today" means sleep ending today (morning).
|
||||
# Logic: Sleep sessions ending between today 00:00 and today 23:59 (or noon to noon?)
|
||||
# Health Connect usually assigns sleep to the day it ends.
|
||||
# We sum duration of sessions overlap. Simpler: Sum duration of sessions ending on this date.
|
||||
|
||||
sleep_query = db.query(HCRawSleepSession).filter(
|
||||
HCRawSleepSession.user_id == current_user.id,
|
||||
HCRawSleepSession.end_time >= start_dt,
|
||||
HCRawSleepSession.end_time <= end_dt
|
||||
)
|
||||
if source:
|
||||
sleep_query = sleep_query.filter(HCRawSleepSession.data_source == source)
|
||||
|
||||
sessions = sleep_query.all()
|
||||
total_sleep_seconds = 0
|
||||
|
||||
for s in sessions:
|
||||
duration = (s.end_time - s.start_time).total_seconds()
|
||||
total_sleep_seconds += duration
|
||||
|
||||
session_total_minutes = int(total_sleep_seconds / 60)
|
||||
|
||||
# Track bedtime/wake (earliest start, latest end)
|
||||
bedtime = min([s.start_time for s in sessions]) if sessions else None
|
||||
wake_time = max([s.end_time for s in sessions]) if sessions else None
|
||||
|
||||
stats["bedtime"] = bedtime.isoformat() if bedtime else None
|
||||
stats["wake_time"] = wake_time.isoformat() if wake_time else None
|
||||
|
||||
# --- HEART RATE (Resting) ---
|
||||
rhr_query = db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
|
||||
HCRawRestingHeartRate.user_id == current_user.id,
|
||||
HCRawRestingHeartRate.time >= start_dt,
|
||||
HCRawRestingHeartRate.time <= end_dt
|
||||
)
|
||||
if source:
|
||||
rhr_query = rhr_query.filter(HCRawRestingHeartRate.data_source == source)
|
||||
|
||||
stats["resting_hr"] = int(rhr_query.scalar() or 0)
|
||||
|
||||
# --- WEIGHT (Latest) ---
|
||||
weight_query = db.query(HCRawWeight.weight_kg).filter(
|
||||
HCRawWeight.user_id == current_user.id,
|
||||
HCRawWeight.time <= end_dt
|
||||
).order_by(HCRawWeight.time.desc())
|
||||
|
||||
if source:
|
||||
weight_query = weight_query.filter(HCRawWeight.data_source == source)
|
||||
|
||||
latest_weight = weight_query.first()
|
||||
stats["weight_kg"] = round(latest_weight[0], 1) if latest_weight else None
|
||||
|
||||
# --- SLEEP STAGES ---
|
||||
# Sum duration per stage for sessions ending today
|
||||
stages_query = db.query(
|
||||
HCRawSleepStage.stage_name,
|
||||
func.sum(func.extract('epoch', HCRawSleepStage.end_time) - func.extract('epoch', HCRawSleepStage.start_time))
|
||||
).join(HCRawSleepSession).filter(
|
||||
HCRawSleepSession.user_id == current_user.id,
|
||||
HCRawSleepSession.end_time >= start_dt,
|
||||
HCRawSleepSession.end_time <= end_dt
|
||||
)
|
||||
if source:
|
||||
stages_query = stages_query.filter(HCRawSleepSession.data_source == source)
|
||||
|
||||
stages_result = stages_query.group_by(HCRawSleepStage.stage_name).all()
|
||||
|
||||
stats["deep_sleep_minutes"] = 0
|
||||
stats["light_sleep_minutes"] = 0
|
||||
stats["rem_sleep_minutes"] = 0
|
||||
stats["awake_duration_minutes"] = 0
|
||||
|
||||
for stage_name, duration_seconds in stages_result:
|
||||
if not duration_seconds:
|
||||
continue
|
||||
minutes = int(duration_seconds / 60)
|
||||
# Normalize stage names
|
||||
name = stage_name.lower() if stage_name else "unknown"
|
||||
|
||||
if "deep" in name:
|
||||
stats["deep_sleep_minutes"] += minutes
|
||||
elif "light" in name:
|
||||
stats["light_sleep_minutes"] += minutes
|
||||
elif "rem" in name:
|
||||
stats["rem_sleep_minutes"] += minutes
|
||||
elif "awake" in name:
|
||||
stats["awake_duration_minutes"] += minutes
|
||||
|
||||
# --- SLEEP HEART RATE ---
|
||||
if bedtime and wake_time:
|
||||
sleep_hr_query = db.query(func.avg(HCRawHeartRateSample.bpm)).join(HCRawHeartRate).filter(
|
||||
HCRawHeartRate.user_id == current_user.id,
|
||||
HCRawHeartRateSample.time >= bedtime,
|
||||
HCRawHeartRateSample.time <= wake_time
|
||||
)
|
||||
if source:
|
||||
sleep_hr_query = sleep_hr_query.filter(HCRawHeartRate.data_source == source)
|
||||
|
||||
stats["sleep_avg_hr"] = int(sleep_hr_query.scalar() or 0)
|
||||
else:
|
||||
stats["sleep_avg_hr"] = None
|
||||
|
||||
# Final Sleep Calculation: Total Sleep = Deep + Light + REM
|
||||
# This excludes Awake time which is often included in session duration
|
||||
actual_sleep = stats["deep_sleep_minutes"] + stats["light_sleep_minutes"] + stats["rem_sleep_minutes"]
|
||||
if actual_sleep > 0:
|
||||
stats["sleep_minutes"] = actual_sleep
|
||||
else:
|
||||
# Fallback to session duration (minus awake if reported as a stage)
|
||||
stats["sleep_minutes"] = max(0, session_total_minutes - stats["awake_duration_minutes"])
|
||||
|
||||
# --- HEART RATE DETAILED (Min/Max/Avg) ---
|
||||
# Query samples directly for accuracy
|
||||
hr_stats_query = db.query(
|
||||
func.min(HCRawHeartRateSample.bpm),
|
||||
func.max(HCRawHeartRateSample.bpm),
|
||||
func.avg(HCRawHeartRateSample.bpm)
|
||||
).join(HCRawHeartRate).filter(
|
||||
HCRawHeartRate.user_id == current_user.id,
|
||||
HCRawHeartRateSample.time >= start_dt,
|
||||
HCRawHeartRateSample.time <= end_dt
|
||||
)
|
||||
|
||||
if source:
|
||||
hr_stats_query = hr_stats_query.filter(HCRawHeartRate.data_source == source)
|
||||
|
||||
min_hr, max_hr, avg_hr = hr_stats_query.first()
|
||||
|
||||
stats["min_heart_rate"] = min_hr
|
||||
stats["max_heart_rate"] = max_hr
|
||||
stats["avg_heart_rate"] = int(avg_hr) if avg_hr else None
|
||||
|
||||
# --- HRV ---
|
||||
hrv_query = db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
|
||||
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
|
||||
HCRawHeartRateVariabilityRmssd.time >= start_dt,
|
||||
HCRawHeartRateVariabilityRmssd.time <= end_dt
|
||||
)
|
||||
if source:
|
||||
hrv_query = hrv_query.filter(HCRawHeartRateVariabilityRmssd.data_source == source)
|
||||
|
||||
avg_hrv = hrv_query.scalar()
|
||||
stats["hrv"] = int(avg_hrv) if avg_hrv else None
|
||||
|
||||
# --- USER CONTEXT ---
|
||||
context = db.query(DailyContext).filter(
|
||||
DailyContext.user_id == current_user.id,
|
||||
DailyContext.date == target_date
|
||||
).first()
|
||||
|
||||
stats["user_context"] = {
|
||||
"mood_score": context.mood_score if context else None,
|
||||
"tags": context.tags.split(",") if context and context.tags else [],
|
||||
"notes": context.notes if context else None
|
||||
}
|
||||
|
||||
# --- READINESS CALCULATION ---
|
||||
# 1. Fetch 30-day baselines for HRV and RHR
|
||||
baseline_start = start_dt - timedelta(days=30)
|
||||
baseline_hrv = float(db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
|
||||
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
|
||||
HCRawHeartRateVariabilityRmssd.time >= baseline_start,
|
||||
HCRawHeartRateVariabilityRmssd.time < start_dt
|
||||
).scalar() or 40.0) # Default fallback
|
||||
|
||||
baseline_rhr = float(db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
|
||||
HCRawRestingHeartRate.user_id == current_user.id,
|
||||
HCRawRestingHeartRate.time >= baseline_start,
|
||||
HCRawRestingHeartRate.time < start_dt
|
||||
).scalar() or 60.0) # Default fallback
|
||||
|
||||
# 2. Mental Score (Nervous System)
|
||||
# HRV: Higher is better. RHR: Lower is better.
|
||||
current_hrv = float(stats["hrv"] or baseline_hrv)
|
||||
current_rhr = float(stats["resting_hr"] or baseline_rhr)
|
||||
|
||||
hrv_component = (current_hrv / baseline_hrv) * 100
|
||||
rhr_component = (baseline_rhr / current_rhr) * 100
|
||||
|
||||
mental_score = min(100, max(0, (hrv_component + rhr_component) / 2))
|
||||
|
||||
# 3. Physical Score (Sleep + Activity)
|
||||
# Sleep goal: 8h (480 min).
|
||||
sleep_score = min(100, (stats["sleep_minutes"] / 480) * 100)
|
||||
|
||||
# Simple physical placeholder: just sleep for now as we don't have "yesterday's strain" handy easily without another query
|
||||
# TODO: Add previous day strain integration
|
||||
physical_score = sleep_score
|
||||
|
||||
overall_score = int((mental_score * 0.5) + (physical_score * 0.5))
|
||||
|
||||
# Recommendation Logic
|
||||
recommendation = ""
|
||||
if overall_score >= 80:
|
||||
recommendation = "All systems go! Your body and mind are primed for high intensity."
|
||||
elif overall_score < 40:
|
||||
recommendation = "Rest day advised. Focus on recovery and sleep."
|
||||
else:
|
||||
# Mixed signals
|
||||
if mental_score < physical_score - 10:
|
||||
recommendation = "Body is ready, but nervous system is tired. Avoid HIIT, try steady cardio or yoga."
|
||||
elif physical_score < mental_score - 10:
|
||||
recommendation = "Mind is fresh, but body needs rest. Light technical work or mobility."
|
||||
else:
|
||||
recommendation = "Moderate intensity recommended. Listen to your body."
|
||||
|
||||
stats["readiness"] = {
|
||||
"score": overall_score,
|
||||
"mental_score": int(mental_score),
|
||||
"physical_score": int(physical_score),
|
||||
"hrv_baseline": int(baseline_hrv),
|
||||
"rhr_baseline": int(baseline_rhr),
|
||||
"recommendation": recommendation
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/range", response_model=Dict[str, Any])
|
||||
async def get_range_analytics(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
source: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Get daily breakdown for a date range (e.g. for charts).
|
||||
"""
|
||||
try:
|
||||
s_date = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
e_date = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
|
||||
# Generate list of dates
|
||||
delta = e_date - s_date
|
||||
days = []
|
||||
for i in range(delta.days + 1):
|
||||
day = s_date + timedelta(days=i)
|
||||
days.append(day)
|
||||
|
||||
results = []
|
||||
|
||||
# Optimized: We could do a big Group By query, but simplistic approach first loop is safer for logic correctness
|
||||
# For performance on 30 days, we should use SQL Group By.
|
||||
|
||||
# STEPS Group By
|
||||
start_dt = datetime.combine(s_date, datetime.min.time())
|
||||
end_dt = datetime.combine(e_date, datetime.max.time())
|
||||
|
||||
# helper for range query
|
||||
def get_daily_sum(model, value_col):
|
||||
qs = db.query(
|
||||
func.date(model.start_time).label("day"),
|
||||
func.sum(value_col)
|
||||
).filter(
|
||||
model.user_id == current_user.id,
|
||||
model.start_time >= start_dt,
|
||||
model.start_time <= end_dt
|
||||
)
|
||||
if source:
|
||||
qs = qs.filter(model.data_source == source)
|
||||
return dict(qs.group_by(func.date(model.start_time)).all())
|
||||
|
||||
steps_data = get_daily_sum(HCRawSteps, HCRawSteps.count)
|
||||
cals_data = get_daily_sum(HCRawCalories, HCRawCalories.calories)
|
||||
dist_data = get_daily_sum(HCRawDistance, HCRawDistance.distance_meters)
|
||||
|
||||
# Resting HR (Average per day)
|
||||
rhr_qs = db.query(
|
||||
func.date(HCRawRestingHeartRate.time).label("day"),
|
||||
func.avg(HCRawRestingHeartRate.bpm)
|
||||
).filter(
|
||||
HCRawRestingHeartRate.user_id == current_user.id,
|
||||
HCRawRestingHeartRate.time >= start_dt,
|
||||
HCRawRestingHeartRate.time <= end_dt
|
||||
)
|
||||
if source:
|
||||
rhr_qs = rhr_qs.filter(HCRawRestingHeartRate.data_source == source)
|
||||
rhr_data = dict(rhr_qs.group_by(func.date(HCRawRestingHeartRate.time)).all())
|
||||
|
||||
# HRV (Average per day)
|
||||
hrv_qs = db.query(
|
||||
func.date(HCRawHeartRateVariabilityRmssd.time).label("day"),
|
||||
func.avg(HCRawHeartRateVariabilityRmssd.rmssd)
|
||||
).filter(
|
||||
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
|
||||
HCRawHeartRateVariabilityRmssd.time >= start_dt,
|
||||
HCRawHeartRateVariabilityRmssd.time <= end_dt
|
||||
)
|
||||
if source:
|
||||
hrv_qs = hrv_qs.filter(HCRawHeartRateVariabilityRmssd.data_source == source)
|
||||
hrv_data = dict(hrv_qs.group_by(func.date(HCRawHeartRateVariabilityRmssd.time)).all())
|
||||
|
||||
# Sleep is trickier. We want Deep + Light + REM, excluding Awake.
|
||||
# 1. Query stages excluding awake
|
||||
sleep_stages_qs = db.query(
|
||||
func.date(HCRawSleepSession.end_time).label("day"),
|
||||
func.sum(func.extract('epoch', HCRawSleepStage.end_time) - func.extract('epoch', HCRawSleepStage.start_time))
|
||||
).join(HCRawSleepStage).filter(
|
||||
HCRawSleepSession.user_id == current_user.id,
|
||||
HCRawSleepSession.end_time >= start_dt,
|
||||
HCRawSleepSession.end_time <= end_dt,
|
||||
func.lower(HCRawSleepStage.stage_name).notlike('%awake%')
|
||||
).group_by(func.date(HCRawSleepSession.end_time))
|
||||
|
||||
if source:
|
||||
sleep_stages_qs = sleep_stages_qs.filter(HCRawSleepSession.data_source == source)
|
||||
|
||||
sleep_stages_map = {str(d): s/60 for d, s in sleep_stages_qs.all()}
|
||||
|
||||
# 2. Query session totals as fallback
|
||||
sleep_sess_qs = db.query(
|
||||
func.date(HCRawSleepSession.end_time).label("day"),
|
||||
func.sum(func.extract('epoch', HCRawSleepSession.end_time) - func.extract('epoch', HCRawSleepSession.start_time))
|
||||
).filter(
|
||||
HCRawSleepSession.user_id == current_user.id,
|
||||
HCRawSleepSession.end_time >= start_dt,
|
||||
HCRawSleepSession.end_time <= end_dt
|
||||
).group_by(func.date(HCRawSleepSession.end_time))
|
||||
|
||||
if source:
|
||||
sleep_sess_qs = sleep_sess_qs.filter(HCRawSleepSession.data_source == source)
|
||||
|
||||
sleep_sess_map = {str(d): s/60 for d, s in sleep_sess_qs.all()}
|
||||
|
||||
# Merge: Prioritize stages
|
||||
sleep_data = {}
|
||||
for d_str in set(list(sleep_stages_map.keys()) + list(sleep_sess_map.keys())):
|
||||
sleep_data[d_str] = sleep_stages_map.get(d_str) or sleep_sess_map.get(d_str) or 0
|
||||
|
||||
# 3. Query bedtime/wake per day
|
||||
sleep_times_qs = db.query(
|
||||
func.date(HCRawSleepSession.end_time).label("day"),
|
||||
func.min(HCRawSleepSession.start_time),
|
||||
func.max(HCRawSleepSession.end_time)
|
||||
).filter(
|
||||
HCRawSleepSession.user_id == current_user.id,
|
||||
HCRawSleepSession.end_time >= start_dt,
|
||||
HCRawSleepSession.end_time <= end_dt
|
||||
).group_by(func.date(HCRawSleepSession.end_time))
|
||||
|
||||
if source:
|
||||
sleep_times_qs = sleep_times_qs.filter(HCRawSleepSession.data_source == source)
|
||||
|
||||
sleep_times_map = {str(d): {"bedtime": b.isoformat(), "wake_time": w.isoformat()} for d, b, w in sleep_times_qs.all()}
|
||||
|
||||
# Construct response
|
||||
daily_data = []
|
||||
for day in days:
|
||||
day_str = day.strftime("%Y-%m-%d")
|
||||
|
||||
# Helper to safely get value from dict with string or date keys
|
||||
def get_val(data_dict):
|
||||
val = data_dict.get(day_str, 0)
|
||||
if not val and data_dict.get(day):
|
||||
val = data_dict.get(day)
|
||||
return val or 0
|
||||
|
||||
times = sleep_times_map.get(day_str, {})
|
||||
|
||||
daily_data.append({
|
||||
"date": day_str,
|
||||
"steps": int(get_val(steps_data)),
|
||||
"calories": round(get_val(cals_data), 1),
|
||||
"distance_meters": round(get_val(dist_data), 1),
|
||||
"resting_hr": int(get_val(rhr_data)),
|
||||
"hrv": int(get_val(hrv_data)),
|
||||
"sleep_minutes": int(sleep_data.get(day_str, 0)),
|
||||
"bedtime": times.get("bedtime"),
|
||||
"wake_time": times.get("wake_time"),
|
||||
"mood_score": 0, # Placeholder
|
||||
"strain": int(get_val(cals_data) / 20), # Rough strain proxy (2000 cal = 100 strain)
|
||||
"recovery": 0 # Placeholder
|
||||
})
|
||||
|
||||
# Enrich with Context and simple readiness (optimized)
|
||||
# Fetch all context for range
|
||||
context_map = {}
|
||||
contexts = db.query(DailyContext).filter(
|
||||
DailyContext.user_id == current_user.id,
|
||||
DailyContext.date >= s_date,
|
||||
DailyContext.date <= e_date
|
||||
).all()
|
||||
for c in contexts:
|
||||
context_map[str(c.date)] = {"mood": c.mood_score, "tags": c.tags}
|
||||
|
||||
# Calculate baselines once (using end_date for simplicity)
|
||||
baseline_start = e_date - timedelta(days=30)
|
||||
base_hrv = float(db.query(func.avg(HCRawHeartRateVariabilityRmssd.rmssd)).filter(
|
||||
HCRawHeartRateVariabilityRmssd.user_id == current_user.id,
|
||||
HCRawHeartRateVariabilityRmssd.time >= datetime.combine(baseline_start, datetime.min.time()),
|
||||
HCRawHeartRateVariabilityRmssd.time <= datetime.combine(e_date, datetime.max.time())
|
||||
).scalar() or 40.0)
|
||||
|
||||
base_rhr = float(db.query(func.avg(HCRawRestingHeartRate.bpm)).filter(
|
||||
HCRawRestingHeartRate.user_id == current_user.id,
|
||||
HCRawRestingHeartRate.time >= datetime.combine(baseline_start, datetime.min.time()),
|
||||
HCRawRestingHeartRate.time <= datetime.combine(e_date, datetime.max.time())
|
||||
).scalar() or 60.0)
|
||||
|
||||
for d in daily_data:
|
||||
d_date = d["date"]
|
||||
# Context
|
||||
if d_date in context_map:
|
||||
d["mood_score"] = context_map[d_date]["mood"]
|
||||
d["tags"] = context_map[d_date]["tags"]
|
||||
|
||||
# Readiness Calculation
|
||||
d_hrv = float(d["hrv"] or base_hrv)
|
||||
d_rhr = float(d["resting_hr"] or base_rhr)
|
||||
d_sleep = d["sleep_minutes"]
|
||||
|
||||
mental = ((d_hrv / base_hrv) * 0.5 + (base_rhr / d_rhr) * 0.5) * 100
|
||||
physical = min(100, (d_sleep / 480) * 100)
|
||||
|
||||
d["readiness"] = int((mental + physical) / 2)
|
||||
d["recovery_score"] = int(mental) # Use mental as "Recovery" for the chart green bar? Or overall readiness.
|
||||
|
||||
return {
|
||||
"daily": daily_data,
|
||||
"baselines": {
|
||||
"hrv": int(base_hrv),
|
||||
"rhr": int(base_rhr)
|
||||
}
|
||||
}
|
||||
|
||||
class ContextUpdate(BaseModel):
|
||||
date: str
|
||||
mood: int
|
||||
tags: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
@router.post("/context", status_code=status.HTTP_201_CREATED)
|
||||
async def update_daily_context(
|
||||
update: ContextUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Update subjective user context (mood, tags) for a specific date.
|
||||
"""
|
||||
try:
|
||||
target_date = datetime.strptime(update.date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
|
||||
context = db.query(DailyContext).filter(
|
||||
DailyContext.user_id == current_user.id,
|
||||
DailyContext.date == target_date
|
||||
).first()
|
||||
|
||||
if not context:
|
||||
context = DailyContext(
|
||||
user_id=current_user.id,
|
||||
date=target_date,
|
||||
mood_score=update.mood,
|
||||
tags=update.tags,
|
||||
notes=update.notes
|
||||
)
|
||||
db.add(context)
|
||||
else:
|
||||
context.mood_score = update.mood
|
||||
context.tags = update.tags
|
||||
context.notes = update.notes
|
||||
|
||||
db.commit()
|
||||
db.refresh(context)
|
||||
|
||||
return {"status": "success", "id": context.id}
|
||||
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Health Connect API - Endpoints for receiving data from Android companion app.
|
||||
|
||||
These endpoints allow the Android companion app to push health data
|
||||
from Health Connect to the HabitForge backend.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models.user import User
|
||||
from backend.models.health_connect import (
|
||||
HealthConnectSyncLog,
|
||||
HealthConnectDailyMetrics,
|
||||
HealthConnectExerciseSession,
|
||||
HEALTH_CONNECT_EXERCISE_TYPES,
|
||||
)
|
||||
from backend.models.daily_health_metrics import DailyHealthMetrics
|
||||
from backend.models.workout_session import WorkoutSession
|
||||
from backend import auth
|
||||
from backend.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/health-connect", tags=["health-connect"])
|
||||
|
||||
|
||||
# --- Request Models ---
|
||||
|
||||
class DailyMetricsPayload(BaseModel):
|
||||
"""Payload for daily health metrics from Health Connect."""
|
||||
date: date
|
||||
step_count: int = 0
|
||||
distance_meters: float = 0.0
|
||||
calories_burned: float = 0.0
|
||||
total_calories: float = 0.0
|
||||
sleep_duration_minutes: int = 0
|
||||
deep_sleep_minutes: int = 0
|
||||
light_sleep_minutes: int = 0
|
||||
rem_sleep_minutes: int = 0
|
||||
awake_duration_minutes: int = 0
|
||||
avg_heart_rate: Optional[int] = None
|
||||
min_heart_rate: Optional[int] = None
|
||||
max_heart_rate: Optional[int] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
avg_spo2: Optional[float] = None
|
||||
min_spo2: Optional[float] = None
|
||||
max_spo2: Optional[float] = None
|
||||
weight: Optional[float] = None
|
||||
# Data source tracking
|
||||
data_source: Optional[str] = None # Package name (e.g., "com.huami.watch.hmwatchmanager")
|
||||
data_source_name: Optional[str] = None # Friendly name (e.g., "Zepp")
|
||||
|
||||
|
||||
class ExerciseSessionPayload(BaseModel):
|
||||
"""Payload for exercise session from Health Connect."""
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
exercise_type: int # Health Connect ExerciseType enum value
|
||||
exercise_type_name: Optional[str] = None
|
||||
duration_seconds: int = 0
|
||||
distance_meters: Optional[float] = None
|
||||
calories_burned: Optional[float] = None
|
||||
avg_heart_rate: Optional[int] = None
|
||||
max_heart_rate: Optional[int] = None
|
||||
min_heart_rate: Optional[int] = None
|
||||
steps: Optional[int] = None
|
||||
elevation_gained: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class HealthConnectSyncRequest(BaseModel):
|
||||
"""Full sync request from Android companion app."""
|
||||
device_id: str
|
||||
sync_timestamp: str = "" # Added for Android compatibility
|
||||
daily_metrics: List[DailyMetricsPayload] = []
|
||||
exercise_sessions: List[ExerciseSessionPayload] = []
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
"""Response for sync operation."""
|
||||
status: str
|
||||
sync_id: int
|
||||
metrics_synced: int # Renamed from metrics_processed to match Android
|
||||
exercises_synced: int # Renamed from sessions_processed to match Android
|
||||
message: str
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/sync", response_model=SyncResponse, status_code=status.HTTP_200_OK)
|
||||
async def receive_health_connect_data(
|
||||
request: HealthConnectSyncRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Receive health data pushed from Android companion app.
|
||||
|
||||
This endpoint accepts data from Health Connect and stores it
|
||||
for later retrieval by the HealthConnectProvider.
|
||||
"""
|
||||
logger.info(f"=== Health Connect Sync Request from user {current_user.username} ===")
|
||||
logger.info(f"Device ID: {request.device_id}")
|
||||
logger.info(f"Timestamp: {request.sync_timestamp}")
|
||||
logger.info(f"Daily metrics count: {len(request.daily_metrics)}")
|
||||
logger.info(f"Exercise sessions count: {len(request.exercise_sessions)}")
|
||||
|
||||
# Log sample data for debugging
|
||||
if request.daily_metrics:
|
||||
sample = request.daily_metrics[0]
|
||||
logger.info(f"Sample metric - Date: {sample.date}, Steps: {sample.step_count}, Calories: {sample.calories_burned}")
|
||||
|
||||
if request.exercise_sessions:
|
||||
sample = request.exercise_sessions[0]
|
||||
logger.info(f"Sample exercise - Type: {sample.exercise_type_name}, Duration: {sample.duration_seconds}s")
|
||||
|
||||
# Create sync log entry
|
||||
sync_log = HealthConnectSyncLog(
|
||||
user_id=current_user.id,
|
||||
device_id=request.device_id,
|
||||
records_pushed=len(request.daily_metrics) + len(request.exercise_sessions),
|
||||
status="processing"
|
||||
)
|
||||
db.add(sync_log)
|
||||
db.flush() # Get the sync_log.id
|
||||
|
||||
metrics_synced = 0
|
||||
exercises_synced = 0
|
||||
|
||||
try:
|
||||
# Process daily metrics
|
||||
for metrics in request.daily_metrics:
|
||||
# Upsert: update if exists, insert if not
|
||||
existing = db.query(HealthConnectDailyMetrics).filter(
|
||||
HealthConnectDailyMetrics.user_id == current_user.id,
|
||||
HealthConnectDailyMetrics.date == metrics.date
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing record
|
||||
for field, value in metrics.model_dump().items():
|
||||
if value is not None:
|
||||
setattr(existing, field, value)
|
||||
existing.last_synced = datetime.utcnow()
|
||||
existing.sync_id = sync_log.id
|
||||
logger.info(f"Updated existing metric for date {metrics.date}")
|
||||
else:
|
||||
# Create new record
|
||||
new_metrics = HealthConnectDailyMetrics(
|
||||
user_id=current_user.id,
|
||||
sync_id=sync_log.id,
|
||||
**metrics.model_dump()
|
||||
)
|
||||
db.add(new_metrics)
|
||||
logger.info(f"Created new metric for date {metrics.date}")
|
||||
|
||||
metrics_synced += 1
|
||||
|
||||
# --- AUTO-SYNC to Main DailyHealthMetrics Table ---
|
||||
try:
|
||||
main_existing = db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date == metrics.date
|
||||
).first()
|
||||
|
||||
# Filter fields that exist in DailyHealthMetrics
|
||||
metric_data = metrics.model_dump()
|
||||
|
||||
# Manual Mapping / Cleanup
|
||||
# DailyMetricsPayload has snake_case keys which match DailyHealthMetrics mostly
|
||||
# Exceptions:
|
||||
# - DailyHealthMetrics has no 'data_source_name'
|
||||
# - DailyHealthMetrics has 'data_source' (string)
|
||||
|
||||
update_data = {}
|
||||
for key, value in metric_data.items():
|
||||
if value is not None and hasattr(DailyHealthMetrics, key):
|
||||
update_data[key] = value
|
||||
|
||||
# Explicitly set data source
|
||||
update_data["data_source"] = "health_connect"
|
||||
|
||||
if main_existing:
|
||||
# Update if value is not None (and maybe preferential logic?)
|
||||
# For now, we trust the push as latest source of truth
|
||||
for key, value in update_data.items():
|
||||
setattr(main_existing, key, value)
|
||||
logger.info(f"Auto-synced main health metric for {metrics.date}")
|
||||
else:
|
||||
new_main = DailyHealthMetrics(
|
||||
user_id=current_user.id,
|
||||
**update_data
|
||||
)
|
||||
db.add(new_main)
|
||||
logger.info(f"Created main health metric for {metrics.date}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-sync daily metric to main table: {e}")
|
||||
# Don't fail the request, just log
|
||||
|
||||
|
||||
# Process exercise sessions
|
||||
for session in request.exercise_sessions:
|
||||
# Dedup by start_time
|
||||
existing = db.query(HealthConnectExerciseSession).filter(
|
||||
HealthConnectExerciseSession.user_id == current_user.id,
|
||||
HealthConnectExerciseSession.start_time == session.start_time
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
# Get exercise type name if not provided
|
||||
exercise_name = session.exercise_type_name or HEALTH_CONNECT_EXERCISE_TYPES.get(
|
||||
session.exercise_type, f"Exercise {session.exercise_type}"
|
||||
)
|
||||
|
||||
new_session = HealthConnectExerciseSession(
|
||||
user_id=current_user.id,
|
||||
sync_id=sync_log.id,
|
||||
exercise_type_name=exercise_name,
|
||||
**session.model_dump(exclude={"exercise_type_name"})
|
||||
)
|
||||
db.add(new_session)
|
||||
exercises_synced += 1
|
||||
logger.info(f"Created new exercise session: {exercise_name}")
|
||||
|
||||
# --- AUTO-SYNC to Main WorkoutSession Table ---
|
||||
try:
|
||||
# Check existence in main table (simple check by start time)
|
||||
main_workout_exists = db.query(WorkoutSession).filter(
|
||||
WorkoutSession.user_id == current_user.id,
|
||||
WorkoutSession.start_time == session.start_time
|
||||
).first()
|
||||
|
||||
if not main_workout_exists:
|
||||
# key mapping
|
||||
sess_data = session.model_dump(exclude={"exercise_type_name"})
|
||||
|
||||
# WorkoutSession fields:
|
||||
# start_time, end_time, activity_type (str), activity_type_id (int),
|
||||
# duration_seconds, distance_meters, calories (not calories_burned),
|
||||
# avg_hr, max_hr, min_hr, notes
|
||||
|
||||
new_workout = WorkoutSession(
|
||||
user_id=current_user.id,
|
||||
start_time=session.start_time,
|
||||
end_time=session.end_time,
|
||||
activity_type=exercise_name,
|
||||
activity_type_id=session.exercise_type,
|
||||
duration_seconds=session.duration_seconds,
|
||||
distance_meters=session.distance_meters or 0,
|
||||
calories=session.calories_burned or 0,
|
||||
avg_hr=session.avg_heart_rate,
|
||||
max_hr=session.max_heart_rate,
|
||||
min_hr=session.min_heart_rate,
|
||||
notes=session.notes,
|
||||
data_source="health_connect"
|
||||
)
|
||||
db.add(new_workout)
|
||||
logger.info(f"Auto-synced workout session: {exercise_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-sync workout to main table: {e}")
|
||||
|
||||
else:
|
||||
logger.info(f"Skipped duplicate exercise session at {session.start_time}")
|
||||
|
||||
# Update sync log
|
||||
sync_log.status = "success"
|
||||
sync_log.records_pushed = metrics_synced + exercises_synced
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"=== Sync Complete: {metrics_synced} metrics, {exercises_synced} exercises ===")
|
||||
|
||||
return SyncResponse(
|
||||
status="success",
|
||||
sync_id=sync_log.id,
|
||||
metrics_synced=metrics_synced,
|
||||
exercises_synced=exercises_synced,
|
||||
message=f"Successfully synced {metrics_synced} daily records and {exercises_synced} exercise sessions"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed: {str(e)}", exc_info=True)
|
||||
sync_log.status = "error"
|
||||
sync_log.error_message = str(e)
|
||||
db.commit()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Sync failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", status_code=status.HTTP_200_OK)
|
||||
async def get_sync_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get the status of Health Connect sync for current user."""
|
||||
# Get latest sync
|
||||
latest_sync = db.query(HealthConnectSyncLog).filter(
|
||||
HealthConnectSyncLog.user_id == current_user.id
|
||||
).order_by(HealthConnectSyncLog.sync_timestamp.desc()).first()
|
||||
|
||||
# Count records
|
||||
metrics_count = db.query(HealthConnectDailyMetrics).filter(
|
||||
HealthConnectDailyMetrics.user_id == current_user.id
|
||||
).count()
|
||||
|
||||
sessions_count = db.query(HealthConnectExerciseSession).filter(
|
||||
HealthConnectExerciseSession.user_id == current_user.id
|
||||
).count()
|
||||
|
||||
return {
|
||||
"has_data": metrics_count > 0 or sessions_count > 0,
|
||||
"metrics_count": metrics_count,
|
||||
"sessions_count": sessions_count,
|
||||
"last_sync": latest_sync.sync_timestamp if latest_sync else None,
|
||||
"last_sync_status": latest_sync.status if latest_sync else None,
|
||||
"last_device_id": latest_sync.device_id if latest_sync else None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/metrics", status_code=status.HTTP_200_OK)
|
||||
async def get_health_connect_metrics(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get Health Connect daily metrics for current user."""
|
||||
from datetime import timedelta
|
||||
|
||||
start_date = date.today() - timedelta(days=days)
|
||||
|
||||
metrics = db.query(HealthConnectDailyMetrics).filter(
|
||||
HealthConnectDailyMetrics.user_id == current_user.id,
|
||||
HealthConnectDailyMetrics.date >= start_date
|
||||
).order_by(HealthConnectDailyMetrics.date.desc()).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": m.date.isoformat(),
|
||||
"step_count": m.step_count,
|
||||
"distance_meters": m.distance_meters,
|
||||
"calories_burned": m.calories_burned,
|
||||
"sleep_duration_minutes": m.sleep_duration_minutes,
|
||||
"deep_sleep_minutes": m.deep_sleep_minutes,
|
||||
"light_sleep_minutes": m.light_sleep_minutes,
|
||||
"rem_sleep_minutes": m.rem_sleep_minutes,
|
||||
"avg_heart_rate": m.avg_heart_rate,
|
||||
"avg_spo2": m.avg_spo2,
|
||||
"weight": m.weight,
|
||||
"data_source": m.data_source,
|
||||
"data_source_name": m.data_source_name
|
||||
}
|
||||
for m in metrics
|
||||
]
|
||||
@@ -0,0 +1,734 @@
|
||||
"""
|
||||
Health Connect Raw Data API - Endpoints for receiving raw health records from Android.
|
||||
|
||||
This endpoint receives individual health records (not aggregated) from the Android
|
||||
companion app and stores them for accurate on-demand aggregation.
|
||||
|
||||
OPTIMIZED VERSION: Uses batch inserts and efficient duplicate detection.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, tuple_
|
||||
from typing import List, Optional, Set, Tuple
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models.user import User
|
||||
from backend.models.health_connect import HealthConnectSyncLog
|
||||
from backend.models.health_connect_raw import (
|
||||
HCRawSteps,
|
||||
HCRawHeartRate,
|
||||
HCRawHeartRateSample,
|
||||
HCRawRestingHeartRate,
|
||||
HCRawSleepSession,
|
||||
HCRawSleepStage,
|
||||
HCRawDistance,
|
||||
HCRawCalories,
|
||||
HCRawOxygenSaturation,
|
||||
HCRawWeight,
|
||||
HCRawHeight,
|
||||
HCRawBodyFat,
|
||||
HCRawExerciseSession,
|
||||
HCRawNutrition,
|
||||
HCRawHydration,
|
||||
SLEEP_STAGE_TYPES,
|
||||
)
|
||||
from backend import auth
|
||||
|
||||
router = APIRouter(prefix="/api/health-connect", tags=["health-connect-raw"])
|
||||
|
||||
|
||||
# ==================== REQUEST MODELS ====================
|
||||
|
||||
class RawStepsPayload(BaseModel):
|
||||
"""Individual step record from Health Connect"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
count: int
|
||||
|
||||
|
||||
class HeartRateSamplePayload(BaseModel):
|
||||
"""Single heart rate sample"""
|
||||
time: datetime
|
||||
bpm: int
|
||||
|
||||
|
||||
class RawHeartRatePayload(BaseModel):
|
||||
"""Heart rate record with samples"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
samples: List[HeartRateSamplePayload] = []
|
||||
|
||||
|
||||
class RawRestingHeartRatePayload(BaseModel):
|
||||
"""Resting heart rate record"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
time: datetime
|
||||
bpm: int
|
||||
|
||||
|
||||
class SleepStagePayload(BaseModel):
|
||||
"""Sleep stage within a session"""
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
stage_type: int
|
||||
stage_name: Optional[str] = None
|
||||
|
||||
|
||||
class RawSleepSessionPayload(BaseModel):
|
||||
"""Sleep session with stages"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
title: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
stages: List[SleepStagePayload] = []
|
||||
|
||||
|
||||
class RawDistancePayload(BaseModel):
|
||||
"""Distance record"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
distance_meters: float
|
||||
|
||||
|
||||
class RawCaloriesPayload(BaseModel):
|
||||
"""Calories record (active or total)"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
calories: float
|
||||
record_type: str # "active" or "total"
|
||||
|
||||
|
||||
class RawOxygenSaturationPayload(BaseModel):
|
||||
"""SpO2 record"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
time: datetime
|
||||
percentage: float
|
||||
|
||||
|
||||
class RawWeightPayload(BaseModel):
|
||||
"""Weight record"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
time: datetime
|
||||
weight_kg: float
|
||||
|
||||
|
||||
class RawHeightPayload(BaseModel):
|
||||
"""Height record"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
time: datetime
|
||||
height_meters: float
|
||||
|
||||
|
||||
class RawBodyFatPayload(BaseModel):
|
||||
"""Body fat record"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
time: datetime
|
||||
percentage: float
|
||||
|
||||
|
||||
class RawExerciseSessionPayload(BaseModel):
|
||||
"""Exercise session"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
exercise_type: int
|
||||
exercise_type_name: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
distance_meters: Optional[float] = None
|
||||
calories: Optional[float] = None
|
||||
avg_heart_rate: Optional[int] = None
|
||||
max_heart_rate: Optional[int] = None
|
||||
min_heart_rate: Optional[int] = None
|
||||
steps: Optional[int] = None
|
||||
elevation_gained_meters: Optional[float] = None
|
||||
|
||||
|
||||
class RawNutritionPayload(BaseModel):
|
||||
"""Nutrition record"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
name: Optional[str] = None
|
||||
meal_type: Optional[int] = None
|
||||
calories: Optional[float] = None
|
||||
protein_grams: Optional[float] = None
|
||||
carbohydrates_grams: Optional[float] = None
|
||||
fat_grams: Optional[float] = None
|
||||
fiber_grams: Optional[float] = None
|
||||
sugar_grams: Optional[float] = None
|
||||
|
||||
|
||||
class RawHydrationPayload(BaseModel):
|
||||
"""Hydration record"""
|
||||
record_id: str
|
||||
data_source: str
|
||||
data_source_name: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
volume_liters: float
|
||||
|
||||
|
||||
class RawDataSyncRequest(BaseModel):
|
||||
"""Complete raw data sync request from Android app"""
|
||||
device_id: str
|
||||
sync_timestamp: str = ""
|
||||
|
||||
# All record types
|
||||
steps_records: List[RawStepsPayload] = []
|
||||
heart_rate_records: List[RawHeartRatePayload] = []
|
||||
resting_hr_records: List[RawRestingHeartRatePayload] = []
|
||||
sleep_sessions: List[RawSleepSessionPayload] = []
|
||||
distance_records: List[RawDistancePayload] = []
|
||||
calories_records: List[RawCaloriesPayload] = []
|
||||
spo2_records: List[RawOxygenSaturationPayload] = []
|
||||
weight_records: List[RawWeightPayload] = []
|
||||
height_records: List[RawHeightPayload] = []
|
||||
body_fat_records: List[RawBodyFatPayload] = []
|
||||
exercise_sessions: List[RawExerciseSessionPayload] = []
|
||||
nutrition_records: List[RawNutritionPayload] = []
|
||||
hydration_records: List[RawHydrationPayload] = []
|
||||
|
||||
|
||||
class RawSyncResponse(BaseModel):
|
||||
"""Response for raw data sync"""
|
||||
status: str
|
||||
sync_id: int
|
||||
records_synced: dict # Count per record type
|
||||
message: str
|
||||
|
||||
|
||||
# ==================== OPTIMIZED HELPER FUNCTIONS ====================
|
||||
|
||||
def get_existing_record_ids(db: Session, model, user_id: int, record_ids: List[str]) -> Set[str]:
|
||||
"""
|
||||
Batch query to find which record_ids already exist for the user.
|
||||
Returns a set of existing record_ids.
|
||||
"""
|
||||
if not record_ids:
|
||||
return set()
|
||||
|
||||
# Process in batches to avoid query limits
|
||||
batch_size = 500
|
||||
existing_ids = set()
|
||||
|
||||
for i in range(0, len(record_ids), batch_size):
|
||||
batch = record_ids[i:i + batch_size]
|
||||
results = db.query(model.record_id).filter(
|
||||
model.user_id == user_id,
|
||||
model.record_id.in_(batch)
|
||||
).all()
|
||||
existing_ids.update(r[0] for r in results)
|
||||
|
||||
return existing_ids
|
||||
|
||||
|
||||
def batch_insert_simple_records(db: Session, model, user_id: int, records: list,
|
||||
sync_id: int, existing_ids: Set[str]) -> int:
|
||||
"""
|
||||
Batch insert records, skipping those that already exist.
|
||||
Also deduplicates records within the payload itself.
|
||||
Returns count of new records inserted.
|
||||
"""
|
||||
new_records = []
|
||||
now = datetime.utcnow()
|
||||
seen_ids = set() # Track record_ids we've already processed in this batch
|
||||
|
||||
for record in records:
|
||||
# Skip if already exists in database
|
||||
if record.record_id in existing_ids:
|
||||
continue
|
||||
# Skip if we've already seen this record_id in this payload (dedupe)
|
||||
if record.record_id in seen_ids:
|
||||
continue
|
||||
|
||||
seen_ids.add(record.record_id)
|
||||
|
||||
data = record.model_dump()
|
||||
data['user_id'] = user_id
|
||||
data['sync_id'] = sync_id
|
||||
data['synced_at'] = now
|
||||
new_records.append(model(**data))
|
||||
|
||||
if new_records:
|
||||
db.bulk_save_objects(new_records)
|
||||
|
||||
return len(new_records)
|
||||
|
||||
|
||||
# ==================== ENDPOINTS ====================
|
||||
|
||||
@router.post("/sync-raw", response_model=RawSyncResponse, status_code=status.HTTP_200_OK)
|
||||
async def receive_raw_health_data(
|
||||
request: RawDataSyncRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Receive raw health records from Android companion app.
|
||||
|
||||
OPTIMIZED: Uses batch queries and bulk inserts for much better performance.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Count total records (excluding HR samples for now)
|
||||
total_records = (
|
||||
len(request.steps_records) +
|
||||
len(request.heart_rate_records) +
|
||||
len(request.resting_hr_records) +
|
||||
len(request.sleep_sessions) +
|
||||
len(request.distance_records) +
|
||||
len(request.calories_records) +
|
||||
len(request.spo2_records) +
|
||||
len(request.weight_records) +
|
||||
len(request.height_records) +
|
||||
len(request.body_fat_records) +
|
||||
len(request.exercise_sessions) +
|
||||
len(request.nutrition_records) +
|
||||
len(request.hydration_records)
|
||||
)
|
||||
|
||||
# Count HR samples
|
||||
total_hr_samples = sum(len(hr.samples) for hr in request.heart_rate_records)
|
||||
|
||||
logger.info(f"=== Raw Data Sync from user {current_user.username} ===")
|
||||
logger.info(f"Device: {request.device_id}, Records: {total_records}, HR Samples: {total_hr_samples}")
|
||||
|
||||
# Create sync log
|
||||
sync_log = HealthConnectSyncLog(
|
||||
user_id=current_user.id,
|
||||
device_id=request.device_id,
|
||||
records_pushed=total_records,
|
||||
status="processing"
|
||||
)
|
||||
db.add(sync_log)
|
||||
db.flush()
|
||||
|
||||
# Track counts
|
||||
counts = {
|
||||
"steps": 0,
|
||||
"heart_rate": 0,
|
||||
"heart_rate_samples": 0,
|
||||
"resting_hr": 0,
|
||||
"sleep_sessions": 0,
|
||||
"sleep_stages": 0,
|
||||
"distance": 0,
|
||||
"calories": 0,
|
||||
"spo2": 0,
|
||||
"weight": 0,
|
||||
"height": 0,
|
||||
"body_fat": 0,
|
||||
"exercise": 0,
|
||||
"nutrition": 0,
|
||||
"hydration": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
|
||||
# === STEPS ===
|
||||
if request.steps_records:
|
||||
step_ids = [r.record_id for r in request.steps_records]
|
||||
existing_step_ids = get_existing_record_ids(db, HCRawSteps, current_user.id, step_ids)
|
||||
counts["steps"] = batch_insert_simple_records(
|
||||
db, HCRawSteps, current_user.id, request.steps_records,
|
||||
sync_log.id, existing_step_ids
|
||||
)
|
||||
logger.info(f"Steps: {counts['steps']} new of {len(request.steps_records)} total")
|
||||
|
||||
# === DISTANCE ===
|
||||
if request.distance_records:
|
||||
dist_ids = [r.record_id for r in request.distance_records]
|
||||
existing_dist_ids = get_existing_record_ids(db, HCRawDistance, current_user.id, dist_ids)
|
||||
counts["distance"] = batch_insert_simple_records(
|
||||
db, HCRawDistance, current_user.id, request.distance_records,
|
||||
sync_log.id, existing_dist_ids
|
||||
)
|
||||
logger.info(f"Distance: {counts['distance']} new of {len(request.distance_records)} total")
|
||||
|
||||
# === CALORIES ===
|
||||
if request.calories_records:
|
||||
cal_ids = [r.record_id for r in request.calories_records]
|
||||
existing_cal_ids = get_existing_record_ids(db, HCRawCalories, current_user.id, cal_ids)
|
||||
counts["calories"] = batch_insert_simple_records(
|
||||
db, HCRawCalories, current_user.id, request.calories_records,
|
||||
sync_log.id, existing_cal_ids
|
||||
)
|
||||
logger.info(f"Calories: {counts['calories']} new of {len(request.calories_records)} total")
|
||||
|
||||
# === RESTING HR ===
|
||||
if request.resting_hr_records:
|
||||
rhr_ids = [r.record_id for r in request.resting_hr_records]
|
||||
existing_rhr_ids = get_existing_record_ids(db, HCRawRestingHeartRate, current_user.id, rhr_ids)
|
||||
counts["resting_hr"] = batch_insert_simple_records(
|
||||
db, HCRawRestingHeartRate, current_user.id, request.resting_hr_records,
|
||||
sync_log.id, existing_rhr_ids
|
||||
)
|
||||
logger.info(f"Resting HR: {counts['resting_hr']} new of {len(request.resting_hr_records)} total")
|
||||
|
||||
# === SpO2 ===
|
||||
if request.spo2_records:
|
||||
spo2_ids = [r.record_id for r in request.spo2_records]
|
||||
existing_spo2_ids = get_existing_record_ids(db, HCRawOxygenSaturation, current_user.id, spo2_ids)
|
||||
counts["spo2"] = batch_insert_simple_records(
|
||||
db, HCRawOxygenSaturation, current_user.id, request.spo2_records,
|
||||
sync_log.id, existing_spo2_ids
|
||||
)
|
||||
logger.info(f"SpO2: {counts['spo2']} new of {len(request.spo2_records)} total")
|
||||
|
||||
# === WEIGHT ===
|
||||
if request.weight_records:
|
||||
weight_ids = [r.record_id for r in request.weight_records]
|
||||
existing_weight_ids = get_existing_record_ids(db, HCRawWeight, current_user.id, weight_ids)
|
||||
counts["weight"] = batch_insert_simple_records(
|
||||
db, HCRawWeight, current_user.id, request.weight_records,
|
||||
sync_log.id, existing_weight_ids
|
||||
)
|
||||
logger.info(f"Weight: {counts['weight']} new of {len(request.weight_records)} total")
|
||||
|
||||
# === HEIGHT ===
|
||||
if request.height_records:
|
||||
height_ids = [r.record_id for r in request.height_records]
|
||||
existing_height_ids = get_existing_record_ids(db, HCRawHeight, current_user.id, height_ids)
|
||||
counts["height"] = batch_insert_simple_records(
|
||||
db, HCRawHeight, current_user.id, request.height_records,
|
||||
sync_log.id, existing_height_ids
|
||||
)
|
||||
logger.info(f"Height: {counts['height']} new of {len(request.height_records)} total")
|
||||
|
||||
# === BODY FAT ===
|
||||
if request.body_fat_records:
|
||||
bf_ids = [r.record_id for r in request.body_fat_records]
|
||||
existing_bf_ids = get_existing_record_ids(db, HCRawBodyFat, current_user.id, bf_ids)
|
||||
counts["body_fat"] = batch_insert_simple_records(
|
||||
db, HCRawBodyFat, current_user.id, request.body_fat_records,
|
||||
sync_log.id, existing_bf_ids
|
||||
)
|
||||
logger.info(f"Body Fat: {counts['body_fat']} new of {len(request.body_fat_records)} total")
|
||||
|
||||
# === NUTRITION ===
|
||||
if request.nutrition_records:
|
||||
nut_ids = [r.record_id for r in request.nutrition_records]
|
||||
existing_nut_ids = get_existing_record_ids(db, HCRawNutrition, current_user.id, nut_ids)
|
||||
counts["nutrition"] = batch_insert_simple_records(
|
||||
db, HCRawNutrition, current_user.id, request.nutrition_records,
|
||||
sync_log.id, existing_nut_ids
|
||||
)
|
||||
logger.info(f"Nutrition: {counts['nutrition']} new of {len(request.nutrition_records)} total")
|
||||
|
||||
# === HYDRATION ===
|
||||
if request.hydration_records:
|
||||
hyd_ids = [r.record_id for r in request.hydration_records]
|
||||
existing_hyd_ids = get_existing_record_ids(db, HCRawHydration, current_user.id, hyd_ids)
|
||||
counts["hydration"] = batch_insert_simple_records(
|
||||
db, HCRawHydration, current_user.id, request.hydration_records,
|
||||
sync_log.id, existing_hyd_ids
|
||||
)
|
||||
logger.info(f"Hydration: {counts['hydration']} new of {len(request.hydration_records)} total")
|
||||
|
||||
# === EXERCISE SESSIONS ===
|
||||
if request.exercise_sessions:
|
||||
ex_ids = [r.record_id for r in request.exercise_sessions]
|
||||
existing_ex_ids = get_existing_record_ids(db, HCRawExerciseSession, current_user.id, ex_ids)
|
||||
counts["exercise"] = batch_insert_simple_records(
|
||||
db, HCRawExerciseSession, current_user.id, request.exercise_sessions,
|
||||
sync_log.id, existing_ex_ids
|
||||
)
|
||||
logger.info(f"Exercise: {counts['exercise']} new of {len(request.exercise_sessions)} total")
|
||||
|
||||
# === HEART RATE (with samples - needs special handling) ===
|
||||
if request.heart_rate_records:
|
||||
hr_ids = [r.record_id for r in request.heart_rate_records]
|
||||
existing_hr_ids = get_existing_record_ids(db, HCRawHeartRate, current_user.id, hr_ids)
|
||||
|
||||
# Insert new HR records
|
||||
new_hr_records = []
|
||||
hr_records_for_samples = [] # Track which records need samples
|
||||
|
||||
for record in request.heart_rate_records:
|
||||
if record.record_id in existing_hr_ids:
|
||||
continue
|
||||
|
||||
data = record.model_dump(exclude={"samples"})
|
||||
data['user_id'] = current_user.id
|
||||
data['sync_id'] = sync_log.id
|
||||
data['synced_at'] = now
|
||||
hr_obj = HCRawHeartRate(**data)
|
||||
new_hr_records.append(hr_obj)
|
||||
hr_records_for_samples.append((hr_obj, record.samples))
|
||||
|
||||
if new_hr_records:
|
||||
db.bulk_save_objects(new_hr_records, return_defaults=True)
|
||||
db.flush() # Get IDs
|
||||
counts["heart_rate"] = len(new_hr_records)
|
||||
|
||||
# Now insert samples in batches
|
||||
all_samples = []
|
||||
for hr_obj, samples in hr_records_for_samples:
|
||||
for sample in samples:
|
||||
all_samples.append(HCRawHeartRateSample(
|
||||
record_id=hr_obj.id,
|
||||
time=sample.time,
|
||||
bpm=sample.bpm
|
||||
))
|
||||
|
||||
if all_samples:
|
||||
# Insert in batches of 1000
|
||||
batch_size = 1000
|
||||
for i in range(0, len(all_samples), batch_size):
|
||||
batch = all_samples[i:i + batch_size]
|
||||
db.bulk_save_objects(batch)
|
||||
counts["heart_rate_samples"] = len(all_samples)
|
||||
|
||||
logger.info(f"Heart Rate: {counts['heart_rate']} records, {counts['heart_rate_samples']} samples")
|
||||
|
||||
# === SLEEP SESSIONS (with stages) ===
|
||||
if request.sleep_sessions:
|
||||
sleep_ids = [r.record_id for r in request.sleep_sessions]
|
||||
existing_sleep_ids = get_existing_record_ids(db, HCRawSleepSession, current_user.id, sleep_ids)
|
||||
|
||||
new_sleep_records = []
|
||||
sleep_records_for_stages = []
|
||||
|
||||
for record in request.sleep_sessions:
|
||||
if record.record_id in existing_sleep_ids:
|
||||
continue
|
||||
|
||||
data = record.model_dump(exclude={"stages"})
|
||||
data['user_id'] = current_user.id
|
||||
data['sync_id'] = sync_log.id
|
||||
data['synced_at'] = now
|
||||
sleep_obj = HCRawSleepSession(**data)
|
||||
new_sleep_records.append(sleep_obj)
|
||||
sleep_records_for_stages.append((sleep_obj, record.stages))
|
||||
|
||||
if new_sleep_records:
|
||||
db.bulk_save_objects(new_sleep_records, return_defaults=True)
|
||||
db.flush()
|
||||
counts["sleep_sessions"] = len(new_sleep_records)
|
||||
|
||||
# Insert stages
|
||||
all_stages = []
|
||||
for sleep_obj, stages in sleep_records_for_stages:
|
||||
for stage in stages:
|
||||
stage_name = stage.stage_name or SLEEP_STAGE_TYPES.get(stage.stage_type, "unknown")
|
||||
all_stages.append(HCRawSleepStage(
|
||||
session_id=sleep_obj.id,
|
||||
start_time=stage.start_time,
|
||||
end_time=stage.end_time,
|
||||
stage_type=stage.stage_type,
|
||||
stage_name=stage_name
|
||||
))
|
||||
|
||||
if all_stages:
|
||||
db.bulk_save_objects(all_stages)
|
||||
counts["sleep_stages"] = len(all_stages)
|
||||
|
||||
logger.info(f"Sleep: {counts['sleep_sessions']} sessions, {counts['sleep_stages']} stages")
|
||||
|
||||
# Update sync log
|
||||
sync_log.status = "success"
|
||||
sync_log.records_pushed = sum(counts.values())
|
||||
|
||||
db.commit()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"=== Raw Sync Complete in {elapsed:.2f}s ===")
|
||||
logger.info(f"Records synced: {counts}")
|
||||
|
||||
return RawSyncResponse(
|
||||
status="success",
|
||||
sync_id=sync_log.id,
|
||||
records_synced=counts,
|
||||
message=f"Successfully synced {sum(counts.values())} raw records in {elapsed:.2f}s"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Raw sync failed: {str(e)}", exc_info=True)
|
||||
db.rollback()
|
||||
sync_log.status = "error"
|
||||
sync_log.error_message = str(e)[:500] # Limit error message length
|
||||
db.commit()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Raw sync failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/raw/steps", status_code=status.HTTP_200_OK)
|
||||
async def get_raw_steps(
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: int = 1000,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get raw step records for a date range."""
|
||||
from datetime import timedelta
|
||||
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
if not start_date:
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
records = db.query(HCRawSteps).filter(
|
||||
HCRawSteps.user_id == current_user.id,
|
||||
HCRawSteps.start_time >= start_date,
|
||||
HCRawSteps.start_time <= end_date
|
||||
).order_by(HCRawSteps.start_time.desc()).limit(limit).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"record_id": r.record_id,
|
||||
"data_source": r.data_source,
|
||||
"data_source_name": r.data_source_name,
|
||||
"start_time": r.start_time.isoformat() if r.start_time else None,
|
||||
"end_time": r.end_time.isoformat() if r.end_time else None,
|
||||
"count": r.count
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
|
||||
|
||||
@router.get("/raw/heart-rate", status_code=status.HTTP_200_OK)
|
||||
async def get_raw_heart_rate(
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
include_samples: bool = False,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get raw heart rate records for a date range."""
|
||||
from datetime import timedelta
|
||||
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
if not start_date:
|
||||
start_date = end_date - timedelta(days=1)
|
||||
|
||||
records = db.query(HCRawHeartRate).filter(
|
||||
HCRawHeartRate.user_id == current_user.id,
|
||||
HCRawHeartRate.start_time >= start_date,
|
||||
HCRawHeartRate.start_time <= end_date
|
||||
).order_by(HCRawHeartRate.start_time.desc()).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for r in records:
|
||||
record_dict = {
|
||||
"id": r.id,
|
||||
"record_id": r.record_id,
|
||||
"data_source": r.data_source,
|
||||
"start_time": r.start_time.isoformat() if r.start_time else None,
|
||||
"end_time": r.end_time.isoformat() if r.end_time else None,
|
||||
}
|
||||
|
||||
if include_samples:
|
||||
record_dict["samples"] = [
|
||||
{"time": s.time.isoformat(), "bpm": s.bpm}
|
||||
for s in r.samples
|
||||
]
|
||||
else:
|
||||
record_dict["sample_count"] = len(r.samples)
|
||||
|
||||
result.append(record_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/raw/stats", status_code=status.HTTP_200_OK)
|
||||
async def get_raw_data_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get statistics about raw data stored for current user."""
|
||||
from sqlalchemy import func
|
||||
|
||||
stats = {
|
||||
"steps": db.query(func.count(HCRawSteps.id)).filter(
|
||||
HCRawSteps.user_id == current_user.id
|
||||
).scalar(),
|
||||
"heart_rate": db.query(func.count(HCRawHeartRate.id)).filter(
|
||||
HCRawHeartRate.user_id == current_user.id
|
||||
).scalar(),
|
||||
"heart_rate_samples": db.query(func.count(HCRawHeartRateSample.id)).join(
|
||||
HCRawHeartRate
|
||||
).filter(
|
||||
HCRawHeartRate.user_id == current_user.id
|
||||
).scalar(),
|
||||
"resting_hr": db.query(func.count(HCRawRestingHeartRate.id)).filter(
|
||||
HCRawRestingHeartRate.user_id == current_user.id
|
||||
).scalar(),
|
||||
"sleep_sessions": db.query(func.count(HCRawSleepSession.id)).filter(
|
||||
HCRawSleepSession.user_id == current_user.id
|
||||
).scalar(),
|
||||
"distance": db.query(func.count(HCRawDistance.id)).filter(
|
||||
HCRawDistance.user_id == current_user.id
|
||||
).scalar(),
|
||||
"calories": db.query(func.count(HCRawCalories.id)).filter(
|
||||
HCRawCalories.user_id == current_user.id
|
||||
).scalar(),
|
||||
"spo2": db.query(func.count(HCRawOxygenSaturation.id)).filter(
|
||||
HCRawOxygenSaturation.user_id == current_user.id
|
||||
).scalar(),
|
||||
"weight": db.query(func.count(HCRawWeight.id)).filter(
|
||||
HCRawWeight.user_id == current_user.id
|
||||
).scalar(),
|
||||
"exercise": db.query(func.count(HCRawExerciseSession.id)).filter(
|
||||
HCRawExerciseSession.user_id == current_user.id
|
||||
).scalar(),
|
||||
}
|
||||
|
||||
# Get date range
|
||||
oldest_step = db.query(func.min(HCRawSteps.start_time)).filter(
|
||||
HCRawSteps.user_id == current_user.id
|
||||
).scalar()
|
||||
newest_step = db.query(func.max(HCRawSteps.start_time)).filter(
|
||||
HCRawSteps.user_id == current_user.id
|
||||
).scalar()
|
||||
|
||||
return {
|
||||
"record_counts": stats,
|
||||
"total_records": sum(stats.values()),
|
||||
"date_range": {
|
||||
"oldest": oldest_step.isoformat() if oldest_step else None,
|
||||
"newest": newest_step.isoformat() if newest_step else None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from .core.config import settings
|
||||
from .database import get_db
|
||||
from .models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||
"""Get the current authenticated user from JWT token."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: Optional[str] = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -0,0 +1 @@
|
||||
"""Core module for configuration and security."""
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Configuration module using pydantic_settings for environment-based configuration."""
|
||||
from secrets import token_urlsafe
|
||||
from typing import List, Union
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "dev_secret_key_fixed_for_persistence_change_in_prod"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES: int = 43200 # 30 days
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "sqlite:///./data/habitforge.db"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: Union[List[str], str] = ["http://localhost:8000", "http://localhost:3000"]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: Union[List[str], str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: Union[List[str], str] = ["*"]
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/api/google/callback"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
RATE_LIMIT_TIMES: int = 60
|
||||
RATE_LIMIT_SECONDS: int = 60
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "HabitForge API"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Health Data Provider Selection
|
||||
# Options: "health_connect"
|
||||
HEALTH_PROVIDER: str = "health_connect"
|
||||
|
||||
@field_validator("CORS_ORIGINS", "CORS_ALLOW_METHODS", "CORS_ALLOW_HEADERS", mode="before")
|
||||
@classmethod
|
||||
def assemble_cors_list(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Convert a comma-separated string into a list of strings if necessary."""
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",") if i.strip()]
|
||||
return v
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Integrations module for external APIs."""
|
||||
from backend.integrations.health_provider import (
|
||||
HealthDataProvider,
|
||||
DataSource,
|
||||
HealthProviderFactory
|
||||
)
|
||||
|
||||
# Import providers to register them with the factory
|
||||
# from backend.integrations import google_fit_provider
|
||||
from backend.integrations import health_connect_provider
|
||||
|
||||
__all__ = [
|
||||
"HealthDataProvider",
|
||||
"DataSource",
|
||||
"HealthProviderFactory",
|
||||
]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Health Connect Provider - Implementation for Health Connect API.
|
||||
|
||||
This module provides the Health Connect implementation that reads data
|
||||
pushed from the Android companion app via the /api/health-connect/sync endpoint.
|
||||
"""
|
||||
import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from backend.integrations.health_provider import (
|
||||
HealthDataProvider,
|
||||
DataSource,
|
||||
HealthProviderFactory
|
||||
)
|
||||
|
||||
|
||||
class HealthConnectProvider(HealthDataProvider):
|
||||
"""
|
||||
Health Connect implementation of HealthDataProvider.
|
||||
|
||||
This provider reads data that has been pushed from an Android companion app
|
||||
via the /api/health-connect/sync endpoint and stored in the database.
|
||||
"""
|
||||
|
||||
@property
|
||||
def source(self) -> DataSource:
|
||||
return DataSource.HEALTH_CONNECT
|
||||
|
||||
def get_auth_url(self) -> str:
|
||||
"""
|
||||
Health Connect doesn't use OAuth - permissions are granted on-device.
|
||||
|
||||
Returns a URL to download the Android companion app.
|
||||
"""
|
||||
# TODO: Replace with actual Play Store URL when app is published
|
||||
return "https://play.google.com/store/apps/details?id=com.habitforge.companion"
|
||||
|
||||
async def fetch_daily_metrics(
|
||||
self,
|
||||
access_token: Optional[str], # Not used for Health Connect, but required by interface
|
||||
days: int = 7,
|
||||
timezone: str = "UTC",
|
||||
user_id: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch daily metrics from Health Connect data store.
|
||||
|
||||
Reads data that was pushed from the Android companion app.
|
||||
"""
|
||||
if not user_id:
|
||||
print("[HealthConnect] ERROR: user_id is required")
|
||||
return []
|
||||
|
||||
print(f"[HealthConnect] Fetching daily metrics for user {user_id}, last {days} days")
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models.health_connect import HealthConnectDailyMetrics
|
||||
from datetime import timedelta, date as date_type
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
start_date = date_type.today() - timedelta(days=days)
|
||||
|
||||
metrics = db.query(HealthConnectDailyMetrics).filter(
|
||||
HealthConnectDailyMetrics.user_id == user_id,
|
||||
HealthConnectDailyMetrics.date >= start_date
|
||||
).order_by(HealthConnectDailyMetrics.date.desc()).all()
|
||||
|
||||
if not metrics:
|
||||
print(f"[HealthConnect] No data found. Ensure Android app has synced data.")
|
||||
return []
|
||||
|
||||
print(f"[HealthConnect] Found {len(metrics)} days of data")
|
||||
|
||||
# Convert to standard format
|
||||
return [
|
||||
{
|
||||
"date": m.date,
|
||||
"step_count": m.step_count or 0,
|
||||
"calories_burned": m.calories_burned or 0.0,
|
||||
"distance_meters": m.distance_meters or 0.0,
|
||||
"sleep_duration_minutes": m.sleep_duration_minutes or 0,
|
||||
"deep_sleep_minutes": m.deep_sleep_minutes or 0,
|
||||
"light_sleep_minutes": m.light_sleep_minutes or 0,
|
||||
"rem_sleep_minutes": m.rem_sleep_minutes or 0,
|
||||
"awake_duration_minutes": m.awake_duration_minutes or 0,
|
||||
"avg_heart_rate": m.avg_heart_rate or 0,
|
||||
"min_heart_rate": m.min_heart_rate or 0,
|
||||
"max_heart_rate": m.max_heart_rate or 0,
|
||||
"avg_spo2": m.avg_spo2 or 0.0,
|
||||
"pai_score": 0.0, # Calculate if needed
|
||||
"weight": m.weight,
|
||||
"resting_heart_rate": m.resting_heart_rate,
|
||||
"hrv": None
|
||||
}
|
||||
for m in metrics
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def fetch_workouts(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch exercise sessions from Health Connect data store.
|
||||
"""
|
||||
# Note: We need user_id but the interface doesn't pass it for workouts
|
||||
# This is a limitation we'll need to address in the interface
|
||||
print(f"[HealthConnect] fetch_workouts called")
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models.health_connect import HealthConnectExerciseSession
|
||||
from datetime import timedelta
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
start_date = datetime.datetime.now() - timedelta(days=days)
|
||||
|
||||
# Query all sessions (we can't filter by user without user_id)
|
||||
# In practice, this would be called with user context
|
||||
sessions = db.query(HealthConnectExerciseSession).filter(
|
||||
HealthConnectExerciseSession.start_time >= start_date
|
||||
).order_by(HealthConnectExerciseSession.start_time.desc()).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"start_time": s.start_time,
|
||||
"end_time": s.end_time,
|
||||
"activity_type": s.exercise_type_name,
|
||||
"activity_type_id": s.exercise_type,
|
||||
"duration_seconds": s.duration_seconds,
|
||||
"distance_meters": s.distance_meters or 0.0,
|
||||
"calories": s.calories_burned or 0.0,
|
||||
"avg_hr": s.avg_heart_rate,
|
||||
"max_hr": s.max_heart_rate,
|
||||
"min_hr": s.min_heart_rate
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def fetch_sleep_sessions(
|
||||
self,
|
||||
access_token: Optional[str],
|
||||
days: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch sleep sessions from Health Connect data store.
|
||||
|
||||
Note: Health Connect stores sleep as part of daily metrics.
|
||||
This returns an empty list as sleep is handled in fetch_daily_metrics.
|
||||
"""
|
||||
print(f"[HealthConnect] fetch_sleep_sessions called")
|
||||
# Sleep data is included in daily metrics for Health Connect
|
||||
return []
|
||||
|
||||
|
||||
# Register the provider with the factory
|
||||
HealthProviderFactory.register("health_connect", HealthConnectProvider)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user