Initial commit: HabitForge - habit tracking application with FastAPI backend, React frontend, and Zepp integration
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
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Environment files
|
||||
.env
|
||||
.env.development
|
||||
.env.staging
|
||||
.env.production
|
||||
.env.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Database
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Secrets
|
||||
backend/client_secret*.json
|
||||
backend/credentials.json
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Node modules (if frontend uses npm)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
@@ -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.models import User, Challenge, Tracking, DailyHealthMetrics, WorkoutSession
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
# Override URL from settings
|
||||
from backend.core.config import settings
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = settings.DATABASE_URL
|
||||
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -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,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,107 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Internet permission for syncing with backend -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Foreground service permission for reliable syncing -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Health Connect Permissions -->
|
||||
<!-- Activity Data -->
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
|
||||
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
|
||||
|
||||
<!-- Heart & Vitals -->
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE_VARIABILITY" />
|
||||
<uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION" />
|
||||
<uses-permission android:name="android.permission.health.READ_RESPIRATORY_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_BLOOD_PRESSURE" />
|
||||
|
||||
<!-- Body Measurements -->
|
||||
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEIGHT" />
|
||||
<uses-permission android:name="android.permission.health.READ_BODY_FAT" />
|
||||
<uses-permission android:name="android.permission.health.READ_BODY_WATER_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_LEAN_BODY_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_BONE_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
|
||||
|
||||
<!-- Sleep -->
|
||||
<uses-permission android:name="android.permission.health.READ_SLEEP" />
|
||||
|
||||
<!-- Exercise / Workouts -->
|
||||
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
||||
<uses-permission android:name="android.permission.health.READ_POWER" />
|
||||
<uses-permission android:name="android.permission.health.READ_SPEED" />
|
||||
|
||||
<!-- Nutrition (optional) -->
|
||||
<uses-permission android:name="android.permission.health.READ_NUTRITION" />
|
||||
<uses-permission android:name="android.permission.health.READ_HYDRATION" />
|
||||
|
||||
<!-- Historical Data Access (for data older than 30 days) -->
|
||||
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY" />
|
||||
|
||||
<application
|
||||
android:name=".HabitForgeApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.HabitForgeCompanion"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.HabitForgeCompanion">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Receive Health Connect permission results -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Privacy Policy Activity (Required by Health Connect) -->
|
||||
<activity
|
||||
android:name=".PrivacyPolicyActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- WorkManager initializer -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
<!-- Declare that this app uses Health Connect -->
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Manages user authentication and session state.
|
||||
* Handles login, logout, and secure token storage.
|
||||
*/
|
||||
class AuthManager(private val context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "habitforge_auth"
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_USER_ID = "user_id"
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_IS_LOGGED_IN = "is_logged_in"
|
||||
|
||||
// Default server URL (can be changed)
|
||||
const val DEFAULT_SERVER_URL = "http://192.168.1.100:8000"
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently logged in
|
||||
*/
|
||||
fun isLoggedIn(): Boolean {
|
||||
return prefs.getBoolean(KEY_IS_LOGGED_IN, false) &&
|
||||
!prefs.getString(KEY_ACCESS_TOKEN, null).isNullOrEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored access token with Bearer prefix
|
||||
*/
|
||||
fun getAuthorizationHeader(): String? {
|
||||
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||
return if (token != null) "Bearer $token" else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw access token (without Bearer prefix)
|
||||
*/
|
||||
fun getAccessToken(): String? {
|
||||
return prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current username
|
||||
*/
|
||||
fun getUsername(): String? {
|
||||
return prefs.getString(KEY_USERNAME, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID
|
||||
*/
|
||||
fun getUserId(): Int {
|
||||
return prefs.getInt(KEY_USER_ID, -1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured server URL
|
||||
*/
|
||||
fun getServerUrl(): String {
|
||||
return prefs.getString(KEY_SERVER_URL, BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Set server URL
|
||||
*/
|
||||
fun setServerUrl(url: String) {
|
||||
prefs.edit().putString(KEY_SERVER_URL, url).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to login with username and password
|
||||
* @return LoginResult with success/failure and message
|
||||
*/
|
||||
suspend fun login(serverUrl: String, username: String, password: String): LoginResult {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Ensure URL has proper format
|
||||
val formattedUrl = formatServerUrl(serverUrl)
|
||||
|
||||
// Create API client
|
||||
val api = HabitForgeApi.create(formattedUrl)
|
||||
|
||||
// Attempt login
|
||||
val response = api.login(username, password)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val loginResponse = response.body()!!
|
||||
val token = loginResponse.access_token
|
||||
|
||||
// Verify token by getting user info
|
||||
val userResponse = api.getCurrentUser("Bearer $token")
|
||||
|
||||
if (userResponse.isSuccessful && userResponse.body() != null) {
|
||||
val user = userResponse.body()!!
|
||||
|
||||
// Save credentials
|
||||
saveSession(
|
||||
serverUrl = formattedUrl,
|
||||
token = token,
|
||||
username = user.username,
|
||||
userId = user.id
|
||||
)
|
||||
|
||||
LoginResult(
|
||||
success = true,
|
||||
message = "Welcome back, ${user.username}!",
|
||||
username = user.username
|
||||
)
|
||||
} else {
|
||||
LoginResult(
|
||||
success = false,
|
||||
message = "Login successful but failed to get user info"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
val errorMessage = when (response.code()) {
|
||||
401 -> "Invalid username or password"
|
||||
404 -> "Server not found. Check URL."
|
||||
500 -> "Server error. Please try again."
|
||||
else -> errorBody ?: "Login failed (${response.code()})"
|
||||
}
|
||||
LoginResult(success = false, message = errorMessage)
|
||||
}
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
LoginResult(success = false, message = "Cannot reach server. Check URL and network.")
|
||||
} catch (e: java.net.ConnectException) {
|
||||
LoginResult(success = false, message = "Connection refused. Is the server running?")
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
LoginResult(success = false, message = "Connection timeout. Server may be slow.")
|
||||
} catch (e: Exception) {
|
||||
LoginResult(success = false, message = "Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session data after successful login
|
||||
*/
|
||||
private fun saveSession(serverUrl: String, token: String, username: String, userId: Int) {
|
||||
prefs.edit()
|
||||
.putString(KEY_SERVER_URL, serverUrl)
|
||||
.putString(KEY_ACCESS_TOKEN, token)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.putInt(KEY_USER_ID, userId)
|
||||
.putBoolean(KEY_IS_LOGGED_IN, true)
|
||||
.apply()
|
||||
|
||||
// Also update the old prefs for backward compatibility with SyncService
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("server_url", serverUrl)
|
||||
.putString("auth_token", token)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session (logout)
|
||||
*/
|
||||
fun logout() {
|
||||
prefs.edit()
|
||||
.remove(KEY_ACCESS_TOKEN)
|
||||
.remove(KEY_USERNAME)
|
||||
.remove(KEY_USER_ID)
|
||||
.putBoolean(KEY_IS_LOGGED_IN, false)
|
||||
.apply()
|
||||
|
||||
// Clear old prefs too
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove("auth_token")
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token if needed (for future token refresh implementation)
|
||||
*/
|
||||
suspend fun refreshTokenIfNeeded(): Boolean {
|
||||
// Future: implement token refresh
|
||||
// For now, just verify current token is valid
|
||||
val token = getAuthorizationHeader() ?: return false
|
||||
val serverUrl = getServerUrl()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val api = HabitForgeApi.create(serverUrl)
|
||||
val response = api.getCurrentUser(token)
|
||||
response.isSuccessful
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format server URL to ensure it has proper protocol and no trailing slash
|
||||
*/
|
||||
private fun formatServerUrl(url: String): String {
|
||||
var formatted = url.trim()
|
||||
|
||||
// Add protocol if missing
|
||||
if (!formatted.startsWith("http://") && !formatted.startsWith("https://")) {
|
||||
formatted = "http://$formatted"
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
if (formatted.endsWith("/")) {
|
||||
formatted = formatted.dropLast(1)
|
||||
}
|
||||
|
||||
// Ensure ends with proper format for Retrofit baseUrl
|
||||
if (!formatted.endsWith("/")) {
|
||||
formatted = "$formatted/"
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a login attempt
|
||||
*/
|
||||
data class LoginResult(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val username: String? = null
|
||||
)
|
||||
+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,140 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Retrofit API interface for communicating with HabitForge backend
|
||||
*/
|
||||
interface HabitForgeApi {
|
||||
|
||||
/**
|
||||
* Login with username and password (OAuth2 form data)
|
||||
* Returns access token on success
|
||||
*/
|
||||
@FormUrlEncoded
|
||||
@POST("api/auth/token")
|
||||
suspend fun login(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String
|
||||
): Response<LoginResponse>
|
||||
|
||||
/**
|
||||
* Get current user information
|
||||
*/
|
||||
@GET("api/auth/me")
|
||||
suspend fun getCurrentUser(
|
||||
@Header("Authorization") token: String
|
||||
): Response<UserResponse>
|
||||
|
||||
@POST("api/health-connect/sync")
|
||||
suspend fun syncHealthData(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: HealthConnectSyncRequest
|
||||
): Response<SyncResponse>
|
||||
|
||||
@GET("api/health-connect/status")
|
||||
suspend fun getSyncStatus(
|
||||
@Header("Authorization") token: String
|
||||
): Response<SyncStatusResponse>
|
||||
|
||||
companion object {
|
||||
fun create(baseUrl: String): HabitForgeApi {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(HabitForgeApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== REQUEST/RESPONSE MODELS ====================
|
||||
|
||||
data class HealthConnectSyncRequest(
|
||||
val device_id: String,
|
||||
val sync_timestamp: String,
|
||||
val daily_metrics: List<DailyMetricsPayload>,
|
||||
val exercise_sessions: List<ExerciseSessionPayload>
|
||||
)
|
||||
|
||||
data class DailyMetricsPayload(
|
||||
val date: String,
|
||||
val step_count: Int,
|
||||
val distance_meters: Double,
|
||||
val calories_burned: Double,
|
||||
val total_calories: Double = 0.0,
|
||||
val avg_heart_rate: Int?,
|
||||
val min_heart_rate: Int?,
|
||||
val max_heart_rate: Int?,
|
||||
val resting_heart_rate: Int?,
|
||||
val sleep_duration_minutes: Int,
|
||||
val deep_sleep_minutes: Int,
|
||||
val light_sleep_minutes: Int,
|
||||
val rem_sleep_minutes: Int,
|
||||
val awake_duration_minutes: Int,
|
||||
val weight: Double?,
|
||||
val avg_spo2: Double?,
|
||||
val min_spo2: Double? = null,
|
||||
val max_spo2: Double? = null
|
||||
)
|
||||
|
||||
data class ExerciseSessionPayload(
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val exercise_type: Int,
|
||||
val exercise_type_name: String?,
|
||||
val duration_seconds: Int,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val calories_burned: Double? = null,
|
||||
val distance_meters: Double? = null,
|
||||
val avg_heart_rate: Int? = null,
|
||||
val max_heart_rate: Int? = null,
|
||||
val min_heart_rate: Int? = null,
|
||||
val steps: Int? = null,
|
||||
val elevation_gained: Double? = null
|
||||
)
|
||||
|
||||
data class SyncResponse(
|
||||
val status: String,
|
||||
val message: String,
|
||||
val sync_id: Int,
|
||||
val metrics_synced: Int,
|
||||
val exercises_synced: Int
|
||||
)
|
||||
|
||||
data class SyncStatusResponse(
|
||||
val has_data: Boolean,
|
||||
val last_sync: String?,
|
||||
val total_records: Int
|
||||
)
|
||||
|
||||
// ==================== AUTH MODELS ====================
|
||||
|
||||
data class LoginResponse(
|
||||
val access_token: String,
|
||||
val token_type: String
|
||||
)
|
||||
|
||||
data class UserResponse(
|
||||
val id: Int,
|
||||
val username: String
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.app.Application
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
|
||||
class HabitForgeApp : Application(), Configuration.Provider {
|
||||
|
||||
lateinit var healthConnectManager: HealthConnectManager
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
healthConnectManager = HealthConnectManager(this)
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
lateinit var instance: HabitForgeApp
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.*
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Health Connect Manager - Handles all interactions with Health Connect API
|
||||
* Implements: Permission requests and data reading for all required health types
|
||||
*/
|
||||
class HealthConnectManager(private val context: Context) {
|
||||
|
||||
private var healthConnectClient: HealthConnectClient? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HealthConnectManager"
|
||||
|
||||
// All permissions we need to read health data
|
||||
val PERMISSIONS = setOf(
|
||||
// Activity
|
||||
HealthPermission.getReadPermission(StepsRecord::class),
|
||||
HealthPermission.getReadPermission(DistanceRecord::class),
|
||||
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
|
||||
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
|
||||
|
||||
// Heart & Vitals
|
||||
HealthPermission.getReadPermission(HeartRateRecord::class),
|
||||
HealthPermission.getReadPermission(RestingHeartRateRecord::class),
|
||||
HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class),
|
||||
HealthPermission.getReadPermission(OxygenSaturationRecord::class),
|
||||
HealthPermission.getReadPermission(RespiratoryRateRecord::class),
|
||||
|
||||
// Body Measurements
|
||||
HealthPermission.getReadPermission(WeightRecord::class),
|
||||
HealthPermission.getReadPermission(HeightRecord::class),
|
||||
HealthPermission.getReadPermission(BodyFatRecord::class),
|
||||
HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
|
||||
|
||||
// Sleep
|
||||
HealthPermission.getReadPermission(SleepSessionRecord::class),
|
||||
|
||||
// Exercise
|
||||
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
||||
HealthPermission.getReadPermission(PowerRecord::class),
|
||||
HealthPermission.getReadPermission(SpeedRecord::class),
|
||||
|
||||
// Nutrition
|
||||
HealthPermission.getReadPermission(NutritionRecord::class),
|
||||
HealthPermission.getReadPermission(HydrationRecord::class),
|
||||
|
||||
// Historical data access (for data older than 30 days)
|
||||
"android.permission.health.READ_HEALTH_DATA_HISTORY"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Health Connect is available on this device
|
||||
*/
|
||||
fun isAvailable(): HealthConnectAvailability {
|
||||
return when (HealthConnectClient.getSdkStatus(context)) {
|
||||
HealthConnectClient.SDK_AVAILABLE -> {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(context)
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
}
|
||||
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
|
||||
HealthConnectAvailability.NOT_INSTALLED
|
||||
}
|
||||
else -> HealthConnectAvailability.NOT_SUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which permissions are currently granted
|
||||
*/
|
||||
suspend fun getGrantedPermissions(): Set<String> {
|
||||
val client = healthConnectClient ?: return emptySet()
|
||||
return client.permissionController.getGrantedPermissions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have all required permissions
|
||||
*/
|
||||
suspend fun hasAllPermissions(): Boolean {
|
||||
val granted = getGrantedPermissions()
|
||||
return PERMISSIONS.all { it in granted }
|
||||
}
|
||||
|
||||
// ==================== DATA READING METHODS ====================
|
||||
|
||||
/**
|
||||
* Read daily metrics for a specific date range
|
||||
*/
|
||||
/**
|
||||
* Read daily metrics for a specific date range efficiently
|
||||
*/
|
||||
suspend fun readDailyMetrics(startDate: LocalDate, endDate: LocalDate): List<DailyMetrics> = withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: run {
|
||||
if (isAvailable() == HealthConnectAvailability.AVAILABLE) healthConnectClient else null
|
||||
} ?: return@withContext emptyList()
|
||||
|
||||
// Validate date range
|
||||
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
|
||||
val actualEndDate = if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
|
||||
|
||||
Log.d(TAG, "Reading daily metrics (bulk) from $actualStartDate to $actualEndDate")
|
||||
|
||||
val startInstant = actualStartDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val endInstant = actualEndDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val timeRange = TimeRangeFilter.between(startInstant, endInstant)
|
||||
|
||||
// Fetch all metrics in bulk WITH PAGINATION for complete history
|
||||
val allSteps = try { readAllRecordsPaginated<StepsRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading steps", e); emptyList() }
|
||||
val allDistance = try { readAllRecordsPaginated<DistanceRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading distance", e); emptyList() }
|
||||
val allCalories = try { readAllRecordsPaginated<TotalCaloriesBurnedRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading calories", e); emptyList() }
|
||||
val allHeartRate = try { readAllRecordsPaginated<HeartRateRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading heart rate", e); emptyList() }
|
||||
val allRestingHR = try { readAllRecordsPaginated<RestingHeartRateRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading resting HR", e); emptyList() }
|
||||
val allSleep = try { readAllRecordsPaginated<SleepSessionRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading sleep", e); emptyList() }
|
||||
val allWeight = try { readAllRecordsPaginated<WeightRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading weight", e); emptyList() }
|
||||
val allSpo2 = try { readAllRecordsPaginated<OxygenSaturationRecord>(client, timeRange) } catch (e: Exception) { Log.w(TAG, "Error reading spo2", e); emptyList() }
|
||||
|
||||
Log.d(TAG, "Loaded totals - Steps: ${allSteps.size}, Distance: ${allDistance.size}, Calories: ${allCalories.size}, HR: ${allHeartRate.size}, SpO2: ${allSpo2.size}")
|
||||
|
||||
val metrics = mutableListOf<DailyMetrics>()
|
||||
var currentDate = actualStartDate
|
||||
|
||||
while (!currentDate.isAfter(actualEndDate)) {
|
||||
val dayStart = currentDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val dayEnd = currentDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
|
||||
// Filter records for this specific day (in memory)
|
||||
val daySteps = allSteps.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.count }
|
||||
val dayDistance = allDistance.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.distance.inMeters }
|
||||
val dayCalories = allCalories.filter { it.startTime >= dayStart && it.endTime <= dayEnd }.sumOf { it.energy.inKilocalories }
|
||||
|
||||
val dayHRRecords = allHeartRate.filter { it.startTime >= dayStart && it.endTime <= dayEnd }
|
||||
val hrSamples = dayHRRecords.flatMap { it.samples }
|
||||
val dayRestingHR = allRestingHR.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.beatsPerMinute?.toInt()
|
||||
|
||||
// For weight and spo2, take the latest of the day
|
||||
val dayWeight = allWeight.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.weight?.inKilograms
|
||||
val daySpo2 = allSpo2.filter { it.time >= dayStart && it.time <= dayEnd }.lastOrNull()?.percentage?.value
|
||||
|
||||
// Sleep logic - sleep for "today" is usually the one finishing today
|
||||
val daySleep = allSleep.filter { it.endTime >= dayStart && it.endTime <= dayEnd }
|
||||
var totalSleep = 0
|
||||
var deep = 0
|
||||
var light = 0
|
||||
var rem = 0
|
||||
var awake = 0
|
||||
|
||||
daySleep.forEach { session ->
|
||||
totalSleep += ChronoUnit.MINUTES.between(session.startTime, session.endTime).toInt()
|
||||
session.stages.forEach { stage ->
|
||||
val dur = ChronoUnit.MINUTES.between(stage.startTime, stage.endTime).toInt()
|
||||
when (stage.stage) {
|
||||
SleepSessionRecord.STAGE_TYPE_DEEP -> deep += dur
|
||||
SleepSessionRecord.STAGE_TYPE_LIGHT -> light += dur
|
||||
SleepSessionRecord.STAGE_TYPE_REM -> rem += dur
|
||||
SleepSessionRecord.STAGE_TYPE_AWAKE -> awake += dur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics.add(DailyMetrics(
|
||||
date = currentDate.toString(),
|
||||
steps = daySteps,
|
||||
distanceMeters = dayDistance,
|
||||
caloriesBurned = dayCalories,
|
||||
avgHeartRate = if (hrSamples.isNotEmpty()) hrSamples.map { it.beatsPerMinute }.average().toInt() else null,
|
||||
minHeartRate = if (hrSamples.isNotEmpty()) hrSamples.minOf { it.beatsPerMinute }.toInt() else null,
|
||||
maxHeartRate = if (hrSamples.isNotEmpty()) hrSamples.maxOf { it.beatsPerMinute }.toInt() else null,
|
||||
restingHeartRate = dayRestingHR,
|
||||
sleepDurationMinutes = totalSleep,
|
||||
deepSleepMinutes = deep,
|
||||
lightSleepMinutes = light,
|
||||
remSleepMinutes = rem,
|
||||
awakeMinutes = awake,
|
||||
weight = dayWeight,
|
||||
spo2 = daySpo2
|
||||
))
|
||||
|
||||
currentDate = currentDate.plusDays(1)
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Read exercise sessions for a date range with pagination support
|
||||
*/
|
||||
suspend fun readExerciseSessions(startDate: LocalDate, endDate: LocalDate): List<ExerciseData> = withContext(Dispatchers.IO) {
|
||||
// Ensure client is initialized
|
||||
val client = healthConnectClient ?: run {
|
||||
if (isAvailable() == HealthConnectAvailability.AVAILABLE) {
|
||||
healthConnectClient
|
||||
} else {
|
||||
Log.e(TAG, "Health Connect client not available for exercises")
|
||||
return@withContext emptyList()
|
||||
}
|
||||
} ?: return@withContext emptyList()
|
||||
|
||||
// Validate date range
|
||||
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
|
||||
val actualEndDate = if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
|
||||
|
||||
Log.d(TAG, "Reading exercise sessions from $actualStartDate to $actualEndDate")
|
||||
val exercises = mutableListOf<ExerciseData>()
|
||||
|
||||
try {
|
||||
val timeRange = TimeRangeFilter.between(
|
||||
actualStartDate.atStartOfDay(ZoneId.systemDefault()).toInstant(),
|
||||
actualEndDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
)
|
||||
|
||||
// Use pagination to get ALL exercise records
|
||||
var pageToken: String? = null
|
||||
var totalRecords = 0
|
||||
|
||||
do {
|
||||
val request = if (pageToken != null) {
|
||||
ReadRecordsRequest(
|
||||
recordType = ExerciseSessionRecord::class,
|
||||
timeRangeFilter = timeRange,
|
||||
pageToken = pageToken
|
||||
)
|
||||
} else {
|
||||
ReadRecordsRequest(
|
||||
recordType = ExerciseSessionRecord::class,
|
||||
timeRangeFilter = timeRange
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.readRecords(request)
|
||||
totalRecords += response.records.size
|
||||
|
||||
for (record in response.records) {
|
||||
// Validate that startTime is before endTime for this record
|
||||
if (record.startTime.isBefore(record.endTime)) {
|
||||
val durationMinutes = ChronoUnit.MINUTES.between(record.startTime, record.endTime).toInt()
|
||||
|
||||
exercises.add(
|
||||
ExerciseData(
|
||||
startTime = record.startTime.toString(),
|
||||
endTime = record.endTime.toString(),
|
||||
exerciseType = record.exerciseType,
|
||||
exerciseTypeName = mapExerciseType(record.exerciseType),
|
||||
durationMinutes = durationMinutes,
|
||||
title = record.title ?: mapExerciseType(record.exerciseType),
|
||||
notes = record.notes
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "Skipping exercise record with invalid time: start=${record.startTime}, end=${record.endTime}")
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = response.pageToken
|
||||
Log.d(TAG, "Read ${response.records.size} exercise records (total: $totalRecords, hasMore: ${pageToken != null})")
|
||||
} while (pageToken != null)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading exercise sessions", e)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Total exercise sessions found: ${exercises.size}")
|
||||
exercises
|
||||
}
|
||||
|
||||
|
||||
// ==================== PRIVATE HELPER METHODS ====================
|
||||
|
||||
/**
|
||||
* Generic helper to read ALL records with pagination support.
|
||||
* Health Connect returns max 1000 records per request by default.
|
||||
* We use pageToken to fetch all pages until no more data is available.
|
||||
*/
|
||||
private suspend inline fun <reified T : Record> readAllRecordsPaginated(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var pageToken: String? = null
|
||||
|
||||
do {
|
||||
val request = if (pageToken != null) {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange,
|
||||
pageToken = pageToken
|
||||
)
|
||||
} else {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.readRecords(request)
|
||||
allRecords.addAll(response.records)
|
||||
pageToken = response.pageToken
|
||||
|
||||
Log.d(TAG, "Read ${response.records.size} ${T::class.simpleName} records (total: ${allRecords.size}, hasMore: ${pageToken != null})")
|
||||
} while (pageToken != null)
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private suspend fun readSteps(client: HealthConnectClient, range: TimeRangeFilter): Long {
|
||||
return try {
|
||||
val response = client.readRecords(ReadRecordsRequest(StepsRecord::class, range))
|
||||
response.records.sumOf { it.count }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read steps", e)
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readDistance(client: HealthConnectClient, range: TimeRangeFilter): Double {
|
||||
return try {
|
||||
val response = client.readRecords(ReadRecordsRequest(DistanceRecord::class, range))
|
||||
response.records.sumOf { it.distance.inMeters }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read distance", e)
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readCalories(client: HealthConnectClient, range: TimeRangeFilter): Double {
|
||||
return try {
|
||||
val response = client.readRecords(ReadRecordsRequest(TotalCaloriesBurnedRecord::class, range))
|
||||
response.records.sumOf { it.energy.inKilocalories }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read calories", e)
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readHeartRate(client: HealthConnectClient, range: TimeRangeFilter): HeartRateData {
|
||||
return try {
|
||||
val response = client.readRecords(ReadRecordsRequest(HeartRateRecord::class, range))
|
||||
val samples = response.records.flatMap { it.samples }
|
||||
val resting = try {
|
||||
client.readRecords(ReadRecordsRequest(RestingHeartRateRecord::class, range))
|
||||
.records.firstOrNull()?.beatsPerMinute
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (samples.isEmpty()) {
|
||||
HeartRateData(avg = null, min = null, max = null, resting = resting?.toInt())
|
||||
} else {
|
||||
HeartRateData(
|
||||
avg = samples.map { it.beatsPerMinute }.average().toInt(),
|
||||
min = samples.minOfOrNull { it.beatsPerMinute }?.toInt(),
|
||||
max = samples.maxOfOrNull { it.beatsPerMinute }?.toInt(),
|
||||
resting = resting?.toInt()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read heart rate", e)
|
||||
HeartRateData(null, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readSleep(client: HealthConnectClient, date: LocalDate): SleepData {
|
||||
val start = date.minusDays(1).atTime(12, 0).toInstant(ZoneId.systemDefault().rules.getOffset(Instant.now()))
|
||||
val end = date.atTime(11, 59).toInstant(ZoneId.systemDefault().rules.getOffset(Instant.now()))
|
||||
val range = TimeRangeFilter.between(start, end)
|
||||
|
||||
return try {
|
||||
val response = client.readRecords(ReadRecordsRequest(SleepSessionRecord::class, range))
|
||||
val session = response.records.firstOrNull()
|
||||
|
||||
SleepData(
|
||||
totalMinutes = session?.let { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
|
||||
deepMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_DEEP }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
|
||||
lightMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_LIGHT }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
|
||||
remSleepMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_REM }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
|
||||
awakeMinutes = session?.stages?.filter { it.stage == SleepSessionRecord.STAGE_TYPE_AWAKE }?.sumOf { ChronoUnit.MINUTES.between(it.startTime, it.endTime).toInt() } ?: 0,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read sleep", e)
|
||||
SleepData(0, 0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readLatestWeight(client: HealthConnectClient, range: TimeRangeFilter): Double? {
|
||||
return try {
|
||||
val response = client.readRecords(ReadRecordsRequest(WeightRecord::class, range))
|
||||
response.records.lastOrNull()?.weight?.inKilograms
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read weight", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readSpO2(client: HealthConnectClient, range: TimeRangeFilter): Double? {
|
||||
return try {
|
||||
val response = client.readRecords(ReadRecordsRequest(OxygenSaturationRecord::class, range))
|
||||
response.records.lastOrNull()?.percentage?.value
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read SpO2", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapExerciseType(type: Int): String {
|
||||
return when (type) {
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "Walking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING -> "Running"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "Hiking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "Biking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL -> "Swimming"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING -> "Strength Training"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "Yoga"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "Pilates"
|
||||
else -> "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DATA CLASSES ====================
|
||||
|
||||
enum class HealthConnectAvailability {
|
||||
AVAILABLE,
|
||||
NOT_INSTALLED,
|
||||
NOT_SUPPORTED
|
||||
}
|
||||
|
||||
data class DailyMetrics(
|
||||
val date: String,
|
||||
val steps: Long,
|
||||
val distanceMeters: Double,
|
||||
val caloriesBurned: Double,
|
||||
val avgHeartRate: Int?,
|
||||
val minHeartRate: Int?,
|
||||
val maxHeartRate: Int?,
|
||||
val restingHeartRate: Int?,
|
||||
val sleepDurationMinutes: Int,
|
||||
val deepSleepMinutes: Int,
|
||||
val lightSleepMinutes: Int,
|
||||
val remSleepMinutes: Int,
|
||||
val awakeMinutes: Int,
|
||||
val weight: Double?,
|
||||
val spo2: Double?
|
||||
)
|
||||
|
||||
data class ExerciseData(
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val exerciseType: Int,
|
||||
val exerciseTypeName: String,
|
||||
val durationMinutes: Int,
|
||||
val title: String?,
|
||||
val notes: String?
|
||||
)
|
||||
|
||||
data class HeartRateData(
|
||||
val avg: Int?,
|
||||
val min: Int?,
|
||||
val max: Int?,
|
||||
val resting: Int?
|
||||
)
|
||||
|
||||
data class SleepData(
|
||||
val totalMinutes: Int,
|
||||
val deepMinutes: Int,
|
||||
val lightMinutes: Int,
|
||||
val remSleepMinutes: Int,
|
||||
val awakeMinutes: Int
|
||||
)
|
||||
@@ -0,0 +1,973 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.health.connect.client.PermissionController
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.habitforge.companion.databinding.ActivityMainBinding
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Main Activity - Handles UI, permission requests, sync triggers, and status monitoring
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var healthConnectManager: HealthConnectManager
|
||||
private lateinit var syncHistoryManager: SyncHistoryManager
|
||||
private lateinit var serverConnectionManager: ServerConnectionManager
|
||||
private lateinit var dataValidator: DataCompatibilityValidator
|
||||
private lateinit var authManager: AuthManager
|
||||
private lateinit var metricsSyncTracker: MetricsSyncTracker
|
||||
|
||||
// Permission request launcher
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
PermissionController.createRequestPermissionResultContract()
|
||||
) { granted ->
|
||||
Log.i(TAG, "Permissions granted: $granted")
|
||||
lifecycleScope.launch {
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
healthConnectManager = HabitForgeApp.instance.healthConnectManager
|
||||
syncHistoryManager = SyncHistoryManager(this)
|
||||
serverConnectionManager = ServerConnectionManager(this)
|
||||
dataValidator = DataCompatibilityValidator(this, healthConnectManager)
|
||||
authManager = AuthManager(this)
|
||||
metricsSyncTracker = MetricsSyncTracker(this)
|
||||
|
||||
setupUI()
|
||||
setupSyncPeriodSpinner()
|
||||
checkHealthConnectAvailability()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
lifecycleScope.launch {
|
||||
updateUI()
|
||||
checkServerAvailability()
|
||||
updateSyncHistory()
|
||||
updateMetricsOverview()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupUI() {
|
||||
// Request Permissions Button
|
||||
binding.btnRequestPermissions.setOnClickListener {
|
||||
requestHealthConnectPermissions()
|
||||
}
|
||||
|
||||
// Sync Now Button
|
||||
binding.btnSyncNow.setOnClickListener {
|
||||
performSync()
|
||||
}
|
||||
|
||||
// Configure Server Button
|
||||
binding.btnConfigureServer.setOnClickListener {
|
||||
showServerConfigDialog()
|
||||
}
|
||||
|
||||
// Test Connection Button
|
||||
binding.btnTestConnection.setOnClickListener {
|
||||
testServerConnection()
|
||||
}
|
||||
|
||||
// Open Health Connect Button
|
||||
binding.btnOpenHealthConnect.setOnClickListener {
|
||||
openHealthConnect()
|
||||
}
|
||||
|
||||
// Validate Data Button
|
||||
binding.btnValidateData.setOnClickListener {
|
||||
validateDataCompatibility()
|
||||
}
|
||||
|
||||
// View All History Button
|
||||
binding.btnViewAllHistory.setOnClickListener {
|
||||
showFullSyncHistory()
|
||||
}
|
||||
|
||||
// Refresh Metrics Button
|
||||
binding.btnRefreshMetrics.setOnClickListener {
|
||||
refreshMetricsOverview()
|
||||
}
|
||||
|
||||
// Initialize metrics display
|
||||
updateMetricsOverview()
|
||||
}
|
||||
|
||||
private fun setupSyncPeriodSpinner() {
|
||||
val options = SyncService.getSyncPeriodOptions()
|
||||
val adapter = ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_spinner_item,
|
||||
options.map { it.label }
|
||||
)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.spinnerSyncPeriod.adapter = adapter
|
||||
|
||||
// Set current selection
|
||||
val currentPeriod = SyncService.getSyncPeriod(this)
|
||||
val currentIndex = options.indexOfFirst { it.minutes == currentPeriod }
|
||||
if (currentIndex >= 0) {
|
||||
binding.spinnerSyncPeriod.setSelection(currentIndex)
|
||||
}
|
||||
|
||||
// Handle selection changes
|
||||
binding.spinnerSyncPeriod.setOnItemSelectedListener(object : android.widget.AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val selectedPeriod = options[position].minutes
|
||||
if (selectedPeriod != SyncService.getSyncPeriod(this@MainActivity)) {
|
||||
SyncService.schedulePeriodicSync(this@MainActivity, selectedPeriod)
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"Sync period updated to ${options[position].label}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun checkHealthConnectAvailability() {
|
||||
when (healthConnectManager.isAvailable()) {
|
||||
HealthConnectAvailability.AVAILABLE -> {
|
||||
binding.statusHealthConnect.text = "✓ Health Connect Available"
|
||||
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.green))
|
||||
binding.layoutAvailable.visibility = View.VISIBLE
|
||||
binding.layoutUnavailable.visibility = View.GONE
|
||||
|
||||
lifecycleScope.launch {
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
HealthConnectAvailability.NOT_INSTALLED -> {
|
||||
binding.statusHealthConnect.text = "✗ Health Connect Not Installed"
|
||||
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.orange))
|
||||
binding.layoutAvailable.visibility = View.GONE
|
||||
binding.layoutUnavailable.visibility = View.VISIBLE
|
||||
binding.btnInstallHealthConnect.setOnClickListener {
|
||||
installHealthConnect()
|
||||
}
|
||||
}
|
||||
HealthConnectAvailability.NOT_SUPPORTED -> {
|
||||
binding.statusHealthConnect.text = "✗ Health Connect Not Supported"
|
||||
binding.statusHealthConnect.setTextColor(ContextCompat.getColor(this, R.color.red))
|
||||
binding.layoutAvailable.visibility = View.GONE
|
||||
binding.layoutUnavailable.visibility = View.VISIBLE
|
||||
binding.btnInstallHealthConnect.visibility = View.GONE
|
||||
binding.tvUnavailableMessage.text = "Your device does not support Health Connect. Android 9+ is required."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateUI() {
|
||||
// Check permissions status
|
||||
val hasPermissions = healthConnectManager.hasAllPermissions()
|
||||
val grantedCount = healthConnectManager.getGrantedPermissions().size
|
||||
val totalCount = HealthConnectManager.PERMISSIONS.size
|
||||
|
||||
if (hasPermissions) {
|
||||
binding.statusPermissions.text = "✓ All Permissions Granted ($grantedCount/$totalCount)"
|
||||
binding.statusPermissions.setTextColor(ContextCompat.getColor(this, R.color.green))
|
||||
binding.btnRequestPermissions.text = "Manage Permissions"
|
||||
binding.btnSyncNow.isEnabled = true
|
||||
} else {
|
||||
binding.statusPermissions.text = "⚠ Permissions Required ($grantedCount/$totalCount)"
|
||||
binding.statusPermissions.setTextColor(ContextCompat.getColor(this, R.color.orange))
|
||||
binding.btnRequestPermissions.text = "Grant Permissions"
|
||||
binding.btnSyncNow.isEnabled = false
|
||||
}
|
||||
|
||||
// Check authentication status
|
||||
if (authManager.isLoggedIn()) {
|
||||
val username = authManager.getUsername()
|
||||
binding.statusServer.text = "✓ Logged in as $username"
|
||||
binding.statusServer.setTextColor(ContextCompat.getColor(this, R.color.green))
|
||||
binding.tvServerUrl.text = authManager.getServerUrl()
|
||||
binding.btnConfigureServer.text = "Logout"
|
||||
} else {
|
||||
binding.statusServer.text = "⚠ Not logged in"
|
||||
binding.statusServer.setTextColor(ContextCompat.getColor(this, R.color.orange))
|
||||
binding.tvServerUrl.text = "Tap Login to connect your account"
|
||||
binding.btnConfigureServer.text = "Login"
|
||||
}
|
||||
|
||||
// Last sync time
|
||||
val prefs = getSharedPreferences("habitforge", MODE_PRIVATE)
|
||||
val lastSync = prefs.getLong("last_sync", 0)
|
||||
if (lastSync > 0) {
|
||||
val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
|
||||
binding.tvLastSync.text = "Last sync: ${dateFormat.format(Date(lastSync))}"
|
||||
} else {
|
||||
binding.tvLastSync.text = "Never synced"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkServerAvailability() {
|
||||
binding.tvServerAvailability.text = "Checking..."
|
||||
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_gray)
|
||||
|
||||
val result = serverConnectionManager.testConnection()
|
||||
|
||||
when (result.status) {
|
||||
ServerStatus.ONLINE -> {
|
||||
if (result.authenticated) {
|
||||
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_green)
|
||||
binding.tvServerAvailability.text = "Online"
|
||||
binding.tvServerResponseTime.text = "Response: ${result.responseTimeMs}ms"
|
||||
binding.tvServerResponseTime.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
|
||||
binding.tvServerAvailability.text = "Auth needed"
|
||||
binding.tvServerResponseTime.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
ServerStatus.UNREACHABLE, ServerStatus.OFFLINE -> {
|
||||
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_red)
|
||||
binding.tvServerAvailability.text = "Offline"
|
||||
binding.tvServerResponseTime.visibility = View.GONE
|
||||
}
|
||||
ServerStatus.TIMEOUT -> {
|
||||
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
|
||||
binding.tvServerAvailability.text = "Timeout"
|
||||
binding.tvServerResponseTime.visibility = View.GONE
|
||||
}
|
||||
ServerStatus.AUTH_ERROR -> {
|
||||
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_orange)
|
||||
binding.tvServerAvailability.text = "Auth error"
|
||||
binding.tvServerResponseTime.visibility = View.GONE
|
||||
}
|
||||
ServerStatus.SERVER_ERROR -> {
|
||||
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_red)
|
||||
binding.tvServerAvailability.text = "Error"
|
||||
binding.tvServerResponseTime.visibility = View.GONE
|
||||
}
|
||||
else -> {
|
||||
binding.serverStatusDot.setBackgroundResource(R.drawable.status_dot_gray)
|
||||
binding.tvServerAvailability.text = "Unknown"
|
||||
binding.tvServerResponseTime.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSyncHistory() {
|
||||
val stats = syncHistoryManager.getStats()
|
||||
val recentSyncs = syncHistoryManager.getRecentHistory(5)
|
||||
|
||||
// Update stats
|
||||
binding.tvSuccessCount.text = stats.successfulSyncs.toString()
|
||||
binding.tvFailedCount.text = stats.failedSyncs.toString()
|
||||
binding.tvMetricsSynced.text = stats.totalMetricsSynced.toString()
|
||||
|
||||
if (stats.totalSyncs > 0) {
|
||||
binding.tvSyncStats.text = "${String.format("%.0f", stats.getSuccessRate())}% success"
|
||||
} else {
|
||||
binding.tvSyncStats.text = ""
|
||||
}
|
||||
|
||||
// Update recent syncs list
|
||||
binding.layoutRecentSyncs.removeAllViews()
|
||||
|
||||
if (recentSyncs.isEmpty()) {
|
||||
binding.tvNoSyncHistory.visibility = View.VISIBLE
|
||||
binding.btnViewAllHistory.visibility = View.GONE
|
||||
} else {
|
||||
binding.tvNoSyncHistory.visibility = View.GONE
|
||||
binding.btnViewAllHistory.visibility = if (syncHistoryManager.getHistory().size > 5) View.VISIBLE else View.GONE
|
||||
|
||||
for (sync in recentSyncs) {
|
||||
val itemView = createSyncHistoryItem(sync)
|
||||
binding.layoutRecentSyncs.addView(itemView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSyncHistoryItem(sync: SyncRecord): View {
|
||||
val itemView = LayoutInflater.from(this).inflate(android.R.layout.simple_list_item_2, null)
|
||||
|
||||
val text1 = itemView.findViewById<TextView>(android.R.id.text1)
|
||||
val text2 = itemView.findViewById<TextView>(android.R.id.text2)
|
||||
|
||||
text1.text = "${sync.getStatusIcon()} ${sync.getFormattedTimestamp()}"
|
||||
text1.setTextColor(ContextCompat.getColor(this, sync.getStatusColor()))
|
||||
text1.textSize = 13f
|
||||
|
||||
val details = if (sync.status == SyncStatus.SUCCESS) {
|
||||
"Metrics: ${sync.metricsSynced}, Exercises: ${sync.exercisesSynced} (${sync.syncDurationMs}ms)"
|
||||
} else {
|
||||
sync.errorMessage ?: "Unknown error"
|
||||
}
|
||||
text2.text = details
|
||||
text2.textSize = 11f
|
||||
text2.setTextColor(ContextCompat.getColor(this, android.R.color.darker_gray))
|
||||
|
||||
val params = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
params.setMargins(0, 4, 0, 4)
|
||||
itemView.layoutParams = params
|
||||
|
||||
return itemView
|
||||
}
|
||||
|
||||
private fun testServerConnection() {
|
||||
binding.btnTestConnection.isEnabled = false
|
||||
binding.btnTestConnection.text = "Testing..."
|
||||
|
||||
lifecycleScope.launch {
|
||||
val result = serverConnectionManager.testConnection()
|
||||
|
||||
binding.btnTestConnection.isEnabled = true
|
||||
binding.btnTestConnection.text = "Test Connection"
|
||||
|
||||
// Show result dialog
|
||||
AlertDialog.Builder(this@MainActivity)
|
||||
.setTitle("Connection Test Result")
|
||||
.setMessage("""
|
||||
Status: ${result.getStatusIcon()} ${result.status}
|
||||
|
||||
${result.message}
|
||||
|
||||
${if (result.responseTimeMs > 0) "Response time: ${result.responseTimeMs}ms" else ""}
|
||||
${if (result.authenticated) "✓ Authentication: Valid" else ""}
|
||||
""".trimIndent().trim())
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
|
||||
// Update server status indicator
|
||||
checkServerAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDataCompatibility() {
|
||||
binding.btnValidateData.isEnabled = false
|
||||
binding.btnValidateData.text = "Validating..."
|
||||
|
||||
lifecycleScope.launch {
|
||||
val result = dataValidator.validateCompatibility()
|
||||
|
||||
runOnUiThread {
|
||||
binding.btnValidateData.isEnabled = true
|
||||
binding.btnValidateData.text = "Validate Data Schema"
|
||||
|
||||
// Update status
|
||||
binding.statusDataCompatibility.text = "${result.getStatusIcon()} ${result.getOverallStatus()}"
|
||||
if (result.isCompatible) {
|
||||
binding.statusDataCompatibility.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.green))
|
||||
} else {
|
||||
binding.statusDataCompatibility.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.red))
|
||||
}
|
||||
|
||||
// Show validation results
|
||||
binding.layoutValidationResults.visibility = View.VISIBLE
|
||||
|
||||
val resultsText = StringBuilder()
|
||||
|
||||
// Add validation items
|
||||
for (item in result.validations) {
|
||||
val icon = if (item.passed) "✓" else "✗"
|
||||
resultsText.appendLine("$icon ${item.name}")
|
||||
resultsText.appendLine(" ${item.message}")
|
||||
}
|
||||
|
||||
// Add issues if any
|
||||
if (result.issues.isNotEmpty()) {
|
||||
resultsText.appendLine("\nIssues:")
|
||||
for (issue in result.issues) {
|
||||
val icon = when (issue.severity) {
|
||||
IssueSeverity.ERROR -> "❌"
|
||||
IssueSeverity.WARNING -> "⚠️"
|
||||
IssueSeverity.INFO -> "ℹ️"
|
||||
}
|
||||
resultsText.appendLine("$icon [${issue.category}] ${issue.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Add data preview if available
|
||||
result.dataPreview?.let { preview ->
|
||||
resultsText.appendLine("\nData Preview:")
|
||||
resultsText.appendLine(" Metrics: ${preview.metricsCount} day(s)")
|
||||
resultsText.appendLine(" Exercises: ${preview.exercisesCount} session(s)")
|
||||
}
|
||||
|
||||
binding.tvValidationResultsList.text = resultsText.toString()
|
||||
|
||||
// Update details text
|
||||
binding.tvCompatibilityDetails.text = if (result.isCompatible) {
|
||||
"All Health Connect data types can be synced with HabitForge."
|
||||
} else {
|
||||
"Some issues were found. Check the validation results below."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFullSyncHistory() {
|
||||
val history = syncHistoryManager.getHistory()
|
||||
|
||||
val message = if (history.isEmpty()) {
|
||||
"No sync history available."
|
||||
} else {
|
||||
history.take(20).joinToString("\n\n") { sync ->
|
||||
val details = if (sync.status == SyncStatus.SUCCESS) {
|
||||
"Metrics: ${sync.metricsSynced}, Exercises: ${sync.exercisesSynced}"
|
||||
} else {
|
||||
sync.errorMessage ?: "Unknown error"
|
||||
}
|
||||
"${sync.getStatusIcon()} ${sync.getFormattedTimestamp()}\n$details"
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Sync History")
|
||||
.setMessage(message)
|
||||
.setPositiveButton("OK", null)
|
||||
.setNeutralButton("Clear History") { _, _ ->
|
||||
syncHistoryManager.clearHistory()
|
||||
updateSyncHistory()
|
||||
Toast.makeText(this, "History cleared", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun requestHealthConnectPermissions() {
|
||||
permissionLauncher.launch(HealthConnectManager.PERMISSIONS)
|
||||
}
|
||||
|
||||
private fun performSync() {
|
||||
if (!authManager.isLoggedIn()) {
|
||||
Toast.makeText(this, "Please login first", Toast.LENGTH_SHORT).show()
|
||||
showLoginDialog()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnSyncNow.isEnabled = false
|
||||
binding.btnSyncNow.text = "Syncing..."
|
||||
binding.progressSync.visibility = View.VISIBLE
|
||||
|
||||
SyncService.syncNow(this)
|
||||
|
||||
// Re-enable after delay (actual completion is async)
|
||||
binding.root.postDelayed({
|
||||
binding.btnSyncNow.isEnabled = true
|
||||
binding.btnSyncNow.text = "Sync Now"
|
||||
binding.progressSync.visibility = View.GONE
|
||||
lifecycleScope.launch {
|
||||
updateUI()
|
||||
updateSyncHistory()
|
||||
}
|
||||
Toast.makeText(this, "Sync started in background", Toast.LENGTH_SHORT).show()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
private fun showServerConfigDialog() {
|
||||
// If already logged in, show logout confirmation
|
||||
if (authManager.isLoggedIn()) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Logout")
|
||||
.setMessage("Are you sure you want to logout from ${authManager.getUsername()}?")
|
||||
.setPositiveButton("Logout") { _, _ ->
|
||||
authManager.logout()
|
||||
lifecycleScope.launch {
|
||||
updateUI()
|
||||
checkServerAvailability()
|
||||
}
|
||||
Toast.makeText(this, "Logged out successfully", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
// Show login dialog
|
||||
showLoginDialog()
|
||||
}
|
||||
|
||||
private fun showLoginDialog() {
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_server_config, null)
|
||||
val etServerUrl = dialogView.findViewById<android.widget.EditText>(R.id.etServerUrl)
|
||||
val etUsername = dialogView.findViewById<android.widget.EditText>(R.id.etUsername)
|
||||
val etPassword = dialogView.findViewById<android.widget.EditText>(R.id.etPassword)
|
||||
val tvError = dialogView.findViewById<TextView>(R.id.tvError)
|
||||
val progressLogin = dialogView.findViewById<android.widget.ProgressBar>(R.id.progressLogin)
|
||||
|
||||
// Pre-fill server URL
|
||||
etServerUrl.setText(authManager.getServerUrl())
|
||||
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setTitle("Login to HabitForge")
|
||||
.setView(dialogView)
|
||||
.setPositiveButton("Login", null) // Set to null to prevent auto-dismiss
|
||||
.setNegativeButton("Cancel", null)
|
||||
.create()
|
||||
|
||||
dialog.setOnShowListener {
|
||||
val loginButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
|
||||
loginButton.setOnClickListener {
|
||||
val serverUrl = etServerUrl.text.toString().trim()
|
||||
val username = etUsername.text.toString().trim()
|
||||
val password = etPassword.text.toString()
|
||||
|
||||
// Validation
|
||||
if (serverUrl.isEmpty()) {
|
||||
tvError.text = "Please enter server URL"
|
||||
tvError.visibility = View.VISIBLE
|
||||
return@setOnClickListener
|
||||
}
|
||||
if (username.isEmpty()) {
|
||||
tvError.text = "Please enter username"
|
||||
tvError.visibility = View.VISIBLE
|
||||
return@setOnClickListener
|
||||
}
|
||||
if (password.isEmpty()) {
|
||||
tvError.text = "Please enter password"
|
||||
tvError.visibility = View.VISIBLE
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
// Show loading
|
||||
tvError.visibility = View.GONE
|
||||
progressLogin.visibility = View.VISIBLE
|
||||
loginButton.isEnabled = false
|
||||
etServerUrl.isEnabled = false
|
||||
etUsername.isEnabled = false
|
||||
etPassword.isEnabled = false
|
||||
|
||||
// Attempt login
|
||||
lifecycleScope.launch {
|
||||
val result = authManager.login(serverUrl, username, password)
|
||||
|
||||
if (result.success) {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
|
||||
|
||||
// Schedule periodic sync
|
||||
SyncService.schedulePeriodicSync(this@MainActivity)
|
||||
|
||||
updateUI()
|
||||
checkServerAvailability()
|
||||
} else {
|
||||
// Show error
|
||||
progressLogin.visibility = View.GONE
|
||||
tvError.text = result.message
|
||||
tvError.visibility = View.VISIBLE
|
||||
loginButton.isEnabled = true
|
||||
etServerUrl.isEnabled = true
|
||||
etUsername.isEnabled = true
|
||||
etPassword.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun openHealthConnect() {
|
||||
try {
|
||||
val intent = Intent("android.health.connect.action.HEALTH_HOME_SETTINGS")
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Fallback for older versions
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setPackage("com.google.android.apps.healthdata")
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e2: Exception) {
|
||||
Toast.makeText(this, "Could not open Health Connect", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun installHealthConnect() {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(
|
||||
"https://play.google.com/store/apps/details?id=com.google.android.apps.healthdata"
|
||||
)))
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Could not open Play Store", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== METRICS OVERVIEW ====================
|
||||
|
||||
private fun updateMetricsOverview() {
|
||||
val allStatus = metricsSyncTracker.getAllMetricsStatus()
|
||||
|
||||
// Update summary text
|
||||
val syncedCount = allStatus.count { it.lastSyncTime != null }
|
||||
val availableCount = allStatus.count { it.isAvailable }
|
||||
binding.tvMetricsSummary.text = "$syncedCount/${allStatus.size} metrics synced, $availableCount with data"
|
||||
|
||||
// Update each metric view with click listener
|
||||
setupMetricClickListener(binding.metricSteps.root, "steps", allStatus.find { it.metricType.id == "steps" })
|
||||
setupMetricClickListener(binding.metricDistance.root, "distance", allStatus.find { it.metricType.id == "distance" })
|
||||
setupMetricClickListener(binding.metricCalories.root, "calories", allStatus.find { it.metricType.id == "calories" })
|
||||
setupMetricClickListener(binding.metricHeartRate.root, "heart_rate", allStatus.find { it.metricType.id == "heart_rate" })
|
||||
setupMetricClickListener(binding.metricRestingHR.root, "resting_hr", allStatus.find { it.metricType.id == "resting_hr" })
|
||||
setupMetricClickListener(binding.metricSleep.root, "sleep", allStatus.find { it.metricType.id == "sleep" })
|
||||
setupMetricClickListener(binding.metricWeight.root, "weight", allStatus.find { it.metricType.id == "weight" })
|
||||
setupMetricClickListener(binding.metricSpO2.root, "spo2", allStatus.find { it.metricType.id == "spo2" })
|
||||
setupMetricClickListener(binding.metricExercise.root, "exercise", allStatus.find { it.metricType.id == "exercise" })
|
||||
}
|
||||
|
||||
private fun setupMetricClickListener(metricView: View, metricId: String, status: MetricSyncStatus?) {
|
||||
updateMetricView(metricView, status)
|
||||
metricView.setOnClickListener {
|
||||
showMetricHistory(metricId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMetricView(metricView: View, status: MetricSyncStatus?) {
|
||||
val metricType = status?.metricType ?: return
|
||||
|
||||
val tvIcon = metricView.findViewById<TextView>(R.id.tvMetricIcon)
|
||||
val tvName = metricView.findViewById<TextView>(R.id.tvMetricName)
|
||||
val tvValue = metricView.findViewById<TextView>(R.id.tvMetricValue)
|
||||
val tvStatus = metricView.findViewById<TextView>(R.id.tvMetricStatus)
|
||||
val tvSyncTime = metricView.findViewById<TextView>(R.id.tvMetricSyncTime)
|
||||
|
||||
tvIcon.text = metricType.icon
|
||||
tvName.text = metricType.displayName
|
||||
tvStatus.text = status.getStatusIcon()
|
||||
tvValue.text = status.getStatusText()
|
||||
|
||||
if (status.lastSyncTime != null) {
|
||||
tvSyncTime.visibility = View.VISIBLE
|
||||
tvSyncTime.text = status.getFormattedSyncTime()
|
||||
} else {
|
||||
tvSyncTime.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Set background color based on status
|
||||
val bgColor = when {
|
||||
status.isAvailable && status.lastSyncTime != null -> "#E8F5E9" // Light green
|
||||
status.lastSyncTime != null -> "#FFF3E0" // Light orange
|
||||
else -> "#F5F5F5" // Light gray
|
||||
}
|
||||
metricView.setBackgroundColor(android.graphics.Color.parseColor(bgColor))
|
||||
}
|
||||
|
||||
private fun refreshMetricsOverview() {
|
||||
lifecycleScope.launch {
|
||||
binding.btnRefreshMetrics.isEnabled = false
|
||||
binding.btnRefreshMetrics.text = "Reading from Health Connect..."
|
||||
|
||||
try {
|
||||
// Read latest data from Health Connect (last 7 days for better data)
|
||||
val endDate = java.time.LocalDate.now()
|
||||
val startDate = endDate.minusDays(7)
|
||||
|
||||
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
|
||||
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
|
||||
|
||||
if (metrics.isNotEmpty()) {
|
||||
// Find the best (most recent non-zero) values for each metric
|
||||
val sortedMetrics = metrics.sortedByDescending { it.date }
|
||||
|
||||
// For steps - find most recent day with steps > 0 (could be today or yesterday)
|
||||
val stepsMetric = sortedMetrics.firstOrNull { it.steps > 0 } ?: sortedMetrics.first()
|
||||
|
||||
// Find best values from available data
|
||||
val bestSteps = stepsMetric.steps
|
||||
val bestDistance = sortedMetrics.firstOrNull { it.distanceMeters > 0 }?.distanceMeters ?: 0.0
|
||||
val bestCalories = sortedMetrics.firstOrNull { it.caloriesBurned > 0 }?.caloriesBurned ?: 0.0
|
||||
val bestAvgHR = sortedMetrics.mapNotNull { it.avgHeartRate }.filter { it > 0 }.firstOrNull()
|
||||
val bestRestingHR = sortedMetrics.mapNotNull { it.restingHeartRate }.filter { it > 0 }.firstOrNull()
|
||||
val bestSleep = sortedMetrics.filter { it.sleepDurationMinutes > 0 }.firstOrNull()?.sleepDurationMinutes
|
||||
val bestWeight = sortedMetrics.mapNotNull { it.weight }.filter { it > 0 }.firstOrNull()
|
||||
val bestSpo2 = sortedMetrics.mapNotNull { it.spo2 }.filter { it > 0 }.firstOrNull()
|
||||
|
||||
Log.d(TAG, "Best values found - Steps: $bestSteps, Distance: $bestDistance, Calories: $bestCalories")
|
||||
|
||||
// Update tracker with best available values
|
||||
metricsSyncTracker.updateFromSyncData(
|
||||
steps = bestSteps,
|
||||
distance = bestDistance,
|
||||
calories = bestCalories,
|
||||
avgHeartRate = bestAvgHR,
|
||||
restingHeartRate = bestRestingHR,
|
||||
sleepMinutes = bestSleep,
|
||||
weight = bestWeight,
|
||||
spo2 = bestSpo2,
|
||||
exerciseCount = exercises.size
|
||||
)
|
||||
|
||||
Toast.makeText(this@MainActivity, "Metrics refreshed from Health Connect", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this@MainActivity, "No metrics data available", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
updateMetricsOverview()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error refreshing metrics", e)
|
||||
Toast.makeText(this@MainActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
} finally {
|
||||
binding.btnRefreshMetrics.isEnabled = true
|
||||
binding.btnRefreshMetrics.text = "Refresh Metrics"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show full metric history in a dialog
|
||||
*/
|
||||
private fun showMetricHistory(metricId: String) {
|
||||
val progressDialog = AlertDialog.Builder(this)
|
||||
.setMessage("Loading 365 days of history...")
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Read all available data from Health Connect (last 365 days for full history)
|
||||
val endDate = java.time.LocalDate.now()
|
||||
val startDate = endDate.minusDays(365)
|
||||
|
||||
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
|
||||
val exercises = if (metricId == "exercise") {
|
||||
healthConnectManager.readExerciseSessions(startDate, endDate)
|
||||
} else emptyList()
|
||||
|
||||
progressDialog.dismiss()
|
||||
|
||||
if (metrics.isEmpty() && exercises.isEmpty()) {
|
||||
Toast.makeText(this@MainActivity, "No history data available", Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Create and show dialog
|
||||
showMetricHistoryDialog(metricId, metrics, exercises)
|
||||
|
||||
} catch (e: Exception) {
|
||||
progressDialog.dismiss()
|
||||
Log.e(TAG, "Error loading history", e)
|
||||
Toast.makeText(this@MainActivity, "Error loading history: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMetricHistoryDialog(
|
||||
metricId: String,
|
||||
metrics: List<DailyMetrics>,
|
||||
exercises: List<ExerciseData>
|
||||
) {
|
||||
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_metric_history, null)
|
||||
|
||||
val tvTitle = dialogView.findViewById<TextView>(R.id.tvHistoryTitle)
|
||||
val tvSubtitle = dialogView.findViewById<TextView>(R.id.tvHistorySubtitle)
|
||||
val tvIcon = dialogView.findViewById<TextView>(R.id.tvHistoryIcon)
|
||||
val tvDataPoints = dialogView.findViewById<TextView>(R.id.tvHistoryDataPoints)
|
||||
val tvLatestValue = dialogView.findViewById<TextView>(R.id.tvHistoryLatestValue)
|
||||
val tvAverage = dialogView.findViewById<TextView>(R.id.tvHistoryAverage)
|
||||
val layoutList = dialogView.findViewById<LinearLayout>(R.id.layoutHistoryList)
|
||||
|
||||
// Get metric info
|
||||
val metricInfo = MetricsSyncTracker.ALL_METRICS.find { it.id == metricId }
|
||||
tvIcon.text = metricInfo?.icon ?: "📊"
|
||||
tvTitle.text = "${metricInfo?.displayName ?: metricId} History"
|
||||
tvSubtitle.text = "Last year from Health Connect"
|
||||
|
||||
// Extract values based on metric type
|
||||
val historyData = mutableListOf<Pair<String, String>>()
|
||||
var unit = ""
|
||||
val values = mutableListOf<Double>()
|
||||
|
||||
when (metricId) {
|
||||
"steps" -> {
|
||||
unit = " steps"
|
||||
metrics.forEach { m ->
|
||||
if (m.steps > 0) {
|
||||
historyData.add(m.date to "%,d".format(m.steps))
|
||||
values.add(m.steps.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
"distance" -> {
|
||||
unit = " m"
|
||||
metrics.forEach { m ->
|
||||
if (m.distanceMeters > 0) {
|
||||
historyData.add(m.date to "%.1f".format(m.distanceMeters))
|
||||
values.add(m.distanceMeters)
|
||||
}
|
||||
}
|
||||
}
|
||||
"calories" -> {
|
||||
unit = " kcal"
|
||||
metrics.forEach { m ->
|
||||
if (m.caloriesBurned > 0) {
|
||||
historyData.add(m.date to "%.0f".format(m.caloriesBurned))
|
||||
values.add(m.caloriesBurned)
|
||||
}
|
||||
}
|
||||
}
|
||||
"heart_rate" -> {
|
||||
unit = " bpm"
|
||||
metrics.forEach { m ->
|
||||
m.avgHeartRate?.let {
|
||||
if (it > 0) {
|
||||
historyData.add(m.date to it.toString())
|
||||
values.add(it.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"resting_hr" -> {
|
||||
unit = " bpm"
|
||||
metrics.forEach { m ->
|
||||
m.restingHeartRate?.let {
|
||||
if (it > 0) {
|
||||
historyData.add(m.date to it.toString())
|
||||
values.add(it.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"sleep" -> {
|
||||
unit = ""
|
||||
metrics.forEach { m ->
|
||||
if (m.sleepDurationMinutes > 0) {
|
||||
val hours = m.sleepDurationMinutes / 60
|
||||
val mins = m.sleepDurationMinutes % 60
|
||||
historyData.add(m.date to "${hours}h ${mins}m")
|
||||
values.add(m.sleepDurationMinutes.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
"weight" -> {
|
||||
unit = " kg"
|
||||
metrics.forEach { m ->
|
||||
m.weight?.let {
|
||||
if (it > 0) {
|
||||
historyData.add(m.date to "%.1f".format(it))
|
||||
values.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"spo2" -> {
|
||||
unit = "%"
|
||||
metrics.forEach { m ->
|
||||
m.spo2?.let {
|
||||
if (it > 0) {
|
||||
historyData.add(m.date to "%.0f".format(it))
|
||||
values.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"exercise" -> {
|
||||
unit = " sessions"
|
||||
// Group exercises by date - extract date safely
|
||||
val byDate = exercises.groupBy { ex ->
|
||||
try {
|
||||
// Handle ISO timestamp format (2026-01-02T10:30:00Z)
|
||||
if (ex.startTime.length >= 10) ex.startTime.substring(0, 10) else ex.startTime
|
||||
} catch (e: Exception) {
|
||||
ex.startTime
|
||||
}
|
||||
}
|
||||
byDate.forEach { (date, exs) ->
|
||||
val summary = if (exs.size <= 3) {
|
||||
exs.joinToString { it.exerciseTypeName }
|
||||
} else {
|
||||
"${exs.take(2).joinToString { it.exerciseTypeName }} +${exs.size - 2} more"
|
||||
}
|
||||
historyData.add(date to "${exs.size} ($summary)")
|
||||
values.add(exs.size.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
historyData.sortByDescending { it.first }
|
||||
|
||||
// Update summary
|
||||
tvDataPoints.text = historyData.size.toString()
|
||||
tvLatestValue.text = if (historyData.isNotEmpty()) historyData.first().second + unit else "--"
|
||||
tvAverage.text = if (values.isNotEmpty()) {
|
||||
val avg = values.average()
|
||||
if (metricId == "sleep") {
|
||||
val hours = (avg / 60).toInt()
|
||||
val mins = (avg % 60).toInt()
|
||||
"${hours}h ${mins}m"
|
||||
} else {
|
||||
"%.1f".format(avg) + unit
|
||||
}
|
||||
} else "--"
|
||||
|
||||
// Populate list
|
||||
val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
|
||||
val parseFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
|
||||
for ((dateStr, value) in historyData) {
|
||||
val rowView = LayoutInflater.from(this).inflate(R.layout.item_history_row, layoutList, false)
|
||||
|
||||
val tvDate = rowView.findViewById<TextView>(R.id.tvHistoryDate)
|
||||
val tvValue = rowView.findViewById<TextView>(R.id.tvHistoryValue)
|
||||
val tvUnit = rowView.findViewById<TextView>(R.id.tvHistoryUnit)
|
||||
|
||||
try {
|
||||
val date = parseFormat.parse(dateStr)
|
||||
tvDate.text = dateFormat.format(date!!)
|
||||
} catch (e: Exception) {
|
||||
tvDate.text = dateStr
|
||||
}
|
||||
|
||||
tvValue.text = value
|
||||
tvUnit.text = if (metricId != "exercise") unit else ""
|
||||
|
||||
layoutList.addView(rowView)
|
||||
|
||||
// Add divider
|
||||
val divider = View(this)
|
||||
divider.layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
1
|
||||
)
|
||||
divider.setBackgroundColor(android.graphics.Color.parseColor("#E5E7EB"))
|
||||
layoutList.addView(divider)
|
||||
}
|
||||
|
||||
// Show dialog
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(dialogView)
|
||||
.create()
|
||||
|
||||
dialogView.findViewById<View>(R.id.btnCloseHistory).setOnClickListener {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Tracks sync status for individual metric types
|
||||
*/
|
||||
class MetricsSyncTracker(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("metrics_sync", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val KEY_METRICS_STATUS = "metrics_status"
|
||||
|
||||
// All available metric types
|
||||
val ALL_METRICS = listOf(
|
||||
MetricType("steps", "Steps", "👣", "Daily step count"),
|
||||
MetricType("distance", "Distance", "📏", "Distance walked/run"),
|
||||
MetricType("calories", "Calories", "🔥", "Calories burned"),
|
||||
MetricType("heart_rate", "Heart Rate", "❤️", "Average, min, max heart rate"),
|
||||
MetricType("resting_hr", "Resting HR", "💗", "Resting heart rate"),
|
||||
MetricType("sleep", "Sleep", "😴", "Sleep duration and stages"),
|
||||
MetricType("weight", "Weight", "⚖️", "Body weight"),
|
||||
MetricType("spo2", "SpO2", "🫁", "Blood oxygen saturation"),
|
||||
MetricType("exercise", "Exercise", "🏃", "Workout sessions")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status of all metrics
|
||||
*/
|
||||
fun getAllMetricsStatus(): List<MetricSyncStatus> {
|
||||
val savedStatus = loadSavedStatus()
|
||||
|
||||
return ALL_METRICS.map { metricType ->
|
||||
savedStatus[metricType.id] ?: MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = null,
|
||||
lastValue = null,
|
||||
syncCount = 0,
|
||||
isAvailable = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sync status for a specific metric
|
||||
*/
|
||||
fun updateMetricStatus(
|
||||
metricId: String,
|
||||
lastValue: String?,
|
||||
isAvailable: Boolean = true
|
||||
) {
|
||||
val savedStatus = loadSavedStatus().toMutableMap()
|
||||
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
|
||||
|
||||
val existing = savedStatus[metricId]
|
||||
savedStatus[metricId] = MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = System.currentTimeMillis(),
|
||||
lastValue = lastValue,
|
||||
syncCount = (existing?.syncCount ?: 0) + 1,
|
||||
isAvailable = isAvailable
|
||||
)
|
||||
|
||||
saveStatus(savedStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update metrics after a sync operation
|
||||
*/
|
||||
fun updateFromSyncData(
|
||||
steps: Long?,
|
||||
distance: Double?,
|
||||
calories: Double?,
|
||||
avgHeartRate: Int?,
|
||||
restingHeartRate: Int?,
|
||||
sleepMinutes: Int?,
|
||||
weight: Double?,
|
||||
spo2: Double?,
|
||||
exerciseCount: Int
|
||||
) {
|
||||
// Steps: always mark as available if we have any value (including 0)
|
||||
steps?.let { updateMetricStatus("steps", "$it steps", true) }
|
||||
|
||||
// Distance: available if value exists
|
||||
distance?.let { updateMetricStatus("distance", String.format(Locale.US, "%.1f m", it), true) }
|
||||
|
||||
// Calories: available if value exists
|
||||
calories?.let { updateMetricStatus("calories", String.format(Locale.US, "%.0f kcal", it), true) }
|
||||
|
||||
// Heart rate: available if we have a reading
|
||||
avgHeartRate?.let { updateMetricStatus("heart_rate", "$it bpm", it > 0) }
|
||||
restingHeartRate?.let { updateMetricStatus("resting_hr", "$it bpm", it > 0) }
|
||||
|
||||
// Sleep: available if we have duration
|
||||
sleepMinutes?.let {
|
||||
val hours = it / 60
|
||||
val mins = it % 60
|
||||
updateMetricStatus("sleep", "${hours}h ${mins}m", it > 0)
|
||||
}
|
||||
|
||||
// Weight: available if we have a measurement
|
||||
weight?.let { updateMetricStatus("weight", String.format(Locale.US, "%.1f kg", it), it > 0) }
|
||||
|
||||
// SpO2: available if we have a reading
|
||||
spo2?.let { updateMetricStatus("spo2", String.format(Locale.US, "%.0f%%", it), it > 0) }
|
||||
|
||||
// Exercise: available if we have sessions
|
||||
if (exerciseCount > 0) {
|
||||
updateMetricStatus("exercise", "$exerciseCount sessions", true)
|
||||
} else {
|
||||
updateMetricStatus("exercise", "0 sessions", false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark metric as unavailable (no data from Health Connect)
|
||||
*/
|
||||
fun markUnavailable(metricId: String) {
|
||||
val savedStatus = loadSavedStatus().toMutableMap()
|
||||
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
|
||||
|
||||
val existing = savedStatus[metricId]
|
||||
savedStatus[metricId] = MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = existing?.lastSyncTime,
|
||||
lastValue = existing?.lastValue,
|
||||
syncCount = existing?.syncCount ?: 0,
|
||||
isAvailable = false
|
||||
)
|
||||
|
||||
saveStatus(savedStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync history
|
||||
*/
|
||||
fun clearAll() {
|
||||
prefs.edit().remove(KEY_METRICS_STATUS).apply()
|
||||
}
|
||||
|
||||
private fun loadSavedStatus(): Map<String, MetricSyncStatus> {
|
||||
val json = prefs.getString(KEY_METRICS_STATUS, null) ?: return emptyMap()
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, MetricSyncStatus>>() {}.type
|
||||
gson.fromJson(json, type)
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveStatus(status: Map<String, MetricSyncStatus>) {
|
||||
val json = gson.toJson(status)
|
||||
prefs.edit().putString(KEY_METRICS_STATUS, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a type of health metric
|
||||
*/
|
||||
data class MetricType(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val icon: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync status for a specific metric
|
||||
*/
|
||||
data class MetricSyncStatus(
|
||||
val metricType: MetricType,
|
||||
val lastSyncTime: Long?,
|
||||
val lastValue: String?,
|
||||
val syncCount: Int,
|
||||
val isAvailable: Boolean
|
||||
) {
|
||||
fun getFormattedSyncTime(): String {
|
||||
return if (lastSyncTime != null) {
|
||||
val format = SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault())
|
||||
format.format(Date(lastSyncTime))
|
||||
} else {
|
||||
"Never"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusIcon(): String {
|
||||
return when {
|
||||
!isAvailable && lastSyncTime == null -> "⚪" // Never synced
|
||||
!isAvailable -> "🔴" // Was synced but no data available
|
||||
lastSyncTime != null -> "🟢" // Synced with data
|
||||
else -> "⚪"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusText(): String {
|
||||
return when {
|
||||
!isAvailable && lastSyncTime == null -> "Not synced"
|
||||
!isAvailable -> "No data"
|
||||
lastSyncTime != null -> lastValue ?: "Synced"
|
||||
else -> "Pending"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
/**
|
||||
* Privacy Policy Activity - Required by Health Connect
|
||||
* Shows why we need access to health data
|
||||
*/
|
||||
class PrivacyPolicyActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val webView = WebView(this)
|
||||
setContentView(webView)
|
||||
|
||||
webView.loadData(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
h1 { color: #0ea5e9; }
|
||||
h2 { color: #666; margin-top: 24px; }
|
||||
ul { padding-left: 20px; }
|
||||
.highlight {
|
||||
background: #f0f9ff;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HabitForge Companion</h1>
|
||||
<h2>Privacy Policy & Health Data Usage</h2>
|
||||
|
||||
<div class="highlight">
|
||||
<strong>Why we need access to your health data:</strong>
|
||||
<p>HabitForge uses your health data to help you track your fitness progress,
|
||||
analyze your habits, and achieve your wellness goals.</p>
|
||||
</div>
|
||||
|
||||
<h2>Data We Collect</h2>
|
||||
<ul>
|
||||
<li><strong>Activity Data:</strong> Steps, distance, calories burned</li>
|
||||
<li><strong>Heart Rate:</strong> Average, resting, and workout heart rates</li>
|
||||
<li><strong>Sleep:</strong> Duration and sleep stages</li>
|
||||
<li><strong>Body Measurements:</strong> Weight, height, BMI</li>
|
||||
<li><strong>Workouts:</strong> Exercise sessions and types</li>
|
||||
<li><strong>Vitals:</strong> SpO2, respiratory rate</li>
|
||||
</ul>
|
||||
|
||||
<h2>How We Use Your Data</h2>
|
||||
<ul>
|
||||
<li>Sync health metrics to your HabitForge account</li>
|
||||
<li>Display progress charts and statistics</li>
|
||||
<li>Track workout history and achievements</li>
|
||||
<li>Calculate readiness and recovery scores</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Security</h2>
|
||||
<ul>
|
||||
<li>Your data is encrypted during transmission (HTTPS)</li>
|
||||
<li>Data is stored securely on your configured HabitForge server</li>
|
||||
<li>You can delete your data at any time from the app</li>
|
||||
<li>We never sell or share your health data with third parties</li>
|
||||
</ul>
|
||||
|
||||
<h2>Your Rights</h2>
|
||||
<ul>
|
||||
<li>You can revoke permissions at any time</li>
|
||||
<li>You can request deletion of your data</li>
|
||||
<li>You control which data types are shared</li>
|
||||
</ul>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>For questions about your data, contact your HabitForge server administrator.</p>
|
||||
|
||||
<p style="margin-top: 40px; color: #999; font-size: 12px;">
|
||||
Last updated: January 2026
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
"text/html",
|
||||
"UTF-8"
|
||||
)
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* Manages server connection tests and availability checks
|
||||
*/
|
||||
class ServerConnectionManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ServerConnectionManager"
|
||||
private const val CONNECTION_TIMEOUT_MS = 5000
|
||||
private const val READ_TIMEOUT_MS = 5000
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to the server
|
||||
* Returns a detailed result with status and response time
|
||||
*/
|
||||
suspend fun testConnection(): ConnectionTestResult = withContext(Dispatchers.IO) {
|
||||
val prefs = context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
|
||||
val authToken = prefs.getString("auth_token", null)
|
||||
|
||||
Log.d(TAG, "Testing connection to: $serverUrl")
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
// First test basic connectivity
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
|
||||
val healthUrl = URL("$baseUrl/api/health")
|
||||
val connection = healthUrl.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = CONNECTION_TIMEOUT_MS
|
||||
connection.readTimeout = READ_TIMEOUT_MS
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
connection.disconnect()
|
||||
|
||||
when (responseCode) {
|
||||
200 -> {
|
||||
// Now test authentication if token is provided
|
||||
if (!authToken.isNullOrEmpty()) {
|
||||
val authResult = testAuthentication(serverUrl, authToken)
|
||||
if (authResult.authenticated) {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.ONLINE,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server connected & authenticated",
|
||||
authenticated = true
|
||||
)
|
||||
} else {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.AUTH_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server reachable but authentication failed: ${authResult.message}",
|
||||
authenticated = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.ONLINE,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server reachable (no auth token configured)",
|
||||
authenticated = false
|
||||
)
|
||||
}
|
||||
}
|
||||
401, 403 -> ConnectionTestResult(
|
||||
status = ServerStatus.AUTH_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Authentication required (HTTP $responseCode)"
|
||||
)
|
||||
in 500..599 -> ConnectionTestResult(
|
||||
status = ServerStatus.SERVER_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server error (HTTP $responseCode)"
|
||||
)
|
||||
else -> ConnectionTestResult(
|
||||
status = ServerStatus.UNKNOWN_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Unexpected response (HTTP $responseCode)"
|
||||
)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Log.e(TAG, "Connection timeout", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.TIMEOUT,
|
||||
responseTimeMs = CONNECTION_TIMEOUT_MS.toLong(),
|
||||
message = "Connection timeout after ${CONNECTION_TIMEOUT_MS}ms"
|
||||
)
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
Log.e(TAG, "Unknown host", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNREACHABLE,
|
||||
responseTimeMs = 0,
|
||||
message = "Server not found (check URL)"
|
||||
)
|
||||
} catch (e: java.net.ConnectException) {
|
||||
Log.e(TAG, "Connection refused", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNREACHABLE,
|
||||
responseTimeMs = 0,
|
||||
message = "Connection refused (server may be down)"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection test failed", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNKNOWN_ERROR,
|
||||
responseTimeMs = 0,
|
||||
message = "Error: ${e.localizedMessage}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is currently reachable (quick check)
|
||||
*/
|
||||
suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
||||
val prefs = context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
|
||||
|
||||
try {
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
|
||||
val url = URL("$baseUrl/api/health")
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "HEAD"
|
||||
connection.connectTimeout = 3000
|
||||
connection.readTimeout = 3000
|
||||
|
||||
val reachable = connection.responseCode == 200
|
||||
connection.disconnect()
|
||||
reachable
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test authentication with the server
|
||||
*/
|
||||
private suspend fun testAuthentication(serverUrl: String, token: String): AuthTestResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl else "$serverUrl/"
|
||||
val api = HabitForgeApi.create(baseUrl)
|
||||
val response = api.getCurrentUser("Bearer $token")
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val user = response.body()!!
|
||||
AuthTestResult(authenticated = true, message = "Logged in as ${user.username}")
|
||||
} else {
|
||||
AuthTestResult(authenticated = false, message = "HTTP ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AuthTestResult(authenticated = false, message = e.localizedMessage ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server status enumeration
|
||||
*/
|
||||
enum class ServerStatus {
|
||||
ONLINE,
|
||||
OFFLINE,
|
||||
UNREACHABLE,
|
||||
TIMEOUT,
|
||||
AUTH_ERROR,
|
||||
SERVER_ERROR,
|
||||
UNKNOWN_ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a connection test
|
||||
*/
|
||||
data class ConnectionTestResult(
|
||||
val status: ServerStatus,
|
||||
val responseTimeMs: Long = 0,
|
||||
val message: String = "",
|
||||
val authenticated: Boolean = false
|
||||
) {
|
||||
fun getStatusIcon(): String {
|
||||
return when (status) {
|
||||
ServerStatus.ONLINE -> if (authenticated) "✓" else "⚠"
|
||||
ServerStatus.OFFLINE -> "○"
|
||||
ServerStatus.UNREACHABLE -> "✗"
|
||||
ServerStatus.TIMEOUT -> "⏱"
|
||||
ServerStatus.AUTH_ERROR -> "🔒"
|
||||
ServerStatus.SERVER_ERROR -> "⚠"
|
||||
ServerStatus.UNKNOWN_ERROR -> "?"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusColor(): Int {
|
||||
return when (status) {
|
||||
ServerStatus.ONLINE -> if (authenticated) android.R.color.holo_green_dark else android.R.color.holo_orange_dark
|
||||
ServerStatus.OFFLINE -> android.R.color.darker_gray
|
||||
ServerStatus.UNREACHABLE -> android.R.color.holo_red_dark
|
||||
ServerStatus.TIMEOUT -> android.R.color.holo_orange_dark
|
||||
ServerStatus.AUTH_ERROR -> android.R.color.holo_orange_dark
|
||||
ServerStatus.SERVER_ERROR -> android.R.color.holo_red_dark
|
||||
ServerStatus.UNKNOWN_ERROR -> android.R.color.holo_orange_dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of authentication test
|
||||
*/
|
||||
data class AuthTestResult(
|
||||
val authenticated: Boolean,
|
||||
val message: String
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Manages sync history for display in the UI
|
||||
*/
|
||||
class SyncHistoryManager(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("sync_history", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val KEY_HISTORY = "sync_history_list"
|
||||
private const val MAX_HISTORY_ITEMS = 50
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new sync record to history
|
||||
*/
|
||||
fun addSyncRecord(record: SyncRecord) {
|
||||
val history = getHistory().toMutableList()
|
||||
history.add(0, record) // Add to beginning
|
||||
|
||||
// Keep only last MAX_HISTORY_ITEMS
|
||||
while (history.size > MAX_HISTORY_ITEMS) {
|
||||
history.removeAt(history.size - 1)
|
||||
}
|
||||
|
||||
saveHistory(history)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sync history records
|
||||
*/
|
||||
fun getHistory(): List<SyncRecord> {
|
||||
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
|
||||
return try {
|
||||
val type = object : TypeToken<List<SyncRecord>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent sync records (last N items)
|
||||
*/
|
||||
fun getRecentHistory(count: Int = 10): List<SyncRecord> {
|
||||
return getHistory().take(count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync history
|
||||
*/
|
||||
fun clearHistory() {
|
||||
prefs.edit().remove(KEY_HISTORY).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync statistics
|
||||
*/
|
||||
fun getStats(): SyncStats {
|
||||
val history = getHistory()
|
||||
val successful = history.count { it.status == SyncStatus.SUCCESS }
|
||||
val failed = history.count { it.status == SyncStatus.FAILED }
|
||||
val totalMetrics = history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.metricsSynced }
|
||||
val totalExercises = history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.exercisesSynced }
|
||||
|
||||
return SyncStats(
|
||||
totalSyncs = history.size,
|
||||
successfulSyncs = successful,
|
||||
failedSyncs = failed,
|
||||
totalMetricsSynced = totalMetrics,
|
||||
totalExercisesSynced = totalExercises,
|
||||
lastSuccessfulSync = history.firstOrNull { it.status == SyncStatus.SUCCESS }?.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveHistory(history: List<SyncRecord>) {
|
||||
val json = gson.toJson(history)
|
||||
prefs.edit().putString(KEY_HISTORY, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single sync operation record
|
||||
*/
|
||||
data class SyncRecord(
|
||||
val timestamp: Long,
|
||||
val status: SyncStatus,
|
||||
val metricsSynced: Int = 0,
|
||||
val exercisesSynced: Int = 0,
|
||||
val errorMessage: String? = null,
|
||||
val syncDurationMs: Long = 0
|
||||
) {
|
||||
fun getFormattedTimestamp(): String {
|
||||
val dateFormat = SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault())
|
||||
return dateFormat.format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun getStatusIcon(): String {
|
||||
return when (status) {
|
||||
SyncStatus.SUCCESS -> "✓"
|
||||
SyncStatus.FAILED -> "✗"
|
||||
SyncStatus.PARTIAL -> "⚠"
|
||||
SyncStatus.IN_PROGRESS -> "⟳"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusColor(): Int {
|
||||
return when (status) {
|
||||
SyncStatus.SUCCESS -> android.R.color.holo_green_dark
|
||||
SyncStatus.FAILED -> android.R.color.holo_red_dark
|
||||
SyncStatus.PARTIAL -> android.R.color.holo_orange_dark
|
||||
SyncStatus.IN_PROGRESS -> android.R.color.holo_blue_dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync status enumeration
|
||||
*/
|
||||
enum class SyncStatus {
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
PARTIAL,
|
||||
IN_PROGRESS
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated sync statistics
|
||||
*/
|
||||
data class SyncStats(
|
||||
val totalSyncs: Int,
|
||||
val successfulSyncs: Int,
|
||||
val failedSyncs: Int,
|
||||
val totalMetricsSynced: Int,
|
||||
val totalExercisesSynced: Int,
|
||||
val lastSuccessfulSync: Long?
|
||||
) {
|
||||
fun getSuccessRate(): Float {
|
||||
return if (totalSyncs > 0) (successfulSyncs.toFloat() / totalSyncs) * 100 else 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Sync Service - Handles reading from Health Connect and pushing to backend
|
||||
* Uses WorkManager for reliable background syncing
|
||||
*/
|
||||
class SyncService(
|
||||
context: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncService"
|
||||
private const val WORK_NAME = "health_connect_sync"
|
||||
private const val SYNC_DAYS = 365 // Sync last year of data to ensure history is complete
|
||||
|
||||
// Sync period options in minutes
|
||||
const val SYNC_PERIOD_5_MINS = 5
|
||||
const val SYNC_PERIOD_10_MINS = 10
|
||||
const val SYNC_PERIOD_15_MINS = 15
|
||||
const val SYNC_PERIOD_30_MINS = 30
|
||||
const val SYNC_PERIOD_45_MINS = 45
|
||||
const val SYNC_PERIOD_1_HOUR = 60
|
||||
const val SYNC_PERIOD_3_HOURS = 180
|
||||
const val SYNC_PERIOD_6_HOURS = 360
|
||||
const val SYNC_PERIOD_12_HOURS = 720
|
||||
const val SYNC_PERIOD_24_HOURS = 1440
|
||||
|
||||
// Preference keys
|
||||
const val PREF_SYNC_PERIOD_MINUTES = "sync_period_minutes"
|
||||
|
||||
/**
|
||||
* Schedule periodic sync with configurable interval
|
||||
*/
|
||||
fun schedulePeriodicSync(context: Context, periodMinutes: Int = getSyncPeriod(context)) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
|
||||
// Note: WorkManager enforces a minimum interval of 15 minutes
|
||||
val syncWork = PeriodicWorkRequestBuilder<SyncService>(
|
||||
periodMinutes.toLong(), TimeUnit.MINUTES,
|
||||
5, TimeUnit.MINUTES // Flex interval
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE, // UPDATE to apply new period
|
||||
syncWork
|
||||
)
|
||||
|
||||
Log.i(TAG, "Periodic sync scheduled every $periodMinutes minutes")
|
||||
|
||||
// Save the period setting
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putInt(PREF_SYNC_PERIOD_MINUTES, periodMinutes)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate one-time sync
|
||||
*/
|
||||
fun syncNow(context: Context, days: Int = SYNC_DAYS) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val syncWork = OneTimeWorkRequestBuilder<SyncService>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(workDataOf("days" to days))
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(syncWork)
|
||||
Log.i(TAG, "Immediate sync triggered for $days days")
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all scheduled syncs
|
||||
*/
|
||||
fun cancelSync(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.i(TAG, "Sync cancelled")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current sync period setting
|
||||
*/
|
||||
fun getSyncPeriod(context: Context): Int {
|
||||
return context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.getInt(PREF_SYNC_PERIOD_MINUTES, SYNC_PERIOD_6_HOURS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available sync period options
|
||||
*/
|
||||
fun getSyncPeriodOptions(): List<SyncPeriodOption> {
|
||||
return listOf(
|
||||
SyncPeriodOption(SYNC_PERIOD_5_MINS, "Every 5 minutes (clamped to 15)"),
|
||||
SyncPeriodOption(SYNC_PERIOD_10_MINS, "Every 10 minutes (clamped to 15)"),
|
||||
SyncPeriodOption(SYNC_PERIOD_15_MINS, "Every 15 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_30_MINS, "Every 30 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_45_MINS, "Every 45 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_1_HOUR, "Every hour"),
|
||||
SyncPeriodOption(SYNC_PERIOD_3_HOURS, "Every 3 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_6_HOURS, "Every 6 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_12_HOURS, "Every 12 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_24_HOURS, "Once a day")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted period string
|
||||
*/
|
||||
fun getFormattedPeriod(context: Context): String {
|
||||
val minutes = getSyncPeriod(context)
|
||||
return getSyncPeriodOptions().find { it.minutes == minutes }?.label ?: "Every $minutes minutes"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
Log.i(TAG, "Starting Health Connect sync...")
|
||||
val startTime = System.currentTimeMillis()
|
||||
val syncHistoryManager = SyncHistoryManager(applicationContext)
|
||||
|
||||
try {
|
||||
val healthConnectManager = HabitForgeApp.instance.healthConnectManager
|
||||
|
||||
// Check availability
|
||||
if (healthConnectManager.isAvailable() != HealthConnectAvailability.AVAILABLE) {
|
||||
Log.e(TAG, "Health Connect not available")
|
||||
recordSyncFailure(syncHistoryManager, startTime, "Health Connect not available")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (!healthConnectManager.hasAllPermissions()) {
|
||||
Log.e(TAG, "Missing Health Connect permissions")
|
||||
recordSyncFailure(syncHistoryManager, startTime, "Missing permissions")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
// Get auth token from preferences
|
||||
val prefs = applicationContext.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
val authToken = prefs.getString("auth_token", null)
|
||||
|
||||
if (authToken.isNullOrEmpty()) {
|
||||
Log.e(TAG, "No auth token configured")
|
||||
recordSyncFailure(syncHistoryManager, startTime, "No auth token")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// Determine sync range
|
||||
val days = inputData.getInt("days", SYNC_DAYS)
|
||||
val endDate = LocalDate.now()
|
||||
val startDate = endDate.minusDays(days.toLong())
|
||||
|
||||
Log.i(TAG, "Reading data from $startDate to $endDate ($days days)")
|
||||
|
||||
// Read daily metrics
|
||||
val dailyMetrics = healthConnectManager.readDailyMetrics(startDate, endDate)
|
||||
Log.i(TAG, "Read ${dailyMetrics.size} days of metrics")
|
||||
|
||||
// Log sample data for debugging
|
||||
if (dailyMetrics.isNotEmpty()) {
|
||||
val sample = dailyMetrics.first()
|
||||
Log.d(TAG, "Sample metric: date=${sample.date}, steps=${sample.steps}, calories=${sample.caloriesBurned}")
|
||||
} else {
|
||||
Log.w(TAG, "No daily metrics returned from Health Connect!")
|
||||
}
|
||||
|
||||
// Read exercise sessions
|
||||
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
|
||||
Log.i(TAG, "Read ${exercises.size} exercise sessions")
|
||||
|
||||
if (exercises.isNotEmpty()) {
|
||||
val sample = exercises.first()
|
||||
Log.d(TAG, "Sample exercise: type=${sample.exerciseTypeName}, duration=${sample.durationMinutes}min")
|
||||
}
|
||||
|
||||
// Convert to API payload
|
||||
val metricsPayload = dailyMetrics.map { metric ->
|
||||
DailyMetricsPayload(
|
||||
date = metric.date,
|
||||
step_count = metric.steps.toInt(),
|
||||
distance_meters = metric.distanceMeters,
|
||||
calories_burned = metric.caloriesBurned,
|
||||
total_calories = metric.caloriesBurned,
|
||||
avg_heart_rate = metric.avgHeartRate,
|
||||
min_heart_rate = metric.minHeartRate,
|
||||
max_heart_rate = metric.maxHeartRate,
|
||||
resting_heart_rate = metric.restingHeartRate,
|
||||
sleep_duration_minutes = metric.sleepDurationMinutes,
|
||||
deep_sleep_minutes = metric.deepSleepMinutes,
|
||||
light_sleep_minutes = metric.lightSleepMinutes,
|
||||
rem_sleep_minutes = metric.remSleepMinutes,
|
||||
awake_duration_minutes = metric.awakeMinutes,
|
||||
weight = metric.weight,
|
||||
avg_spo2 = metric.spo2,
|
||||
min_spo2 = metric.spo2,
|
||||
max_spo2 = metric.spo2
|
||||
)
|
||||
}
|
||||
|
||||
val exercisesPayload = exercises.map { exercise ->
|
||||
ExerciseSessionPayload(
|
||||
start_time = exercise.startTime,
|
||||
end_time = exercise.endTime,
|
||||
exercise_type = exercise.exerciseType,
|
||||
exercise_type_name = exercise.exerciseTypeName,
|
||||
duration_seconds = exercise.durationMinutes * 60,
|
||||
title = exercise.title,
|
||||
notes = exercise.notes
|
||||
)
|
||||
}
|
||||
|
||||
// Get device ID
|
||||
val deviceId = Settings.Secure.getString(
|
||||
applicationContext.contentResolver,
|
||||
Settings.Secure.ANDROID_ID
|
||||
)
|
||||
|
||||
// Build request
|
||||
val request = HealthConnectSyncRequest(
|
||||
device_id = deviceId,
|
||||
sync_timestamp = Instant.now().toString(),
|
||||
daily_metrics = metricsPayload,
|
||||
exercise_sessions = exercisesPayload
|
||||
)
|
||||
|
||||
// Log request details for debugging
|
||||
Log.i(TAG, "=== Preparing Sync Request ===")
|
||||
Log.i(TAG, "Payload: ${metricsPayload.size} metrics, ${exercisesPayload.size} exercises")
|
||||
if (metricsPayload.isNotEmpty()) {
|
||||
val sample = metricsPayload.first()
|
||||
Log.d(TAG, "Metric payload sample: date=${sample.date}, steps=${sample.step_count}, calories=${sample.calories_burned}")
|
||||
}
|
||||
if (exercisesPayload.isNotEmpty()) {
|
||||
val sample = exercisesPayload.first()
|
||||
Log.d(TAG, "Exercise payload sample: type=${sample.exercise_type_name}, duration=${sample.duration_seconds}s")
|
||||
}
|
||||
|
||||
// Send to backend
|
||||
val backendUrl = prefs.getString("server_url", BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
|
||||
Log.i(TAG, "Sending to: $backendUrl/api/health-connect/sync")
|
||||
val api = HabitForgeApi.create(backendUrl)
|
||||
|
||||
val response = api.syncHealthData("Bearer $authToken", request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
Log.i(TAG, "=== Sync Response ===")
|
||||
Log.i(TAG, "Status: ${body?.status}")
|
||||
Log.i(TAG, "Sync ID: ${body?.sync_id}")
|
||||
Log.i(TAG, "Metrics synced: ${body?.metrics_synced}")
|
||||
Log.i(TAG, "Exercises synced: ${body?.exercises_synced}")
|
||||
Log.i(TAG, "Message: ${body?.message}")
|
||||
|
||||
// Save last sync time
|
||||
prefs.edit().putLong("last_sync", System.currentTimeMillis()).apply()
|
||||
|
||||
// Update metrics sync tracker with best values from last few days
|
||||
if (dailyMetrics.isNotEmpty()) {
|
||||
val sorted = dailyMetrics.sortedByDescending { it.date }
|
||||
val stepsMetric = sorted.firstOrNull { it.steps > 0 } ?: sorted.first()
|
||||
val bestDistance = sorted.firstOrNull { it.distanceMeters > 0 }?.distanceMeters ?: 0.0
|
||||
val bestCalories = sorted.firstOrNull { it.caloriesBurned > 0 }?.caloriesBurned ?: 0.0
|
||||
|
||||
val metricsTracker = MetricsSyncTracker(applicationContext)
|
||||
metricsTracker.updateFromSyncData(
|
||||
steps = stepsMetric.steps,
|
||||
distance = bestDistance,
|
||||
calories = bestCalories,
|
||||
avgHeartRate = sorted.mapNotNull { it.avgHeartRate }.filter { it > 0 }.firstOrNull(),
|
||||
restingHeartRate = sorted.mapNotNull { it.restingHeartRate }.filter { it > 0 }.firstOrNull(),
|
||||
sleepMinutes = sorted.filter { it.sleepDurationMinutes > 0 }.firstOrNull()?.sleepDurationMinutes,
|
||||
weight = sorted.mapNotNull { it.weight }.filter { it > 0 }.firstOrNull(),
|
||||
spo2 = sorted.mapNotNull { it.spo2 }.filter { it > 0 }.firstOrNull(),
|
||||
exerciseCount = exercises.size
|
||||
)
|
||||
}
|
||||
|
||||
// Record successful sync in history
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
syncHistoryManager.addSyncRecord(
|
||||
SyncRecord(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
status = SyncStatus.SUCCESS,
|
||||
metricsSynced = body?.metrics_synced ?: dailyMetrics.size,
|
||||
exercisesSynced = body?.exercises_synced ?: exercises.size,
|
||||
syncDurationMs = duration
|
||||
)
|
||||
)
|
||||
|
||||
return@withContext Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Sync failed with code: ${response.code()}")
|
||||
recordSyncFailure(syncHistoryManager, startTime, "Server returned ${response.code()}")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Sync error", e)
|
||||
recordSyncFailure(syncHistoryManager, startTime, e.localizedMessage ?: "Unknown error")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordSyncFailure(manager: SyncHistoryManager, startTime: Long, errorMessage: String) {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
manager.addSyncRecord(
|
||||
SyncRecord(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
status = SyncStatus.FAILED,
|
||||
errorMessage = errorMessage,
|
||||
syncDurationMs = duration
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync period option for UI selection
|
||||
*/
|
||||
data class SyncPeriodOption(
|
||||
val minutes: Int,
|
||||
val label: String
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M7,13l2,2l6,-6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Heart shape -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,78.35l-2.18,-1.98C41.1,67.04 35,61.42 35,54.25c0,-5.82 4.64,-10.5 10.5,-10.5c3.26,0 6.39,1.52 8.5,3.93c2.11,-2.41 5.24,-3.93 8.5,-3.93c5.86,0 10.5,4.68 10.5,10.5c0,7.17 -6.1,12.79 -16.82,22.15L54,78.35z"/>
|
||||
<!-- Pulse line -->
|
||||
<path
|
||||
android:strokeColor="#0ea5e9"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M35,54 L45,54 L48,48 L51,60 L54,51 L57,57 L60,54 L73,54"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F3F4F6" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#EFF6FF" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#ECFDF5" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FEF2F2" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#9CA3AF" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#10b981" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#f59e0b" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#ef4444" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,787 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F5F5F5"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_heart_pulse"
|
||||
app:tint="#0ea5e9" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="HabitForge"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect Companion"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Health Connect Status Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusHealthConnect"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<!-- Available Layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutAvailable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnOpenHealthConnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Open Health Connect"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Unavailable Layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutUnavailable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvUnavailableMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect needs to be installed to sync your health data."
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnInstallHealthConnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Install Health Connect"
|
||||
android:textAllCaps="false"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Permissions Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Permissions"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusPermissions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRequestPermissions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Grant Permissions"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#0ea5e9"
|
||||
android:layout_marginTop="12dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Server Configuration Card with Status Indicator -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Server"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<!-- Server Availability Indicator -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutServerIndicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="4dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/serverStatusDot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:background="@drawable/status_dot_gray" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerAvailability"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Unknown"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginStart="6dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusServer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerUrl"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<!-- Server response time -->
|
||||
<TextView
|
||||
android:id="@+id/tvServerResponseTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="11sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnConfigureServer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Login"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTestConnection"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Test Connection"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#6366f1" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Sync Settings Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Settings"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Frequency:"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerSyncPeriod"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLastSync"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Never synced"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSyncNow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Now"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#10b981"
|
||||
android:enabled="false" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressSync"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
android:indeterminateTint="#FFFFFF" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Metrics Overview Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Metrics Overview"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsLastUpdate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#9CA3AF" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsSummary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="View all available health metrics"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<!-- Metrics Grid -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMetricsGrid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<!-- Row 1: Steps, Distance, Calories -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSteps"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricDistance"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricCalories"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 2: Heart Rate, Resting HR, Sleep -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricHeartRate"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricRestingHR"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSleep"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 3: Weight, SpO2, Exercise -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricWeight"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSpO2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricExercise"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRefreshMetrics"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Refresh Metrics"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Data Compatibility Validation Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Data Compatibility"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusDataCompatibility"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Not validated"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCompatibilityDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validate that your Health Connect data can be synced with the HabitForge schema."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnValidateData"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validate Data Schema"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#f59e0b"
|
||||
android:layout_marginTop="12dp" />
|
||||
|
||||
<!-- Validation Results (hidden by default) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutValidationResults"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="12dp"
|
||||
android:padding="12dp"
|
||||
android:background="@drawable/rounded_background">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validation Results"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#374151" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvValidationResultsList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Sync History Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Sync History"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSyncStats"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutSyncStats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_green">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSuccessCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#10b981" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Successful"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_red">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFailedCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#ef4444" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Failed"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_blue">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsSynced"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#0ea5e9" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Metrics"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Recent Sync List -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Recent Syncs"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutRecentSyncs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNoSyncHistory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="No sync history yet"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:gravity="center"
|
||||
android:padding="16dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnViewAllHistory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="View Full History"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Info Section -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="This app reads your health data from Health Connect and syncs it to your HabitForge dashboard. Keep it installed for automatic background syncing."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAlignment="center"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,164 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📊"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Metric History"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistorySubtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="All synced data from Health Connect"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnCloseHistory"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||
android:contentDescription="Close" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:background="#F3F4F6"
|
||||
android:padding="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryDataPoints"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#10B981" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Data Points"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#D1D5DB" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryLatestValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#3B82F6" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Latest Value"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#D1D5DB" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryAverage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#8B5CF6" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Average"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- History List -->
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutHistoryList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Server URL -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Server URL"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etServerUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="http://192.168.x.x:8000"
|
||||
android:inputType="textUri"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Username -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Username"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etUsername"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Enter your username"
|
||||
android:inputType="text"
|
||||
android:autofillHints="username"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Password -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Password"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Enter your password"
|
||||
android:inputType="textPassword"
|
||||
android:autofillHints="password"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<!-- Helper text -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Use the same credentials as the HabitForge web app"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#888888"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Error message (hidden by default) -->
|
||||
<TextView
|
||||
android:id="@+id/tvError"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#D32F2F"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Loading indicator (hidden by default) -->
|
||||
<ProgressBar
|
||||
android:id="@+id/progressLogin"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryDate"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Jan 01, 2026"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#374151" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="10,000"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryUnit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=" steps"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginStart="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background">
|
||||
|
||||
<!-- Status indicator and icon -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="👣"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚪"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Metric name -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Steps"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#374151"
|
||||
android:layout_marginTop="4dp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Last value or status -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Not synced"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Last sync time -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricSyncTime"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="8sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="brand_primary">#0ea5e9</color>
|
||||
<color name="brand_secondary">#10b981</color>
|
||||
<color name="brand_dark">#0c4a6e</color>
|
||||
|
||||
<color name="background_light">#F5F5F5</color>
|
||||
<color name="background_dark">#1F2937</color>
|
||||
|
||||
<color name="text_primary">#1F2937</color>
|
||||
<color name="text_secondary">#6B7280</color>
|
||||
<color name="text_muted">#9CA3AF</color>
|
||||
|
||||
<color name="success">#10b981</color>
|
||||
<color name="warning">#f59e0b</color>
|
||||
<color name="error">#ef4444</color>
|
||||
|
||||
<!-- Status colors used in MainActivity -->
|
||||
<color name="green">#10b981</color>
|
||||
<color name="orange">#f59e0b</color>
|
||||
<color name="red">#ef4444</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">HabitForge-companion</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.HabitForgeCompanion" parent="Theme.Material3.Light.NoActionBar">
|
||||
<item name="colorPrimary">@color/brand_primary</item>
|
||||
<item name="colorPrimaryVariant">@color/brand_dark</item>
|
||||
<item name="colorSecondary">@color/brand_secondary</item>
|
||||
<item name="android:statusBarColor">@color/brand_primary</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!--
|
||||
Allow cleartext HTTP traffic for development.
|
||||
In production, you should use HTTPS only.
|
||||
-->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,3 @@
|
||||
#Auto-incremented Build Number
|
||||
#Sat Jan 03 11:40:28 EST 2026
|
||||
build.number=18
|
||||
@@ -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,54 @@
|
||||
Initialized native services in: C:\Users\bruno\.gradle\native
|
||||
Initialized jansi services in: C:\Users\bruno\.gradle\native
|
||||
The client will now receive all logging from the daemon (pid: 40576). The daemon log file: C:\Users\bruno\.gradle\daemon\8.13\daemon-40576.out.log
|
||||
Starting 7th build in daemon [uptime: 3 mins 13.508 secs, performance: 100%, GC rate: 0.00/s, heap usage: 0% of 2 GiB]
|
||||
Using 16 worker leases.
|
||||
Now considering [C:\dev\git\python\HabitForge\android-companion] as hierarchies to watch
|
||||
Watching the file system is configured to be enabled if available
|
||||
File system watching is active
|
||||
Starting Build
|
||||
Settings evaluated using settings file 'C:\dev\git\python\HabitForge\android-companion\settings.gradle.kts'.
|
||||
Projects loaded. Root project using build file 'C:\dev\git\python\HabitForge\android-companion\build.gradle.kts'.
|
||||
Included projects: [root project 'HabitForgeCompanion', project ':app']
|
||||
|
||||
> Configure project :
|
||||
Evaluating root project 'HabitForgeCompanion' using build file 'C:\dev\git\python\HabitForge\android-companion\build.gradle.kts'.
|
||||
Resolved plugin [id: 'com.android.application', version: '8.13.2', apply: false]
|
||||
Resolved plugin [id: 'org.jetbrains.kotlin.android', version: '1.9.22', apply: false]
|
||||
|
||||
> Configure project :app
|
||||
Evaluating project ':app' using build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts'.
|
||||
Resolved plugin [id: 'com.android.application']
|
||||
Resolved plugin [id: 'org.jetbrains.kotlin.android']
|
||||
|
||||
[Incubating] Problems report is available at: file:///C:/dev/git/python/HabitForge/android-companion/build/reports/problems/problems-report.html
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* Where:
|
||||
Build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts' line: 6
|
||||
|
||||
* What went wrong:
|
||||
An exception occurred applying plugin request [id: 'com.android.application']
|
||||
> Failed to apply plugin 'com.android.internal.application'.
|
||||
> Android Gradle plugin requires Java 17 to run. You are currently using Java 11.
|
||||
Your current JDK is located in C:\Users\bruno\scoop\apps\temurin11-jdk\current
|
||||
You can try some of the following options:
|
||||
- changing the IDE settings.
|
||||
- changing the JAVA_HOME environment variable.
|
||||
- changing `org.gradle.java.home` in `gradle.properties`.
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> Run with --debug option to get more log output.
|
||||
> Run with --scan to get full insights.
|
||||
> Get more help at https://help.gradle.org.
|
||||
|
||||
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
|
||||
|
||||
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
|
||||
|
||||
For more on this, please refer to https://docs.gradle.org/8.13/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
|
||||
|
||||
BUILD FAILED in 3s
|
||||
Watched directory hierarchies: []
|
||||
@@ -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,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,63 @@
|
||||
"""Authentication endpoints for API v1."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from backend.models.user import User
|
||||
from backend import auth
|
||||
from backend.schemas.user import UserCreate, UserResponse
|
||||
from backend.schemas.token import Token
|
||||
from backend.database import get_db
|
||||
from backend.core.config import settings
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
|
||||
def register(request: Request, user: UserCreate, db: Session = Depends(get_db)):
|
||||
"""Register a new user."""
|
||||
db_user = db.query(User).filter(User.username == user.username).first()
|
||||
if db_user:
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
|
||||
hashed_password = auth.get_password_hash(user.password)
|
||||
new_user = User(username=user.username, hashed_password=hashed_password)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
@limiter.limit(f"{settings.RATE_LIMIT_TIMES}/{settings.RATE_LIMIT_SECONDS}s")
|
||||
def login_for_access_token(
|
||||
request: Request,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Login and get access token."""
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
if not user or not auth.verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
from datetime import timedelta
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def read_users_me(current_user: User = Depends(auth.get_current_user)):
|
||||
"""Get current user information."""
|
||||
return current_user
|
||||
@@ -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,215 @@
|
||||
"""Health check and metric endpoints for API v1."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Optional
|
||||
import datetime
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models.daily_health_metrics import DailyHealthMetrics
|
||||
from backend.models.user import User
|
||||
from backend.models.workout_session import WorkoutSession
|
||||
from backend.schemas.daily_health_metrics import DailyHealthMetricsResponse
|
||||
from backend import auth
|
||||
|
||||
router = APIRouter(prefix="/api/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get("", status_code=status.HTTP_200_OK)
|
||||
def health_check(db: Session = Depends(get_db)):
|
||||
"""Check if the application and database are healthy."""
|
||||
try:
|
||||
# Check database connection
|
||||
db.execute(text("SELECT 1"))
|
||||
return {
|
||||
"status": "healthy",
|
||||
"database": "connected"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats", response_model=List[DailyHealthMetricsResponse])
|
||||
def get_health_stats(
|
||||
period: str = "week",
|
||||
ref_date: Optional[datetime.date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Retrieve health statistics for various periods."""
|
||||
if ref_date is None:
|
||||
ref_date = datetime.date.today()
|
||||
|
||||
if period == "day":
|
||||
return db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date == ref_date
|
||||
).all()
|
||||
|
||||
elif period == "week":
|
||||
start_date = ref_date - datetime.timedelta(days=6)
|
||||
return db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date.between(start_date, ref_date)
|
||||
).order_by(DailyHealthMetrics.date.asc()).all()
|
||||
|
||||
elif period == "month":
|
||||
start_date = ref_date - datetime.timedelta(days=29)
|
||||
raw_data = db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date.between(start_date, ref_date)
|
||||
).order_by(DailyHealthMetrics.date.asc()).all()
|
||||
|
||||
aggregated = []
|
||||
import itertools
|
||||
key_func = lambda x: x.date.isocalendar()[1]
|
||||
|
||||
for key, group in itertools.groupby(raw_data, key_func):
|
||||
g = list(group)
|
||||
first = g[0]
|
||||
agg = {
|
||||
"id": key,
|
||||
"user_id": current_user.id,
|
||||
"date": first.date,
|
||||
"step_count": sum(x.step_count for x in g) // len(g),
|
||||
"step_count_total": sum(x.step_count for x in g),
|
||||
"calories_burned": sum(x.calories_burned for x in g) / len(g),
|
||||
"distance_meters": sum(x.distance_meters for x in g) / len(g),
|
||||
"sleep_duration_minutes": sum(x.sleep_duration_minutes for x in g) // len(g),
|
||||
"deep_sleep_minutes": sum(x.deep_sleep_minutes for x in g) // len(g),
|
||||
"light_sleep_minutes": sum(getattr(x, 'light_sleep_minutes', 0) for x in g) // len(g),
|
||||
"rem_sleep_minutes": sum(getattr(x, 'rem_sleep_minutes', 0) for x in g) // len(g),
|
||||
"awake_duration_minutes": sum(x.awake_duration_minutes for x in g) // len(g),
|
||||
"pai_score": sum(x.pai_score for x in g) / len(g),
|
||||
"avg_heart_rate": sum(x.avg_heart_rate or 0 for x in g) // len(g),
|
||||
"avg_spo2": sum(x.avg_spo2 or 0 for x in g) / len(g),
|
||||
"min_heart_rate": min((x.min_heart_rate for x in g if x.min_heart_rate), default=0),
|
||||
"max_heart_rate": max((x.max_heart_rate for x in g if x.max_heart_rate), default=0)
|
||||
}
|
||||
aggregated.append(agg)
|
||||
|
||||
return aggregated
|
||||
|
||||
elif period == "year":
|
||||
start_date = datetime.date.today() - datetime.timedelta(days=365)
|
||||
raw_data = db.query(DailyHealthMetrics).filter(
|
||||
DailyHealthMetrics.user_id == current_user.id,
|
||||
DailyHealthMetrics.date >= start_date
|
||||
).order_by(DailyHealthMetrics.date.asc()).all()
|
||||
|
||||
aggregated = []
|
||||
import itertools
|
||||
key_func = lambda x: x.date.month
|
||||
|
||||
for key, group in itertools.groupby(raw_data, key_func):
|
||||
g = list(group)
|
||||
first = g[0]
|
||||
agg = {
|
||||
"id": key,
|
||||
"user_id": current_user.id,
|
||||
"date": first.date.replace(day=1),
|
||||
"step_count": sum(x.step_count for x in g) // len(g),
|
||||
"step_count_total": sum(x.step_count for x in g),
|
||||
"calories_burned": sum(x.calories_burned for x in g) / len(g),
|
||||
"distance_meters": sum(x.distance_meters for x in g) / len(g),
|
||||
"sleep_duration_minutes": sum(x.sleep_duration_minutes for x in g) // len(g),
|
||||
"deep_sleep_minutes": sum(x.deep_sleep_minutes for x in g) // len(g),
|
||||
"light_sleep_minutes": sum(getattr(x, 'light_sleep_minutes', 0) for x in g) // len(g),
|
||||
"rem_sleep_minutes": sum(getattr(x, 'rem_sleep_minutes', 0) for x in g) // len(g),
|
||||
"awake_duration_minutes": sum(x.awake_duration_minutes for x in g) // len(g),
|
||||
"pai_score": sum(x.pai_score for x in g) / len(g),
|
||||
"avg_heart_rate": sum(x.avg_heart_rate or 0 for x in g) // len(g),
|
||||
"avg_spo2": sum(x.avg_spo2 or 0 for x in g) / len(g),
|
||||
"min_heart_rate": min((x.min_heart_rate for x in g if x.min_heart_rate), default=0),
|
||||
"max_heart_rate": max((x.max_heart_rate for x in g if x.max_heart_rate), default=0)
|
||||
}
|
||||
aggregated.append(agg)
|
||||
|
||||
return aggregated
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/workouts")
|
||||
def get_workouts(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
activity_type: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Retrieve workout sessions for the current user."""
|
||||
query = db.query(WorkoutSession).filter(
|
||||
WorkoutSession.user_id == current_user.id
|
||||
)
|
||||
|
||||
if activity_type:
|
||||
query = query.filter(WorkoutSession.activity_type == activity_type)
|
||||
|
||||
workouts = query.order_by(
|
||||
WorkoutSession.start_time.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
# Manually serialize to avoid Pydantic issues
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"user_id": w.user_id,
|
||||
"start_time": w.start_time.isoformat() if w.start_time else None,
|
||||
"end_time": w.end_time.isoformat() if w.end_time else None,
|
||||
"activity_type": w.activity_type,
|
||||
"activity_type_id": w.activity_type_id,
|
||||
"duration_seconds": w.duration_seconds or 0,
|
||||
"distance_meters": w.distance_meters or 0.0,
|
||||
"calories": w.calories or 0.0,
|
||||
"avg_pace": w.avg_pace,
|
||||
"max_pace": w.max_pace,
|
||||
"min_pace": w.min_pace,
|
||||
"avg_hr": w.avg_hr,
|
||||
"max_hr": w.max_hr,
|
||||
"min_hr": w.min_hr,
|
||||
"notes": w.notes
|
||||
}
|
||||
for w in workouts
|
||||
]
|
||||
|
||||
|
||||
@router.get("/workouts/summary")
|
||||
def get_workout_summary(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get summary statistics for workouts."""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Total counts
|
||||
total = db.query(func.count(WorkoutSession.id)).filter(
|
||||
WorkoutSession.user_id == current_user.id
|
||||
).scalar()
|
||||
|
||||
# By activity type
|
||||
by_type = db.query(
|
||||
WorkoutSession.activity_type,
|
||||
func.count(WorkoutSession.id).label('count'),
|
||||
func.sum(WorkoutSession.duration_seconds).label('total_duration'),
|
||||
func.sum(WorkoutSession.calories).label('total_calories'),
|
||||
func.sum(WorkoutSession.distance_meters).label('total_distance')
|
||||
).filter(
|
||||
WorkoutSession.user_id == current_user.id
|
||||
).group_by(WorkoutSession.activity_type).all()
|
||||
|
||||
return {
|
||||
"total_workouts": total,
|
||||
"by_activity_type": [
|
||||
{
|
||||
"activity_type": row[0],
|
||||
"count": row[1],
|
||||
"total_duration_seconds": row[2] or 0,
|
||||
"total_calories": row[3] or 0,
|
||||
"total_distance_meters": row[4] or 0
|
||||
}
|
||||
for row in by_type
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Zepp API Router - Endpoints for Zepp/Amazfit data synchronization.
|
||||
|
||||
Provides endpoints for connecting to Zepp account and syncing health data.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import DailyHealthMetrics, User, WorkoutSession
|
||||
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
|
||||
from backend.integrations.zepp_api import ZeppApiClient, ZeppApiError, sync_zepp_data
|
||||
from backend import auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/zepp", tags=["zepp"])
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
class ZeppConnectRequest(BaseModel):
|
||||
"""Request to connect a Zepp account."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class ZeppConnectResponse(BaseModel):
|
||||
"""Response after connecting Zepp account."""
|
||||
status: str
|
||||
user_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class ZeppSyncRequest(BaseModel):
|
||||
"""Request to sync data from Zepp."""
|
||||
days: int = 30
|
||||
|
||||
|
||||
class ZeppSyncResponse(BaseModel):
|
||||
"""Response after syncing Zepp data."""
|
||||
status: str
|
||||
days_synced: int
|
||||
workouts_synced: int
|
||||
message: str
|
||||
|
||||
|
||||
class ZeppStatusResponse(BaseModel):
|
||||
"""Response with Zepp connection status."""
|
||||
connected: bool
|
||||
zepp_user_id: Optional[str] = None
|
||||
last_sync: Optional[datetime] = None
|
||||
|
||||
|
||||
# --- In-memory token storage (per user) ---
|
||||
# In production, this should be stored in the database
|
||||
_zepp_clients: dict[int, ZeppApiClient] = {}
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/connect", response_model=ZeppConnectResponse)
|
||||
async def connect_zepp(
|
||||
request: ZeppConnectRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Connect user's Zepp account.
|
||||
|
||||
Authenticates with Huami servers and stores the session for data syncing.
|
||||
"""
|
||||
try:
|
||||
client = ZeppApiClient(request.email, request.password)
|
||||
auth_result = client.authenticate()
|
||||
|
||||
# Store client for this user
|
||||
_zepp_clients[current_user.id] = client
|
||||
|
||||
logger.info(f"User {current_user.id} connected Zepp account: {auth_result['user_id']}")
|
||||
|
||||
return ZeppConnectResponse(
|
||||
status="success",
|
||||
user_id=auth_result["user_id"],
|
||||
message="Successfully connected to Zepp account"
|
||||
)
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Zepp connection failed for user {current_user.id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error connecting Zepp: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to connect to Zepp"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=ZeppSyncResponse)
|
||||
async def sync_zepp(
|
||||
request: ZeppSyncRequest = ZeppSyncRequest(),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Sync health data from connected Zepp account.
|
||||
|
||||
Fetches daily metrics (steps, sleep, heart rate, SpO2) and workout sessions.
|
||||
"""
|
||||
# Check if user has connected their Zepp account
|
||||
client = _zepp_clients.get(current_user.id)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Zepp account not connected. Please connect first."
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch data from Zepp
|
||||
sync_result = sync_zepp_data(client, days=request.days)
|
||||
|
||||
# Save daily metrics to database
|
||||
metrics_saved = 0
|
||||
for entry in sync_result.get("daily_metrics", []):
|
||||
saved = _save_daily_metrics(db, current_user.id, entry)
|
||||
if saved:
|
||||
metrics_saved += 1
|
||||
|
||||
# Save workouts to database
|
||||
workouts_saved = 0
|
||||
for workout in sync_result.get("workouts", []):
|
||||
saved = _save_workout(db, current_user.id, workout)
|
||||
if saved:
|
||||
workouts_saved += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.id} synced {metrics_saved} days, {workouts_saved} workouts from Zepp"
|
||||
)
|
||||
|
||||
return ZeppSyncResponse(
|
||||
status="success",
|
||||
days_synced=metrics_saved,
|
||||
workouts_synced=workouts_saved,
|
||||
message=f"Synced {metrics_saved} days of data and {workouts_saved} workouts"
|
||||
)
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Zepp sync failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Zepp API error: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during Zepp sync: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to sync data from Zepp"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=ZeppStatusResponse)
|
||||
async def get_zepp_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get the current Zepp connection status."""
|
||||
client = _zepp_clients.get(current_user.id)
|
||||
|
||||
if client and client.user_id:
|
||||
# Get last sync time from database
|
||||
last_metric = db.query(DailyHealthMetrics).filter_by(
|
||||
user_id=current_user.id
|
||||
).order_by(DailyHealthMetrics.date.desc()).first()
|
||||
|
||||
return ZeppStatusResponse(
|
||||
connected=True,
|
||||
zepp_user_id=client.user_id,
|
||||
last_sync=last_metric.date if last_metric else None
|
||||
)
|
||||
|
||||
return ZeppStatusResponse(connected=False)
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_zepp(
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Disconnect Zepp account."""
|
||||
if current_user.id in _zepp_clients:
|
||||
del _zepp_clients[current_user.id]
|
||||
logger.info(f"User {current_user.id} disconnected Zepp account")
|
||||
|
||||
return {"status": "success", "message": "Zepp account disconnected"}
|
||||
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def _save_daily_metrics(db: Session, user_id: int, entry: dict) -> bool:
|
||||
"""Save or update daily health metrics from Zepp data."""
|
||||
try:
|
||||
date_str = entry.get("date")
|
||||
if not date_str:
|
||||
return False
|
||||
|
||||
# Parse date
|
||||
if isinstance(date_str, str):
|
||||
d_date = datetime.strptime(date_str[:10], "%Y-%m-%d").date()
|
||||
else:
|
||||
d_date = date_str
|
||||
|
||||
# Find or create metric record
|
||||
metric = db.query(DailyHealthMetrics).filter_by(
|
||||
user_id=user_id, date=d_date
|
||||
).first()
|
||||
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=user_id, date=d_date)
|
||||
db.add(metric)
|
||||
|
||||
# Update activity metrics
|
||||
if entry.get("steps"):
|
||||
metric.step_count = entry["steps"]
|
||||
if entry.get("distance"):
|
||||
metric.distance_meters = entry["distance"]
|
||||
if entry.get("calories"):
|
||||
metric.calories_burned = entry["calories"]
|
||||
|
||||
# Update sleep metrics
|
||||
sleep = entry.get("sleep", {})
|
||||
if sleep:
|
||||
if sleep.get("total_minutes"):
|
||||
metric.sleep_duration_minutes = sleep["total_minutes"]
|
||||
if sleep.get("deep_sleep_minutes"):
|
||||
metric.deep_sleep_minutes = sleep["deep_sleep_minutes"]
|
||||
if sleep.get("light_sleep_minutes"):
|
||||
metric.light_sleep_minutes = sleep["light_sleep_minutes"]
|
||||
if sleep.get("rem_sleep_minutes"):
|
||||
metric.rem_sleep_minutes = sleep["rem_sleep_minutes"]
|
||||
if sleep.get("awake_minutes"):
|
||||
metric.awake_duration_minutes = sleep["awake_minutes"]
|
||||
|
||||
# Update heart rate metrics
|
||||
hr = entry.get("heart_rate", {})
|
||||
if hr:
|
||||
if hr.get("avg"):
|
||||
metric.avg_heart_rate = hr["avg"]
|
||||
if hr.get("min"):
|
||||
metric.min_heart_rate = hr["min"]
|
||||
if hr.get("max"):
|
||||
metric.max_heart_rate = hr["max"]
|
||||
if hr.get("resting"):
|
||||
metric.resting_heart_rate = hr["resting"]
|
||||
|
||||
# Update SpO2 metrics
|
||||
spo2 = entry.get("spo2", {})
|
||||
if spo2:
|
||||
if spo2.get("avg"):
|
||||
metric.avg_spo2 = spo2["avg"]
|
||||
if spo2.get("min"):
|
||||
metric.min_spo2 = spo2["min"]
|
||||
if spo2.get("max"):
|
||||
metric.max_spo2 = spo2["max"]
|
||||
|
||||
# Calculate simple PAI score
|
||||
pai = 0.0
|
||||
if metric.step_count:
|
||||
pai += metric.step_count / 1000
|
||||
if metric.calories_burned and metric.calories_burned > 0:
|
||||
pai += metric.calories_burned / 50
|
||||
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
|
||||
pai += (metric.avg_heart_rate - 100) / 10
|
||||
metric.pai_score = round(min(pai, 100), 1)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving daily metrics: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _save_workout(db: Session, user_id: int, workout: dict) -> bool:
|
||||
"""Save or update workout session from Zepp data."""
|
||||
try:
|
||||
# Extract workout data
|
||||
start_time_str = workout.get("startTime") or workout.get("start_time")
|
||||
if not start_time_str:
|
||||
return False
|
||||
|
||||
# Parse start time
|
||||
if isinstance(start_time_str, str):
|
||||
# Handle various formats
|
||||
start_time_str = start_time_str.split("+")[0].split("-0")[0]
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
start_time = datetime.strptime(start_time_str[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
else:
|
||||
start_time = start_time_str
|
||||
|
||||
# Check if workout already exists
|
||||
existing = db.query(WorkoutSession).filter_by(
|
||||
user_id=user_id, start_time=start_time
|
||||
).first()
|
||||
|
||||
# Get workout type
|
||||
type_id = int(workout.get("type", 0))
|
||||
activity_name = ZEPP_ACTIVITY_TYPES.get(type_id, "Unknown")
|
||||
|
||||
# Duration
|
||||
duration = int(workout.get("sportTime(s)", workout.get("duration", 0)))
|
||||
|
||||
# End time
|
||||
end_time = start_time + timedelta(seconds=duration)
|
||||
|
||||
# Distance and calories
|
||||
distance = float(workout.get("distance(m)", workout.get("distance", 0)))
|
||||
calories = float(workout.get("calories(kcal)", workout.get("calories", 0)))
|
||||
|
||||
# Pace metrics (convert from /meter to min/km)
|
||||
avg_pace = float(workout.get("avgPace(/meter)", workout.get("avg_pace", 0)))
|
||||
min_pace = float(workout.get("minPace(/meter)", workout.get("min_pace", 0)))
|
||||
max_pace = float(workout.get("maxPace(/meter)", workout.get("max_pace", 0)))
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.activity_type = activity_name
|
||||
existing.activity_type_id = type_id
|
||||
existing.duration_seconds = duration
|
||||
existing.distance_meters = distance
|
||||
existing.calories = calories
|
||||
existing.avg_pace = avg_pace if avg_pace > 0 else None
|
||||
existing.min_pace = min_pace if min_pace > 0 else None
|
||||
existing.max_pace = max_pace if max_pace > 0 else None
|
||||
else:
|
||||
# Create new
|
||||
workout_session = WorkoutSession(
|
||||
user_id=user_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
activity_type=activity_name,
|
||||
activity_type_id=type_id,
|
||||
duration_seconds=duration,
|
||||
distance_meters=distance,
|
||||
calories=calories,
|
||||
avg_pace=avg_pace if avg_pace > 0 else None,
|
||||
min_pace=min_pace if min_pace > 0 else None,
|
||||
max_pace=max_pace if max_pace > 0 else None,
|
||||
)
|
||||
db.add(workout_session)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving workout: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from .core.config import settings
|
||||
from .database import get_db
|
||||
from .models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||
"""Get the current authenticated user from JWT token."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: Optional[str] = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -0,0 +1 @@
|
||||
"""Core module for configuration and security."""
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Configuration module using pydantic_settings for environment-based configuration."""
|
||||
from secrets import token_urlsafe
|
||||
from typing import List, Union
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = token_urlsafe(32)
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "sqlite:///./data/habitforge.db"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: Union[List[str], str] = ["http://localhost:8000", "http://localhost:3000"]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: Union[List[str], str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: Union[List[str], str] = ["*"]
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/api/google/callback"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
RATE_LIMIT_TIMES: int = 60
|
||||
RATE_LIMIT_SECONDS: int = 60
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "HabitForge API"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Health Data Provider Selection
|
||||
# Options: "google_fit", "health_connect", "dual" (both for comparison)
|
||||
HEALTH_PROVIDER: str = "google_fit"
|
||||
|
||||
@field_validator("CORS_ORIGINS", "CORS_ALLOW_METHODS", "CORS_ALLOW_HEADERS", mode="before")
|
||||
@classmethod
|
||||
def assemble_cors_list(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Convert a comma-separated string into a list of strings if necessary."""
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",") if i.strip()]
|
||||
return v
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
@@ -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,14 @@
|
||||
"""Integrations module for external APIs."""
|
||||
from backend.integrations.zepp_api import (
|
||||
ZeppApiClient,
|
||||
ZeppApiError,
|
||||
create_zepp_client,
|
||||
sync_zepp_data
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ZeppApiClient",
|
||||
"ZeppApiError",
|
||||
"create_zepp_client",
|
||||
"sync_zepp_data",
|
||||
]
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Zepp Data Import Module
|
||||
|
||||
Imports health data from Zepp/Mi Band/Amazfit export files into HabitForge database.
|
||||
Supports: Activity, Sleep, Heart Rate, Body measurements, and Sport/Workouts.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import glob
|
||||
import os
|
||||
import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models import DailyHealthMetrics, User, WorkoutSession
|
||||
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
|
||||
from backend.database import SessionLocal, engine, Base
|
||||
|
||||
# --- Configuration ---
|
||||
EXPORT_DIR = "export-zepp"
|
||||
USER_ID = 1 # Default user ID since we are single user for now or running locally
|
||||
|
||||
|
||||
def parse_date(date_str):
|
||||
"""Parse YYYY-MM-DD date string."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_datetime(dt_str):
|
||||
"""Parse datetime string with timezone (e.g., 2025-12-28 18:25:54+0000)."""
|
||||
if not dt_str:
|
||||
return None
|
||||
try:
|
||||
# Remove timezone for simplicity
|
||||
clean_str = dt_str.split('+')[0].split('-0')[0]
|
||||
return datetime.datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_latest_export_dir(base_dir):
|
||||
"""Find the numbered directory inside export-zepp."""
|
||||
subdirs = glob.glob(os.path.join(base_dir, "*"))
|
||||
if not subdirs:
|
||||
return None
|
||||
# Return the most recent one (sorted by name, which includes timestamp)
|
||||
return sorted(subdirs)[-1]
|
||||
|
||||
|
||||
def import_activity(session, export_path):
|
||||
"""Import daily activity summary (steps, distance, calories)."""
|
||||
activity_dir = os.path.join(export_path, "ACTIVITY")
|
||||
csv_files = glob.glob(os.path.join(activity_dir, "*.csv"))
|
||||
if not csv_files:
|
||||
print("No Activity CSV found.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Activity: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
d_date = parse_date(row.get('date'))
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
steps = int(row.get('steps', 0))
|
||||
distance = float(row.get('distance', 0))
|
||||
calories = float(row.get('calories', 0))
|
||||
|
||||
# Upsert
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
# Overwrite with Zepp data as it's authoritative
|
||||
if steps > 0:
|
||||
metric.step_count = steps
|
||||
if distance > 0:
|
||||
metric.distance_meters = distance
|
||||
if calories > 0:
|
||||
metric.calories_burned = calories
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} activity records.")
|
||||
|
||||
|
||||
def import_sleep(session, export_path):
|
||||
"""Import sleep data with phases (deep, light, REM, awake)."""
|
||||
sleep_dir = os.path.join(export_path, "SLEEP")
|
||||
csv_files = glob.glob(os.path.join(sleep_dir, "*.csv"))
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Sleep: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
d_date = parse_date(row.get('date'))
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
try:
|
||||
deep = int(row.get('deepSleepTime', 0))
|
||||
light = int(row.get('shallowSleepTime', 0))
|
||||
wake = int(row.get('wakeTime', 0))
|
||||
rem = int(row.get('REMTime', 0))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
total_sleep = deep + light + rem
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
if total_sleep > 0:
|
||||
metric.sleep_duration_minutes = total_sleep
|
||||
metric.deep_sleep_minutes = deep
|
||||
metric.light_sleep_minutes = light
|
||||
metric.rem_sleep_minutes = rem
|
||||
metric.awake_duration_minutes = wake
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} sleep records.")
|
||||
|
||||
|
||||
def import_heart_rate(session, export_path):
|
||||
"""Import manual heart rate readings."""
|
||||
hr_dir = os.path.join(export_path, "HEARTRATE")
|
||||
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
|
||||
|
||||
daily_hr = {} # date -> [hr_values]
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Heart Rate: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
ts_str = row.get('time')
|
||||
hr_val = int(row.get('heartRate', 0))
|
||||
|
||||
if not ts_str or hr_val <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
|
||||
d_date = dt.date()
|
||||
|
||||
if d_date not in daily_hr:
|
||||
daily_hr[d_date] = []
|
||||
daily_hr[d_date].append(hr_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate
|
||||
for d_date, values in daily_hr.items():
|
||||
if not values:
|
||||
continue
|
||||
avg_hr = sum(values) // len(values)
|
||||
min_hr = min(values)
|
||||
max_hr = max(values)
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.avg_heart_rate = avg_hr
|
||||
metric.min_heart_rate = min_hr
|
||||
metric.max_heart_rate = max_hr
|
||||
|
||||
session.commit()
|
||||
print(f" Imported heart rate data for {len(daily_hr)} days.")
|
||||
|
||||
|
||||
def import_heart_rate_auto(session, export_path):
|
||||
"""Import automatic heart rate monitoring data."""
|
||||
hr_dir = os.path.join(export_path, "HEARTRATE_AUTO")
|
||||
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No HEARTRATE_AUTO CSV found.")
|
||||
return
|
||||
|
||||
daily_hr = {}
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Heart Rate Auto: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
date_str = row.get('date')
|
||||
hr_val = int(row.get('heartRate', 0))
|
||||
|
||||
if not date_str or hr_val <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
d_date = parse_date(date_str)
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
if d_date not in daily_hr:
|
||||
daily_hr[d_date] = []
|
||||
daily_hr[d_date].append(hr_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate and Save
|
||||
for d_date, values in daily_hr.items():
|
||||
if not values:
|
||||
continue
|
||||
|
||||
avg_hr = sum(values) // len(values)
|
||||
min_hr = min(values)
|
||||
max_hr = max(values)
|
||||
rhr = min_hr # Resting HR approximation
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.avg_heart_rate = avg_hr
|
||||
metric.min_heart_rate = min_hr
|
||||
metric.max_heart_rate = max_hr
|
||||
metric.resting_heart_rate = rhr
|
||||
|
||||
session.commit()
|
||||
print(f" Imported auto heart rate data for {len(daily_hr)} days.")
|
||||
|
||||
|
||||
def import_body(session, export_path):
|
||||
"""Import body measurements (weight, etc.)."""
|
||||
body_dir = os.path.join(export_path, "BODY")
|
||||
csv_files = glob.glob(os.path.join(body_dir, "*.csv"))
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Body: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
ts_str = row.get('time')
|
||||
w_str = row.get('weight', '0')
|
||||
weight = float(w_str)
|
||||
|
||||
if not ts_str or weight <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
|
||||
d_date = dt.date()
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.weight = weight
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} body measurements.")
|
||||
|
||||
|
||||
def import_sport(session, export_path):
|
||||
"""Import sport/workout sessions."""
|
||||
sport_dir = os.path.join(export_path, "SPORT")
|
||||
csv_files = glob.glob(os.path.join(sport_dir, "*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No SPORT CSV found.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Sport: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
# Columns: type, startTime, sportTime(s), maxPace(/meter), minPace(/meter),
|
||||
# distance(m), avgPace(/meter), calories(kcal)
|
||||
for row in reader:
|
||||
try:
|
||||
type_id = int(row.get('type', 0))
|
||||
start_time_str = row.get('startTime')
|
||||
duration_seconds = int(row.get('sportTime(s)', 0))
|
||||
distance = float(row.get('distance(m)', 0))
|
||||
calories = float(row.get('calories(kcal)', 0))
|
||||
max_pace = float(row.get('maxPace(/meter)', 0))
|
||||
min_pace = float(row.get('minPace(/meter)', 0))
|
||||
avg_pace = float(row.get('avgPace(/meter)', 0))
|
||||
|
||||
start_time = parse_datetime(start_time_str)
|
||||
if not start_time:
|
||||
continue
|
||||
|
||||
end_time = start_time + datetime.timedelta(seconds=duration_seconds)
|
||||
activity_name = WorkoutSession.get_activity_name(type_id)
|
||||
|
||||
# Check if workout already exists (by start time and user)
|
||||
existing = session.query(WorkoutSession).filter_by(
|
||||
user_id=USER_ID, start_time=start_time
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.activity_type = activity_name
|
||||
existing.activity_type_id = type_id
|
||||
existing.duration_seconds = duration_seconds
|
||||
existing.distance_meters = distance
|
||||
existing.calories = calories
|
||||
existing.avg_pace = avg_pace if avg_pace > 0 else None
|
||||
existing.max_pace = max_pace if max_pace > 0 else None
|
||||
existing.min_pace = min_pace if min_pace > 0 else None
|
||||
else:
|
||||
# Create new
|
||||
workout = WorkoutSession(
|
||||
user_id=USER_ID,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
activity_type=activity_name,
|
||||
activity_type_id=type_id,
|
||||
duration_seconds=duration_seconds,
|
||||
distance_meters=distance,
|
||||
calories=calories,
|
||||
avg_pace=avg_pace if avg_pace > 0 else None,
|
||||
max_pace=max_pace if max_pace > 0 else None,
|
||||
min_pace=min_pace if min_pace > 0 else None
|
||||
)
|
||||
session.add(workout)
|
||||
count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error processing workout: {e}")
|
||||
continue
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} workout sessions.")
|
||||
|
||||
|
||||
def calculate_pai_scores(session):
|
||||
"""Calculate PAI scores for all days based on activity and heart rate data."""
|
||||
print("Calculating PAI scores...")
|
||||
|
||||
metrics = session.query(DailyHealthMetrics).filter_by(user_id=USER_ID).all()
|
||||
|
||||
for metric in metrics:
|
||||
# Simple PAI calculation based on activity
|
||||
# Real PAI requires continuous HR data, this is an approximation
|
||||
pai = 0.0
|
||||
|
||||
# Base PAI from steps (rough approximation)
|
||||
if metric.step_count:
|
||||
pai += metric.step_count / 1000 # 1 PAI per 1000 steps
|
||||
|
||||
# Bonus from calories burned (beyond baseline)
|
||||
if metric.calories_burned and metric.calories_burned > 0:
|
||||
pai += metric.calories_burned / 50 # 1 PAI per 50 cal
|
||||
|
||||
# Bonus from elevated heart rate activities
|
||||
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
|
||||
intensity_bonus = (metric.avg_heart_rate - 100) / 10
|
||||
pai += intensity_bonus
|
||||
|
||||
metric.pai_score = round(min(pai, 100), 1) # Cap at 100
|
||||
|
||||
session.commit()
|
||||
print(f" Updated PAI scores for {len(metrics)} days.")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main import function."""
|
||||
print("=" * 60)
|
||||
print("Starting Zepp Data Import...")
|
||||
print("=" * 60)
|
||||
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
base_export = get_latest_export_dir(EXPORT_DIR)
|
||||
if not base_export:
|
||||
print(f"No export directory found in {EXPORT_DIR}")
|
||||
return
|
||||
|
||||
print(f"Using export directory: {base_export}\n")
|
||||
|
||||
# Import all data types
|
||||
import_activity(session, base_export)
|
||||
import_sleep(session, base_export)
|
||||
import_heart_rate(session, base_export)
|
||||
import_heart_rate_auto(session, base_export)
|
||||
import_body(session, base_export)
|
||||
import_sport(session, base_export)
|
||||
|
||||
# Calculate derived metrics
|
||||
calculate_pai_scores(session)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Import completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nAn error occurred: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
Zepp/Huami API Client
|
||||
|
||||
Provides authentication and data fetching from the Zepp/Huami cloud API
|
||||
for Amazfit and Mi Band devices.
|
||||
|
||||
Based on the Huami API technical documentation:
|
||||
- Authentication: account.huami.com
|
||||
- Data: api-mifit-de2.zepp.com (EU GDPR compliant)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from backend.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Constants ---
|
||||
AUTH_URL = "https://account.huami.com/v2/client/login"
|
||||
API_BASE_EU = "https://api-mifit-de2.zepp.com"
|
||||
API_BASE_GLOBAL = "https://api-mifit.huami.com"
|
||||
|
||||
# App identification (mimics official Zepp app)
|
||||
APP_NAME = "com.huami.watch.hmwatchmanager"
|
||||
CLIENT_ID = "HuaMi"
|
||||
APP_VERSION = "6.9.3"
|
||||
|
||||
|
||||
class ZeppApiError(Exception):
|
||||
"""Custom exception for Zepp API errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ZeppApiClient:
|
||||
"""
|
||||
Client for interacting with the Zepp/Huami API.
|
||||
|
||||
Handles authentication, token management, and data fetching.
|
||||
"""
|
||||
|
||||
def __init__(self, email: str = None, password: str = None):
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.app_token: Optional[str] = None
|
||||
self.login_token: Optional[str] = None
|
||||
self.user_id: Optional[str] = None
|
||||
self.token_expiry: Optional[datetime.datetime] = None
|
||||
self.api_base = API_BASE_EU # Default to EU for GDPR
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({
|
||||
"User-Agent": f"Zepp/{APP_VERSION} (Android)",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
|
||||
def authenticate(self, email: str = None, password: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Authenticate with the Huami/Zepp API.
|
||||
|
||||
Args:
|
||||
email: Zepp account email
|
||||
password: Zepp account password
|
||||
|
||||
Returns:
|
||||
Dict containing user_id, app_token, and other session info
|
||||
"""
|
||||
email = email or self.email
|
||||
password = password or self.password
|
||||
|
||||
if not email or not password:
|
||||
raise ZeppApiError("Email and password are required")
|
||||
|
||||
# Generate unique device ID
|
||||
device_id = uuid.uuid4().hex[:16].upper()
|
||||
|
||||
# Prepare authentication request
|
||||
auth_data = {
|
||||
"grant_type": "access_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"app_name": APP_NAME,
|
||||
"app_version": APP_VERSION,
|
||||
"third_name": "huami",
|
||||
"device_id": device_id,
|
||||
"device_model": "android_phone",
|
||||
"allow_registration": "false",
|
||||
"dn": "api-user.huami.com,api-mifit-de2.zepp.com,app-analytics.huami.com",
|
||||
"country_code": "DE",
|
||||
"lang": "en",
|
||||
"state": "REDIRECTION",
|
||||
"redirect_uri": "https://s3-us-west-2.amazonws.com/hm-registration/successs498.html",
|
||||
}
|
||||
|
||||
# Add credentials
|
||||
auth_data["email"] = email
|
||||
auth_data["password"] = password
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._session.post(
|
||||
AUTH_URL,
|
||||
data=auth_data,
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
|
||||
if "error_code" in result:
|
||||
error_msg = result.get("message", "Authentication failed")
|
||||
raise ZeppApiError(f"Auth error: {error_msg}")
|
||||
|
||||
# Extract tokens
|
||||
token_info = result.get("token_info", {})
|
||||
self.app_token = token_info.get("app_token")
|
||||
self.login_token = token_info.get("login_token")
|
||||
self.user_id = token_info.get("user_id")
|
||||
|
||||
if not self.app_token or not self.user_id:
|
||||
raise ZeppApiError("Invalid authentication response: missing tokens")
|
||||
|
||||
# Store credentials for refresh
|
||||
self.email = email
|
||||
self.password = password
|
||||
|
||||
# Set token expiry (typically 30 days, be conservative with 7)
|
||||
self.token_expiry = datetime.datetime.now() + datetime.timedelta(days=7)
|
||||
|
||||
logger.info(f"Successfully authenticated Zepp user: {self.user_id}")
|
||||
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"app_token": self.app_token,
|
||||
"login_token": self.login_token,
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Zepp authentication request failed: {e}")
|
||||
raise ZeppApiError(f"Network error during authentication: {e}")
|
||||
|
||||
def _ensure_authenticated(self):
|
||||
"""Ensure we have valid tokens, re-authenticate if needed."""
|
||||
if not self.app_token or not self.user_id:
|
||||
if self.email and self.password:
|
||||
self.authenticate()
|
||||
else:
|
||||
raise ZeppApiError("Not authenticated. Call authenticate() first.")
|
||||
|
||||
# Check token expiry
|
||||
if self.token_expiry and datetime.datetime.now() > self.token_expiry:
|
||||
logger.info("Token expired, re-authenticating...")
|
||||
self.authenticate()
|
||||
|
||||
def _make_request(self, endpoint: str, params: Dict = None, method: str = "GET") -> Dict:
|
||||
"""
|
||||
Make an authenticated request to the Zepp API.
|
||||
"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
url = f"{self.api_base}{endpoint}"
|
||||
|
||||
headers = {
|
||||
"apptoken": self.app_token,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
response = self._session.get(url, params=params, headers=headers, timeout=30)
|
||||
else:
|
||||
response = self._session.post(url, data=params, headers=headers, timeout=30)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Zepp API request failed: {e}")
|
||||
raise ZeppApiError(f"API request failed: {e}")
|
||||
|
||||
def fetch_band_data(
|
||||
self,
|
||||
from_date: datetime.date,
|
||||
to_date: datetime.date,
|
||||
query_type: str = "summary"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch daily health data (steps, sleep, heart rate).
|
||||
|
||||
Args:
|
||||
from_date: Start date
|
||||
to_date: End date
|
||||
query_type: "summary" for daily totals, "detail" for minute-by-minute
|
||||
|
||||
Returns:
|
||||
Dict containing decoded health data
|
||||
"""
|
||||
params = {
|
||||
"query_type": query_type,
|
||||
"from_date": from_date.strftime("%Y-%m-%d"),
|
||||
"to_date": to_date.strftime("%Y-%m-%d"),
|
||||
"userid": self.user_id,
|
||||
"device_type": "android_phone",
|
||||
}
|
||||
|
||||
response = self._make_request("/v1/data/band_data.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
raise ZeppApiError(f"band_data request failed: {response.get('message')}")
|
||||
|
||||
# Decode the Base64 summary data
|
||||
data_list = response.get("data", [])
|
||||
decoded_data = []
|
||||
|
||||
for entry in data_list:
|
||||
decoded_entry = self._decode_band_data_entry(entry)
|
||||
if decoded_entry:
|
||||
decoded_data.append(decoded_entry)
|
||||
|
||||
return {"data": decoded_data, "raw_response": response}
|
||||
|
||||
def _decode_band_data_entry(self, entry: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Decode a single band_data entry with Base64 encoded fields.
|
||||
"""
|
||||
try:
|
||||
date_str = entry.get("date_time", entry.get("date"))
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"date": date_str,
|
||||
"summary": {},
|
||||
}
|
||||
|
||||
# Decode summary field (Base64 JSON)
|
||||
summary_b64 = entry.get("summary")
|
||||
if summary_b64:
|
||||
try:
|
||||
summary_json = base64.b64decode(summary_b64).decode("utf-8")
|
||||
result["summary"] = json.loads(summary_json)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode summary: {e}")
|
||||
|
||||
# Extract steps data
|
||||
stp = result["summary"].get("stp", {})
|
||||
result["steps"] = stp.get("ttl", 0)
|
||||
result["distance"] = stp.get("dis", 0)
|
||||
result["calories"] = stp.get("cal", 0)
|
||||
|
||||
# Extract sleep data
|
||||
slp = result["summary"].get("slp", {})
|
||||
if slp:
|
||||
result["sleep"] = {
|
||||
"start_time": slp.get("st"),
|
||||
"end_time": slp.get("ed"),
|
||||
"deep_sleep_minutes": slp.get("dp", 0),
|
||||
"light_sleep_minutes": slp.get("lt", 0),
|
||||
"rem_sleep_minutes": slp.get("rm", 0),
|
||||
"awake_minutes": slp.get("wk", 0),
|
||||
}
|
||||
# Calculate total
|
||||
result["sleep"]["total_minutes"] = (
|
||||
result["sleep"]["deep_sleep_minutes"] +
|
||||
result["sleep"]["light_sleep_minutes"] +
|
||||
result["sleep"]["rem_sleep_minutes"]
|
||||
)
|
||||
|
||||
# Decode heart rate blob if present
|
||||
hr_blob = entry.get("data_hr")
|
||||
if hr_blob:
|
||||
hr_values = self._decode_heart_rate_blob(hr_blob)
|
||||
if hr_values:
|
||||
valid_hr = [v for v in hr_values if 30 < v < 220]
|
||||
if valid_hr:
|
||||
result["heart_rate"] = {
|
||||
"avg": sum(valid_hr) // len(valid_hr),
|
||||
"min": min(valid_hr),
|
||||
"max": max(valid_hr),
|
||||
"resting": min(valid_hr), # Approximation
|
||||
}
|
||||
|
||||
# Decode SpO2 if present
|
||||
spo2_blob = entry.get("data_spo2")
|
||||
if spo2_blob:
|
||||
spo2_values = self._decode_spo2_blob(spo2_blob)
|
||||
if spo2_values:
|
||||
valid_spo2 = [v for v in spo2_values if 70 < v <= 100]
|
||||
if valid_spo2:
|
||||
result["spo2"] = {
|
||||
"avg": sum(valid_spo2) / len(valid_spo2),
|
||||
"min": min(valid_spo2),
|
||||
"max": max(valid_spo2),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding band_data entry: {e}")
|
||||
return None
|
||||
|
||||
def _decode_heart_rate_blob(self, b64_data: str) -> List[int]:
|
||||
"""
|
||||
Decode Base64-encoded heart rate binary data.
|
||||
Format: sequence of 1-byte or 2-byte integers.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64_data)
|
||||
# Try single-byte format first (most common)
|
||||
values = list(raw)
|
||||
# Filter out invalid values (0, 255 are typically "no reading")
|
||||
return [v for v in values if v > 0 and v < 255]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode HR blob: {e}")
|
||||
return []
|
||||
|
||||
def _decode_spo2_blob(self, b64_data: str) -> List[float]:
|
||||
"""
|
||||
Decode Base64-encoded SpO2 binary data.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64_data)
|
||||
# SpO2 is typically stored as single bytes (percentage)
|
||||
values = list(raw)
|
||||
return [float(v) for v in values if v > 0]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode SpO2 blob: {e}")
|
||||
return []
|
||||
|
||||
def fetch_workout_history(self, days: int = 30) -> List[Dict]:
|
||||
"""
|
||||
Fetch workout/sport session history.
|
||||
|
||||
Args:
|
||||
days: Number of days of history to fetch
|
||||
|
||||
Returns:
|
||||
List of workout sessions
|
||||
"""
|
||||
from_date = datetime.date.today() - datetime.timedelta(days=days)
|
||||
|
||||
params = {
|
||||
"from_date": from_date.strftime("%Y-%m-%d"),
|
||||
"to_date": datetime.date.today().strftime("%Y-%m-%d"),
|
||||
"userid": self.user_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request("/v1/sport/run/history.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
logger.warning(f"Workout history request failed: {response}")
|
||||
return []
|
||||
|
||||
return response.get("data", {}).get("summary", [])
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Failed to fetch workout history: {e}")
|
||||
return []
|
||||
|
||||
def fetch_workout_detail(self, track_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch detailed workout data including GPS track.
|
||||
|
||||
Args:
|
||||
track_id: The workout track ID
|
||||
|
||||
Returns:
|
||||
Dict with workout details and GPS data
|
||||
"""
|
||||
params = {
|
||||
"trackid": track_id,
|
||||
"source": "run.mifit.huami.com",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request("/v1/sport/run/detail.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
return None
|
||||
|
||||
data = response.get("data", {})
|
||||
|
||||
# Parse GPS coordinates if present
|
||||
gps_str = data.get("longitude_latitude", "")
|
||||
if gps_str:
|
||||
data["gps_track"] = self._parse_gps_string(gps_str)
|
||||
|
||||
return data
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Failed to fetch workout detail {track_id}: {e}")
|
||||
return None
|
||||
|
||||
def _parse_gps_string(self, gps_str: str) -> List[Tuple[float, float]]:
|
||||
"""
|
||||
Parse Zepp's proprietary GPS format.
|
||||
Format: lat,lon;lat,lon;lat,lon...
|
||||
"""
|
||||
points = []
|
||||
try:
|
||||
for point_str in gps_str.split(";"):
|
||||
if "," in point_str:
|
||||
parts = point_str.split(",")
|
||||
if len(parts) >= 2:
|
||||
lat = float(parts[0])
|
||||
lon = float(parts[1])
|
||||
points.append((lat, lon))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse GPS string: {e}")
|
||||
return points
|
||||
|
||||
|
||||
# --- Convenience Functions ---
|
||||
|
||||
def create_zepp_client(email: str, password: str) -> ZeppApiClient:
|
||||
"""Create and authenticate a Zepp API client."""
|
||||
client = ZeppApiClient(email, password)
|
||||
client.authenticate()
|
||||
return client
|
||||
|
||||
|
||||
def sync_zepp_data(
|
||||
client: ZeppApiClient,
|
||||
days: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync all available data from Zepp.
|
||||
|
||||
Args:
|
||||
client: Authenticated ZeppApiClient
|
||||
days: Number of days to sync
|
||||
|
||||
Returns:
|
||||
Dict with synced data counts
|
||||
"""
|
||||
from_date = datetime.date.today() - datetime.timedelta(days=days)
|
||||
to_date = datetime.date.today()
|
||||
|
||||
results = {
|
||||
"daily_metrics": [],
|
||||
"workouts": [],
|
||||
"days_synced": 0,
|
||||
"workouts_synced": 0,
|
||||
}
|
||||
|
||||
# Fetch band data (daily metrics)
|
||||
try:
|
||||
band_data = client.fetch_band_data(from_date, to_date)
|
||||
results["daily_metrics"] = band_data.get("data", [])
|
||||
results["days_synced"] = len(results["daily_metrics"])
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Failed to fetch band data: {e}")
|
||||
|
||||
# Fetch workouts
|
||||
try:
|
||||
workouts = client.fetch_workout_history(days)
|
||||
results["workouts"] = workouts
|
||||
results["workouts_synced"] = len(workouts)
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Failed to fetch workouts: {e}")
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,69 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
|
||||
from backend.core.config import settings
|
||||
from backend.api.v1 import (
|
||||
auth as auth_router,
|
||||
challenges as challenges_router,
|
||||
health as health_router,
|
||||
zepp as zepp_router
|
||||
)
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
# Rate limiting setup
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Create database tables on startup
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
from backend.database import Base, engine
|
||||
from backend.models import User, Challenge, Tracking, DailyHealthMetrics, WorkoutSession
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# CORS - Use configured origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
|
||||
allow_methods=settings.CORS_ALLOW_METHODS,
|
||||
allow_headers=settings.CORS_ALLOW_HEADERS,
|
||||
)
|
||||
|
||||
# Include API v1 routers
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(challenges_router.router)
|
||||
app.include_router(health_router.router)
|
||||
app.include_router(zepp_router.router)
|
||||
|
||||
# Middleware to drop unexpected WebSocket connections (Fixes StaticFiles crash)
|
||||
@app.middleware("http")
|
||||
async def reject_websockets(request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
from starlette.websockets import WebSocket
|
||||
@app.websocket("/{path:path}")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
await websocket.close()
|
||||
|
||||
@app.get("/api/static/images")
|
||||
def get_static_images():
|
||||
"""Retrieve list of available static images."""
|
||||
static_dir = os.path.join("frontend", "static")
|
||||
images = []
|
||||
if os.path.exists(static_dir):
|
||||
for filename in os.listdir(static_dir):
|
||||
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
|
||||
images.append(filename)
|
||||
return images
|
||||
|
||||
# Mount static files last to avoid intercepting API calls
|
||||
app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend")
|
||||
@@ -0,0 +1,179 @@
|
||||
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, Text, Float, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from .database import Base
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import date
|
||||
|
||||
# --- SQLAlchemy Models ---
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String)
|
||||
|
||||
challenges = relationship("Challenge", back_populates="owner")
|
||||
health_metrics = relationship("DailyHealthMetrics", back_populates="user")
|
||||
workouts = relationship("WorkoutSession", back_populates="user")
|
||||
|
||||
class Challenge(Base):
|
||||
__tablename__ = "challenges"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
period_type = Column(String) # 'week', 'month', 'year'
|
||||
daily_target = Column(Integer)
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
created_at = Column(Date, default=date.today)
|
||||
display_order = Column(Integer, default=0)
|
||||
user_id = Column(Integer, ForeignKey("users.id")) # New FK
|
||||
unit_type = Column(String, default="reps") # New: 'reps', 'minutes', 'pages', 'km'
|
||||
rest_days = Column(String, nullable=True) # New: comma-separated list of days
|
||||
icon = Column(String, default="fa-dumbbell") # New
|
||||
image_url = Column(String, nullable=True) # New
|
||||
description = Column(String, nullable=True) # New
|
||||
frequency_count = Column(Integer, nullable=True) # New
|
||||
selected_days = Column(String, nullable=True) # New
|
||||
|
||||
owner = relationship("User", back_populates="challenges") # New Rel
|
||||
trackings = relationship("Tracking", back_populates="challenge", cascade="all, delete-orphan")
|
||||
|
||||
class Tracking(Base):
|
||||
__tablename__ = "tracking"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
challenge_id = Column(Integer, ForeignKey("challenges.id"))
|
||||
date = Column(Date)
|
||||
reps = Column(Integer, default=0)
|
||||
completed = Column(Boolean, default=False)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
challenge = relationship("Challenge", back_populates="trackings")
|
||||
|
||||
class DailyHealthMetrics(Base):
|
||||
__tablename__ = "daily_health_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
date = Column(Date, index=True)
|
||||
step_count = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, default=0.0)
|
||||
calories_burned = Column(Float, default=0.0)
|
||||
sleep_duration_minutes = Column(Integer, default=0)
|
||||
deep_sleep_minutes = Column(Integer, default=0)
|
||||
light_sleep_minutes = Column(Integer, default=0) # New
|
||||
rem_sleep_minutes = Column(Integer, default=0) # New
|
||||
awake_duration_minutes = Column(Integer, default=0)
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
avg_spo2 = Column(Float, nullable=True)
|
||||
pai_score = Column(Float, default=0.0)
|
||||
weight = Column(Float, nullable=True) # New
|
||||
resting_heart_rate = Column(Integer, nullable=True) # New
|
||||
hrv = Column(Float, nullable=True) # New
|
||||
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
|
||||
class WorkoutSession(Base):
|
||||
__tablename__ = "workout_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
start_time = Column(DateTime)
|
||||
end_time = Column(DateTime)
|
||||
activity_type = Column(String)
|
||||
calories = Column(Float, default=0.0)
|
||||
avg_hr = Column(Integer, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="workouts")
|
||||
|
||||
|
||||
# --- Pydantic Schemas ---
|
||||
|
||||
class TrackingBase(BaseModel):
|
||||
date: date
|
||||
reps: int
|
||||
completed: bool = False
|
||||
notes: Optional[str] = None
|
||||
|
||||
class TrackingCreate(TrackingBase):
|
||||
challenge_id: int
|
||||
|
||||
class TrackingResponse(TrackingBase):
|
||||
id: int
|
||||
challenge_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ChallengeBase(BaseModel):
|
||||
name: str
|
||||
period_type: str
|
||||
daily_target: int
|
||||
start_date: date
|
||||
end_date: Optional[date] = None
|
||||
unit_type: Optional[str] = "reps"
|
||||
rest_days: Optional[str] = None
|
||||
icon: Optional[str] = "fa-dumbbell"
|
||||
image_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
frequency_count: Optional[int] = None # For Monthly frequency (X times per month)
|
||||
selected_days: Optional[str] = None # For Weekly frequency (Mon,Wed,Fri)
|
||||
|
||||
class ChallengeCreate(ChallengeBase):
|
||||
pass
|
||||
|
||||
class ChallengeResponse(ChallengeBase):
|
||||
id: int
|
||||
created_at: date
|
||||
display_order: int = 0
|
||||
trackings: List[TrackingResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class DailyHealthMetricsBase(BaseModel):
|
||||
date: date
|
||||
step_count: Optional[int] = 0
|
||||
distance_meters: Optional[float] = 0.0
|
||||
calories_burned: Optional[float] = 0.0
|
||||
sleep_duration_minutes: Optional[int] = 0
|
||||
deep_sleep_minutes: Optional[int] = 0
|
||||
light_sleep_minutes: Optional[int] = 0
|
||||
rem_sleep_minutes: Optional[int] = 0
|
||||
awake_duration_minutes: Optional[int] = 0
|
||||
avg_heart_rate: Optional[int] = None
|
||||
min_heart_rate: Optional[int] = None
|
||||
max_heart_rate: Optional[int] = None
|
||||
avg_spo2: Optional[float] = None
|
||||
pai_score: Optional[float] = 0.0
|
||||
weight: Optional[float] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
hrv: Optional[float] = None
|
||||
|
||||
class DailyHealthMetricsResponse(DailyHealthMetricsBase):
|
||||
id: int
|
||||
user_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Auth Schemas
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -0,0 +1,8 @@
|
||||
"""SQLAlchemy models module."""
|
||||
from backend.models.user import User
|
||||
from backend.models.challenge import Challenge
|
||||
from backend.models.tracking import Tracking
|
||||
from backend.models.daily_health_metrics import DailyHealthMetrics
|
||||
from backend.models.workout_session import WorkoutSession
|
||||
|
||||
__all__ = ["User", "Challenge", "Tracking", "DailyHealthMetrics", "WorkoutSession"]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Challenge SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, String, Date, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import date
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class Challenge(Base):
|
||||
"""Challenge model for tracking habits."""
|
||||
__tablename__ = "challenges"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
period_type = Column(String) # 'week', 'month', 'year'
|
||||
daily_target = Column(Integer)
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
created_at = Column(Date, default=date.today)
|
||||
display_order = Column(Integer, default=0)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
unit_type = Column(String, default="reps") # 'reps', 'minutes', 'pages', 'km'
|
||||
rest_days = Column(String, nullable=True) # comma-separated list of days
|
||||
icon = Column(String, default="fa-dumbbell")
|
||||
image_url = Column(String, nullable=True)
|
||||
description = Column(String, nullable=True)
|
||||
frequency_count = Column(Integer, nullable=True)
|
||||
selected_days = Column(String, nullable=True)
|
||||
|
||||
owner = relationship("User", back_populates="challenges")
|
||||
trackings = relationship("Tracking", back_populates="challenge", cascade="all, delete-orphan")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""DailyHealthMetrics SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, Date, Float, ForeignKey, UniqueConstraint, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class DailyHealthMetrics(Base):
|
||||
"""Daily health metrics model for tracking health data."""
|
||||
__tablename__ = "daily_health_metrics"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'date', name='uq_user_date'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
date = Column(Date, index=True)
|
||||
step_count = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, default=0.0)
|
||||
calories_burned = Column(Float, default=0.0)
|
||||
sleep_duration_minutes = Column(Integer, default=0)
|
||||
deep_sleep_minutes = Column(Integer, default=0)
|
||||
light_sleep_minutes = Column(Integer, default=0)
|
||||
rem_sleep_minutes = Column(Integer, default=0)
|
||||
awake_duration_minutes = Column(Integer, default=0)
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
avg_spo2 = Column(Float, nullable=True)
|
||||
pai_score = Column(Float, default=0.0)
|
||||
weight = Column(Float, nullable=True)
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
hrv = Column(Float, nullable=True)
|
||||
|
||||
# Data provenance tracking
|
||||
data_source = Column(String(50), default="google_fit") # "google_fit", "health_connect", "manual"
|
||||
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Health Connect Data Models - Storage for data pushed from Android companion app.
|
||||
|
||||
These models store health data received from the Health Connect Android app,
|
||||
enabling the HealthConnectProvider to serve data to the API layer.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, Date, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
import datetime
|
||||
|
||||
|
||||
class HealthConnectSyncLog(Base):
|
||||
"""
|
||||
Tracks sync operations from the Android companion app.
|
||||
Used for debugging and monitoring data flow.
|
||||
"""
|
||||
__tablename__ = "health_connect_sync_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
device_id = Column(String(100)) # Android device identifier
|
||||
sync_timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
records_pushed = Column(Integer, default=0)
|
||||
status = Column(String(50)) # "success", "partial", "error"
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class HealthConnectDailyMetrics(Base):
|
||||
"""
|
||||
Daily health metrics received from Health Connect.
|
||||
Mirrors DailyHealthMetrics but with Health Connect-specific metadata.
|
||||
"""
|
||||
__tablename__ = "health_connect_daily_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
date = Column(Date, index=True)
|
||||
|
||||
# Activity metrics
|
||||
step_count = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, default=0.0)
|
||||
calories_burned = Column(Float, default=0.0) # ActiveCaloriesBurnedRecord
|
||||
total_calories = Column(Float, default=0.0) # TotalCaloriesBurnedRecord
|
||||
|
||||
# Sleep metrics (from SleepSessionRecord)
|
||||
sleep_duration_minutes = Column(Integer, default=0)
|
||||
deep_sleep_minutes = Column(Integer, default=0)
|
||||
light_sleep_minutes = Column(Integer, default=0)
|
||||
rem_sleep_minutes = Column(Integer, default=0)
|
||||
awake_duration_minutes = Column(Integer, default=0)
|
||||
|
||||
# Heart rate metrics (from HeartRateRecord)
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
|
||||
# SpO2 (from OxygenSaturationRecord)
|
||||
avg_spo2 = Column(Float, nullable=True)
|
||||
min_spo2 = Column(Float, nullable=True)
|
||||
max_spo2 = Column(Float, nullable=True)
|
||||
|
||||
# Body metrics
|
||||
weight = Column(Float, nullable=True) # WeightRecord
|
||||
|
||||
# Metadata
|
||||
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class HealthConnectExerciseSession(Base):
|
||||
"""
|
||||
Exercise sessions from Health Connect ExerciseSessionRecord.
|
||||
"""
|
||||
__tablename__ = "health_connect_exercise_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||
|
||||
# Session timing
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
|
||||
# Exercise type (Health Connect ExerciseType enum)
|
||||
exercise_type = Column(Integer) # Health Connect type ID
|
||||
exercise_type_name = Column(String(100)) # Human-readable name
|
||||
|
||||
# Metrics
|
||||
duration_seconds = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, nullable=True)
|
||||
calories_burned = Column(Float, nullable=True)
|
||||
|
||||
# Heart rate during exercise
|
||||
avg_heart_rate = Column(Integer, nullable=True)
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
min_heart_rate = Column(Integer, nullable=True)
|
||||
|
||||
# Additional metrics
|
||||
steps = Column(Integer, nullable=True)
|
||||
elevation_gained = Column(Float, nullable=True)
|
||||
|
||||
# Metadata
|
||||
notes = Column(Text, nullable=True)
|
||||
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
# Health Connect Exercise Type mapping (subset)
|
||||
HEALTH_CONNECT_EXERCISE_TYPES = {
|
||||
0: "Unknown",
|
||||
2: "Badminton",
|
||||
4: "Baseball",
|
||||
5: "Basketball",
|
||||
8: "Biking",
|
||||
9: "Biking Stationary",
|
||||
10: "Boot Camp",
|
||||
11: "Boxing",
|
||||
13: "Calisthenics",
|
||||
14: "Cricket",
|
||||
16: "Dancing",
|
||||
25: "Elliptical",
|
||||
26: "Exercise Class",
|
||||
27: "Fencing",
|
||||
28: "Football American",
|
||||
29: "Football Australian",
|
||||
31: "Frisbee Disc",
|
||||
32: "Golf",
|
||||
33: "Guided Breathing",
|
||||
34: "Gymnastics",
|
||||
35: "Handball",
|
||||
36: "High Intensity Interval Training",
|
||||
37: "Hiking",
|
||||
38: "Ice Hockey",
|
||||
39: "Ice Skating",
|
||||
44: "Martial Arts",
|
||||
46: "Paddling",
|
||||
48: "Pilates",
|
||||
50: "Racquetball",
|
||||
51: "Rock Climbing",
|
||||
52: "Roller Hockey",
|
||||
53: "Rowing",
|
||||
54: "Rowing Machine",
|
||||
55: "Rugby",
|
||||
56: "Running",
|
||||
57: "Running Treadmill",
|
||||
58: "Sailing",
|
||||
59: "Scuba Diving",
|
||||
60: "Skating",
|
||||
61: "Skiing",
|
||||
62: "Snowboarding",
|
||||
63: "Snowshoeing",
|
||||
64: "Soccer",
|
||||
65: "Softball",
|
||||
67: "Squash",
|
||||
68: "Stair Climbing",
|
||||
69: "Stair Climbing Machine",
|
||||
70: "Strength Training",
|
||||
71: "Stretching",
|
||||
72: "Surfing",
|
||||
73: "Swimming Open Water",
|
||||
74: "Swimming Pool",
|
||||
75: "Table Tennis",
|
||||
76: "Tennis",
|
||||
78: "Volleyball",
|
||||
79: "Walking",
|
||||
80: "Water Polo",
|
||||
81: "Weightlifting",
|
||||
82: "Wheelchair",
|
||||
84: "Yoga",
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Tracking SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, Date, Boolean, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class Tracking(Base):
|
||||
"""Tracking model for recording challenge progress."""
|
||||
__tablename__ = "tracking"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
challenge_id = Column(Integer, ForeignKey("challenges.id"))
|
||||
date = Column(Date)
|
||||
reps = Column(Integer, default=0)
|
||||
completed = Column(Boolean, default=False)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
challenge = relationship("Challenge", back_populates="trackings")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""User SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication and ownership."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String)
|
||||
|
||||
challenges = relationship("Challenge", back_populates="owner")
|
||||
health_metrics = relationship("DailyHealthMetrics", back_populates="user")
|
||||
workouts = relationship("WorkoutSession", back_populates="user")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""WorkoutSession SQLAlchemy model."""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
# Zepp activity type mapping
|
||||
ZEPP_ACTIVITY_TYPES = {
|
||||
1: "Running",
|
||||
6: "Walking",
|
||||
8: "Cycling",
|
||||
10: "Elliptical",
|
||||
16: "Free Training",
|
||||
23: "HIIT",
|
||||
52: "Stretching",
|
||||
60: "Yoga",
|
||||
}
|
||||
|
||||
|
||||
class WorkoutSession(Base):
|
||||
"""Workout data model."""
|
||||
__tablename__ = "workout_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
start_time = Column(DateTime, index=True)
|
||||
end_time = Column(DateTime)
|
||||
activity_type = Column(String) # Human readable name
|
||||
activity_type_id = Column(Integer, nullable=True) # Original Zepp type ID
|
||||
duration_seconds = Column(Integer, default=0)
|
||||
distance_meters = Column(Float, default=0.0)
|
||||
calories = Column(Float, default=0.0)
|
||||
avg_pace = Column(Float, nullable=True) # seconds per meter
|
||||
max_pace = Column(Float, nullable=True)
|
||||
min_pace = Column(Float, nullable=True)
|
||||
avg_hr = Column(Integer, nullable=True)
|
||||
max_hr = Column(Integer, nullable=True)
|
||||
min_hr = Column(Integer, nullable=True)
|
||||
notes = Column(String, nullable=True)
|
||||
|
||||
# Data provenance tracking
|
||||
data_source = Column(String(50), default="google_fit") # "google_fit", "health_connect", "manual"
|
||||
|
||||
user = relationship("User", back_populates="workouts")
|
||||
|
||||
@staticmethod
|
||||
def get_activity_name(type_id: int) -> str:
|
||||
return ZEPP_ACTIVITY_TYPES.get(type_id, f"Activity {type_id}")
|
||||
@@ -0,0 +1 @@
|
||||
"""Repositories module for data access layer."""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Base repository for common database operations."""
|
||||
from typing import Generic, TypeVar, Type, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
ModelType = TypeVar("ModelType")
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelType]):
|
||||
"""Base repository with common CRUD operations."""
|
||||
|
||||
def __init__(self, model: Type[ModelType], db: Session):
|
||||
"""Initialize repository with model and database session."""
|
||||
self.model = model
|
||||
self.db = db
|
||||
|
||||
def get_by_id(self, id: int) -> Optional[ModelType]:
|
||||
"""Get a single record by ID."""
|
||||
return self.db.query(self.model).filter(self.model.id == id).first()
|
||||
|
||||
def get_all(self) -> List[ModelType]:
|
||||
"""Get all records."""
|
||||
return self.db.query(self.model).all()
|
||||
|
||||
def create(self, **kwargs) -> ModelType:
|
||||
"""Create a new record."""
|
||||
db_obj = self.model(**kwargs)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update(self, id: int, **kwargs) -> Optional[ModelType]:
|
||||
"""Update a record by ID."""
|
||||
db_obj = self.get_by_id(id)
|
||||
if db_obj:
|
||||
for key, value in kwargs.items():
|
||||
setattr(db_obj, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def delete(self, id: int) -> bool:
|
||||
"""Delete a record by ID."""
|
||||
db_obj = self.get_by_id(id)
|
||||
if db_obj:
|
||||
self.db.delete(db_obj)
|
||||
self.db.commit()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Challenge repository for data access operations."""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from backend.models.challenge import Challenge
|
||||
from backend.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class ChallengeRepository(BaseRepository[Challenge]):
|
||||
"""Repository for Challenge model operations."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize challenge repository."""
|
||||
super().__init__(Challenge, db)
|
||||
|
||||
def get_by_user_id(self, user_id: int) -> List[Challenge]:
|
||||
"""Get all challenges for a user ordered by display_order."""
|
||||
return self.db.query(Challenge).filter(
|
||||
Challenge.user_id == user_id
|
||||
).order_by(Challenge.display_order.asc()).all()
|
||||
|
||||
def get_by_user_and_id(self, user_id: int, challenge_id: int) -> Optional[Challenge]:
|
||||
"""Get a challenge by user ID and challenge ID."""
|
||||
return self.db.query(Challenge).filter(
|
||||
Challenge.id == challenge_id,
|
||||
Challenge.user_id == user_id
|
||||
).first()
|
||||
|
||||
def get_max_display_order(self, user_id: int) -> Optional[int]:
|
||||
"""Get the maximum display_order for a user."""
|
||||
return self.db.query(func.max(Challenge.display_order)).filter(
|
||||
Challenge.user_id == user_id
|
||||
).scalar()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""User repository for data access operations."""
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.models.user import User
|
||||
from backend.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""Repository for User model operations."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize user repository."""
|
||||
super().__init__(User, db)
|
||||
|
||||
def get_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get a user by username."""
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Pydantic schemas module."""
|
||||
from backend.schemas.user import UserCreate, UserResponse
|
||||
from backend.schemas.challenge import ChallengeBase, ChallengeCreate, ChallengeResponse
|
||||
from backend.schemas.tracking import TrackingBase, TrackingCreate, TrackingResponse
|
||||
from backend.schemas.daily_health_metrics import DailyHealthMetricsBase, DailyHealthMetricsResponse
|
||||
from backend.schemas.workout_session import WorkoutSessionBase, WorkoutSessionCreate, WorkoutSessionResponse
|
||||
from backend.schemas.token import Token
|
||||
|
||||
__all__ = [
|
||||
"UserCreate", "UserResponse",
|
||||
"ChallengeBase", "ChallengeCreate", "ChallengeResponse",
|
||||
"TrackingBase", "TrackingCreate", "TrackingResponse",
|
||||
"DailyHealthMetricsBase", "DailyHealthMetricsResponse",
|
||||
"WorkoutSessionBase", "WorkoutSessionCreate", "WorkoutSessionResponse",
|
||||
"Token"
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Challenge Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import date
|
||||
from backend.schemas.tracking import TrackingResponse
|
||||
from backend.models.tracking import Tracking
|
||||
|
||||
|
||||
class ChallengeBase(BaseModel):
|
||||
"""Base schema for challenge."""
|
||||
name: str
|
||||
period_type: str
|
||||
daily_target: int
|
||||
start_date: date
|
||||
end_date: Optional[date] = None
|
||||
unit_type: Optional[str] = "reps"
|
||||
rest_days: Optional[str] = None
|
||||
icon: Optional[str] = "fa-dumbbell"
|
||||
image_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
frequency_count: Optional[int] = None
|
||||
selected_days: Optional[str] = None
|
||||
|
||||
|
||||
class ChallengeCreate(ChallengeBase):
|
||||
"""Schema for creating a challenge."""
|
||||
pass
|
||||
|
||||
|
||||
class ChallengeResponse(ChallengeBase):
|
||||
"""Schema for challenge response."""
|
||||
id: int
|
||||
created_at: date
|
||||
display_order: int = 0
|
||||
trackings: List[TrackingResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,34 @@
|
||||
"""DailyHealthMetrics Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
|
||||
class DailyHealthMetricsBase(BaseModel):
|
||||
"""Base schema for daily health metrics."""
|
||||
date: date
|
||||
step_count: Optional[int] = 0
|
||||
distance_meters: Optional[float] = 0.0
|
||||
calories_burned: Optional[float] = 0.0
|
||||
sleep_duration_minutes: Optional[int] = 0
|
||||
deep_sleep_minutes: Optional[int] = 0
|
||||
light_sleep_minutes: Optional[int] = 0
|
||||
rem_sleep_minutes: Optional[int] = 0
|
||||
awake_duration_minutes: Optional[int] = 0
|
||||
avg_heart_rate: Optional[int] = None
|
||||
min_heart_rate: Optional[int] = None
|
||||
max_heart_rate: Optional[int] = None
|
||||
avg_spo2: Optional[float] = None
|
||||
pai_score: Optional[float] = 0.0
|
||||
weight: Optional[float] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
hrv: Optional[float] = None
|
||||
|
||||
|
||||
class DailyHealthMetricsResponse(DailyHealthMetricsBase):
|
||||
"""Schema for daily health metrics response."""
|
||||
id: int
|
||||
user_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Token Pydantic schema."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for JWT token response."""
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tracking Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
|
||||
class TrackingBase(BaseModel):
|
||||
"""Base schema for tracking."""
|
||||
date: date
|
||||
reps: int
|
||||
completed: bool = False
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class TrackingCreate(TrackingBase):
|
||||
"""Schema for creating tracking."""
|
||||
challenge_id: int
|
||||
|
||||
|
||||
class TrackingResponse(TrackingBase):
|
||||
"""Schema for tracking response."""
|
||||
id: int
|
||||
challenge_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,17 @@
|
||||
"""User Pydantic schemas."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for user registration."""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema for user response."""
|
||||
id: int
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user