diff --git a/.gitignore b/.gitignore index 9a5aced..529c1be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,139 +1,80 @@ -# Logs -logs +# Miscellaneous +*.class *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.* -!.env.example - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp +*.lock +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# Flutter/Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Android +android/app/libs/ +android/app/src/main/assets/ +android/app/src/main/res/ +android/app/src/main/jniLibs/ +android/app/src/main/obj/ +android/app/src/main/symbols/ +android/gradle/ +android/gradlew +android/gradlew.bat +android/local.properties +android/.gradle +android/captures/ +.gradle +/android/.gradle/ + +# iOS +ios/Flutter/App.framework +ios/Flutter/Flutter.framework +ios/Flutter/Generated.xcconfig +ios/Flutter/app.flx +ios/Flutter/app.zip +ios/Flutter/flutter_assets/ +ios/ServiceDefinitions.json +ios/Runner/GeneratedPluginRegistrant.* +ios/.generated/ + +# Visual Studio Code +.vscode/ + +# IntelliJ +.idea/ +*.iml + +# Python +__pycache__/ +*.pyo +*.pyd +.Python +env/ +venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* .cache +nosetests.xml +coverage.xml +*.cover +.mypy_cache +.pytest_cache +.hypothesis/ + +# Environment Variables +.env -# Sveltekit cache directory -.svelte-kit/ - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# Firebase cache directory -.firebase/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v3 -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Vite logs files -vite.config.js.timestamp-* -vite.config.ts.timestamp-* +# Streamlit secrets +dashboard/.streamlit/secrets.toml diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..b1ff8e8 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,11 @@ +import os +from motor.motor_asyncio import AsyncIOMotorClient + +MONGODB_URI = os.environ.get("MONGODB_URI") + +client: AsyncIOMotorClient | None = None +db = None + +if MONGODB_URI: + client = AsyncIOMotorClient(MONGODB_URI) + db = client.get_database("workout_logger") diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..a1a4539 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,260 @@ +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import Depends, FastAPI, HTTPException, Query, Security +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import APIKeyHeader + +from .models import UsageStats, BackupData, AppEvent, HeartbeatPayload +from .database import db + +logger = logging.getLogger(__name__) + +app = FastAPI(title="RepForge Analytics API", version="1.2.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ────────────────────── auth ────────────────────── + +ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "") + +_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +async def require_admin(api_key: Optional[str] = Security(_api_key_header)): + """Enforce admin API key on analytics read endpoints.""" + if not ADMIN_API_KEY: + raise HTTPException(status_code=503, detail="Admin key not configured") + if api_key != ADMIN_API_KEY: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +# ────────────────────── helpers ────────────────────── + +def _ensure_db(): + if db is None: + raise HTTPException(status_code=503, detail="Database not configured") + + +# ────────────────────── ingest endpoints ────────────────────── + +@app.post("/report") +async def report_usage(stats: UsageStats): + """Receive periodic usage-stats snapshots from a device.""" + _ensure_db() + try: + doc = stats.model_dump() + # upsert: latest report per user_app_id replaces older one + await db.reports.update_one( + {"user_app_id": stats.user_app_id}, + {"$set": dict(doc)}, # copy to avoid _id mutation leaking + upsert=True, + ) + # also keep a time-series log for trend analysis + doc.pop("_id", None) + await db.report_log.insert_one(doc) + return {"status": "success", "message": "Usage stats reported"} + except HTTPException: + raise + except Exception as e: + logger.exception("report_usage failed") + raise HTTPException(status_code=500, detail="Internal server error") from e + + +@app.post("/backup") +async def backup_data(data: BackupData): + """Receive a full data backup from a device.""" + _ensure_db() + try: + doc = data.parsed_backup() # decode any JSON-string list items + # keep only latest backup per user; overwrite previous + await db.backups.update_one( + {"user_app_id": data.user_app_id}, + {"$set": doc}, + upsert=True, + ) + return {"status": "success", "message": "Backup received"} + except HTTPException: + raise + except Exception as e: + logger.exception("backup_data failed") + raise HTTPException(status_code=500, detail="Internal server error") from e + + +@app.post("/event") +async def track_event(event: AppEvent): + """Record a lightweight analytics event.""" + _ensure_db() + try: + await db.events.insert_one(event.model_dump()) + return {"status": "success"} + except HTTPException: + raise + except Exception as e: + logger.exception("track_event failed") + raise HTTPException(status_code=500, detail="Internal server error") from e + + +@app.post("/heartbeat") +async def heartbeat(payload: HeartbeatPayload): + """Minimal ping on every app-open for DAU/MAU tracking.""" + _ensure_db() + try: + doc = payload.model_dump() + # upsert user record + await db.users.update_one( + {"user_app_id": payload.user_app_id}, + { + "$set": { + "last_seen": doc["timestamp"], + "app_version": doc.get("app_version"), + "platform": doc.get("platform"), + }, + "$setOnInsert": {"first_seen": doc["timestamp"]}, + "$inc": {"total_opens": 1}, + }, + upsert=True, + ) + # also append to heartbeat log for DAU/MAU queries + await db.heartbeats.insert_one(doc) + return {"status": "success"} + except HTTPException: + raise + except Exception as e: + logger.exception("heartbeat failed") + raise HTTPException(status_code=500, detail="Internal server error") from e + + +# ────────────────────── analytics / read endpoints ────────────────────── +# All analytics routes require ADMIN_API_KEY via X-API-Key header. + +@app.get("/analytics/overview", dependencies=[Depends(require_admin)]) +async def analytics_overview(): + """High-level numbers: total users, DAU, WAU, MAU, total workouts.""" + _ensure_db() + now = datetime.now(timezone.utc) + day_ago = now - timedelta(days=1) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + total_users = await db.users.count_documents({}) + # DAU = distinct users with heartbeat in last 24 h + dau_ids = await db.heartbeats.distinct( + "user_app_id", {"timestamp": {"$gte": day_ago}} + ) + wau_ids = await db.heartbeats.distinct( + "user_app_id", {"timestamp": {"$gte": week_ago}} + ) + mau_ids = await db.heartbeats.distinct( + "user_app_id", {"timestamp": {"$gte": month_ago}} + ) + + # aggregate total workouts across latest reports + pipeline = [{"$group": {"_id": None, "total": {"$sum": "$total_workouts"}}}] + cursor = db.reports.aggregate(pipeline) + agg = await cursor.to_list(1) + total_workouts = agg[0]["total"] if agg else 0 + + return { + "total_users": total_users, + "dau": len(dau_ids), + "wau": len(wau_ids), + "mau": len(mau_ids), + "total_workouts_all_users": total_workouts, + } + + +@app.get("/analytics/retention", dependencies=[Depends(require_admin)]) +async def analytics_retention(days: int = Query(30, ge=1, le=365)): + """Return daily active-user counts for the last N days (retention curve).""" + _ensure_db() + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + pipeline = [ + {"$match": {"timestamp": {"$gte": start}}}, + { + "$group": { + "_id": { + "$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"} + }, + "unique_users": {"$addToSet": "$user_app_id"}, + } + }, + { + "$project": { + "date": "$_id", + "active_users": {"$size": "$unique_users"}, + "_id": 0, + } + }, + {"$sort": {"date": 1}}, + ] + cursor = db.heartbeats.aggregate(pipeline) + results = await cursor.to_list(days + 1) + return {"days": days, "retention": results} + + +@app.get("/analytics/events", dependencies=[Depends(require_admin)]) +async def analytics_events( + event: Optional[str] = None, + days: int = Query(7, ge=1, le=365), +): + """Aggregate event counts, optionally filtered by event name.""" + _ensure_db() + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + match: dict = {"timestamp": {"$gte": start}} + if event: + match["event"] = event + + pipeline = [ + {"$match": match}, + {"$group": {"_id": "$event", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + cursor = db.events.aggregate(pipeline) + results = await cursor.to_list(100) + return {"days": days, "events": [{"event": r["_id"], "count": r["count"]} for r in results]} + + +@app.get("/analytics/users", dependencies=[Depends(require_admin)]) +async def analytics_users( + limit: int = Query(50, ge=1, le=500), + sort_by: str = Query("last_seen", regex="^(last_seen|first_seen|total_opens)$"), +): + """List user records (PII-redacted) for drill-down.""" + _ensure_db() + cursor = db.users.find( + {}, {"_id": 0} + ).sort(sort_by, -1).limit(limit) + users = await cursor.to_list(limit) + return {"count": len(users), "users": users} + + +@app.get("/analytics/user/{user_app_id}", dependencies=[Depends(require_admin)]) +async def analytics_user_detail(user_app_id: str): + """Full detail for a single installation: profile, latest report, events.""" + _ensure_db() + user = await db.users.find_one({"user_app_id": user_app_id}, {"_id": 0}) + report = await db.reports.find_one({"user_app_id": user_app_id}, {"_id": 0}) + events_cursor = db.events.find( + {"user_app_id": user_app_id}, {"_id": 0} + ).sort("timestamp", -1).limit(50) + events = await events_cursor.to_list(50) + return {"user": user, "latest_report": report, "recent_events": events} + + +# ────────────────────── health ────────────────────── + +@app.get("/") +async def root(): + return {"message": "RepForge Analytics Backend Running", "version": "1.2.0"} diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..ac34ed5 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,96 @@ +import json + +from pydantic import BaseModel, Field, field_validator +from typing import Any, Optional +from datetime import datetime, timezone + +MAX_LIST_ITEMS = 5000 +MAX_JSON_NESTING_DEPTH = 20 + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class UsageStats(BaseModel): + user_app_id: str # unique per-install identifier + total_workouts: int + weekly_workouts: int + weekly_volume: float + exercises_this_week: int + app_version: Optional[str] = None + platform: Optional[str] = None # android / ios / web + report_date: datetime = Field(default_factory=_utcnow) + + +class BackupData(BaseModel): + user_app_id: str + sessions: list[Any] = Field(default_factory=list, max_length=MAX_LIST_ITEMS) + routines: list[Any] = Field(default_factory=list, max_length=MAX_LIST_ITEMS) + targets: list[Any] = Field(default_factory=list, max_length=MAX_LIST_ITEMS) + muscleGroups: list[Any] = Field(default_factory=list, max_length=MAX_LIST_ITEMS) + customExercises: list[Any] = Field(default_factory=list, max_length=MAX_LIST_ITEMS) + exportDate: str # ISO string from Dart + backup_received_at: datetime = Field(default_factory=_utcnow) + + @field_validator("sessions", "routines", "targets", "muscleGroups", "customExercises", mode="before") + @classmethod + def _enforce_list_size(cls, v: Any) -> Any: + if isinstance(v, list) and len(v) > MAX_LIST_ITEMS: + raise ValueError(f"List exceeds maximum of {MAX_LIST_ITEMS} items") + return v + + @staticmethod + def _parse_list(items: list) -> list: + """Decode any JSON-string items; enforce nesting depth.""" + out: list = [] + for item in items: + if isinstance(item, str): + try: + # Custom decoder with depth guard + _depth_guard_decode(item, max_depth=MAX_JSON_NESTING_DEPTH) + out.append(json.loads(item)) + except (json.JSONDecodeError, TypeError, ValueError): + out.append(item) + else: + out.append(item) + return out + + def parsed_backup(self) -> dict: + """Return a copy with any JSON-string items decoded to dicts.""" + data = self.model_dump() + for key in ("sessions", "routines", "targets", "muscleGroups", "customExercises"): + data[key] = self._parse_list(data.get(key, [])) + return data + + +def _depth_guard_decode(s: str, max_depth: int = 20) -> None: + """Raise ValueError if the JSON string nests deeper than max_depth.""" + depth = 0 + for ch in s: + if ch in "{[": + depth += 1 + if depth > max_depth: + raise ValueError(f"JSON nesting exceeds maximum depth of {max_depth}") + elif ch in "}]": + depth -= 1 + + +class AppEvent(BaseModel): + """Lightweight event for tracking feature usage, app opens, etc.""" + user_app_id: str + event: str # e.g. "app_open", "workout_started", "backup_triggered" + metadata: Optional[dict[str, Any]] = None + app_version: Optional[str] = None + platform: Optional[str] = None + timestamp: datetime = Field(default_factory=_utcnow) + + +class HeartbeatPayload(BaseModel): + """Minimal ping sent on every app open for DAU/MAU calculation.""" + user_app_id: str + app_version: Optional[str] = None + platform: Optional[str] = None + timestamp: datetime = Field(default_factory=_utcnow) + platform: Optional[str] = None + timestamp: datetime = Field(default_factory=_utcnow) diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..8649cce --- /dev/null +++ b/dashboard/app.py @@ -0,0 +1,344 @@ +""" +RepForge Analytics Dashboard +──────────────────────────── +Connects to the same MongoDB Atlas used by the FastAPI backend +and visualises usage stats, retention, events, and user drill-downs. +""" + +import streamlit as st +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +from pymongo import MongoClient +from pymongo.errors import ConnectionFailure, ConfigurationError +from datetime import datetime, timedelta, timezone + +# ─── page config ─── +st.set_page_config( + page_title="RepForge Analytics", + page_icon="🏋️", + layout="wide", +) + +# ─── MongoDB connection (cached) ─── +@st.cache_resource +def get_db(): + try: + uri = st.secrets["mongo"]["uri"] + except (KeyError, FileNotFoundError): + st.error("MongoDB URI not found. Add `[mongo]` with `uri` to `.streamlit/secrets.toml`.") + return None + try: + client = MongoClient(uri, serverSelectionTimeoutMS=5000) + # Force a connection check + client.admin.command("ping") + return client.get_database("workout_logger") + except (ConnectionFailure, ConfigurationError) as e: + st.error(f"Failed to connect to MongoDB: {e}") + return None + + +db = get_db() +if db is None: + st.stop() + +# ─── sidebar ─── +st.sidebar.title("🏋️ RepForge Analytics") +page = st.sidebar.radio( + "Navigate", + ["Overview", "Retention", "Events", "Users", "Backups"], +) +st.sidebar.markdown("---") +st.sidebar.caption(f"Data as of {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC") + + +# ════════════════════════════════════════════════════════════ +# OVERVIEW +# ════════════════════════════════════════════════════════════ +if page == "Overview": + st.title("📊 Overview") + + now = datetime.now(timezone.utc) + day_ago = now - timedelta(days=1) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + total_users = db.users.count_documents({}) + dau = len(db.heartbeats.distinct("user_app_id", {"timestamp": {"$gte": day_ago}})) + wau = len(db.heartbeats.distinct("user_app_id", {"timestamp": {"$gte": week_ago}})) + mau = len(db.heartbeats.distinct("user_app_id", {"timestamp": {"$gte": month_ago}})) + + # total workouts across all latest reports + agg = list(db.reports.aggregate([{"$group": {"_id": None, "total": {"$sum": "$total_workouts"}}}])) + total_workouts = agg[0]["total"] if agg else 0 + + total_backups = db.backups.count_documents({}) + total_events = db.events.count_documents({}) + + # KPI cards + c1, c2, c3, c4 = st.columns(4) + c1.metric("Total Installs", total_users) + c2.metric("DAU", dau) + c3.metric("WAU", wau) + c4.metric("MAU", mau) + + c5, c6, c7 = st.columns(3) + c5.metric("Total Workouts (all users)", total_workouts) + c6.metric("Total Backups", total_backups) + c7.metric("Total Events Logged", total_events) + + st.markdown("---") + + # Platform distribution + st.subheader("Platform Distribution") + platform_data = list(db.users.aggregate([ + {"$group": {"_id": "$platform", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ])) + if platform_data: + df_plat = pd.DataFrame(platform_data).rename(columns={"_id": "platform"}) + fig = px.pie(df_plat, names="platform", values="count", hole=0.4) + st.plotly_chart(fig, use_container_width=True) + else: + st.info("No platform data yet.") + + # Daily heartbeats (last 30 days) + st.subheader("Daily Active Heartbeats (30 days)") + hb_pipeline = [ + {"$match": {"timestamp": {"$gte": month_ago}}}, + {"$group": { + "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"}}, + "unique_users": {"$addToSet": "$user_app_id"}, + }}, + {"$project": {"date": "$_id", "active_users": {"$size": "$unique_users"}, "_id": 0}}, + {"$sort": {"date": 1}}, + ] + hb_data = list(db.heartbeats.aggregate(hb_pipeline)) + if hb_data: + df_hb = pd.DataFrame(hb_data) + fig2 = px.bar(df_hb, x="date", y="active_users", labels={"active_users": "Unique Users"}) + st.plotly_chart(fig2, use_container_width=True) + else: + st.info("No heartbeat data yet.") + + +# ════════════════════════════════════════════════════════════ +# RETENTION +# ════════════════════════════════════════════════════════════ +elif page == "Retention": + st.title("📈 User Retention") + + days = st.slider("Lookback window (days)", 7, 180, 30) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + + pipeline = [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": { + "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"}}, + "unique_users": {"$addToSet": "$user_app_id"}, + }}, + {"$project": {"date": "$_id", "active_users": {"$size": "$unique_users"}, "_id": 0}}, + {"$sort": {"date": 1}}, + ] + data = list(db.heartbeats.aggregate(pipeline)) + if data: + df = pd.DataFrame(data) + fig = px.area(df, x="date", y="active_users", title="Daily Active Users") + st.plotly_chart(fig, use_container_width=True) + + # New vs returning users (users whose first_seen is in the window) + st.subheader("New Installs per Day") + new_pipeline = [ + {"$match": {"first_seen": {"$gte": start}}}, + {"$group": { + "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": "$first_seen"}}, + "new_users": {"$sum": 1}, + }}, + {"$project": {"date": "$_id", "new_users": 1, "_id": 0}}, + {"$sort": {"date": 1}}, + ] + new_data = list(db.users.aggregate(new_pipeline)) + if new_data: + df_new = pd.DataFrame(new_data) + fig2 = px.bar(df_new, x="date", y="new_users", title="New Installs") + st.plotly_chart(fig2, use_container_width=True) + else: + st.info("No new-install data yet.") + else: + st.info("No heartbeat data in the selected window.") + + +# ════════════════════════════════════════════════════════════ +# EVENTS +# ════════════════════════════════════════════════════════════ +elif page == "Events": + st.title("⚡ Event Analytics") + + days = st.slider("Lookback (days)", 1, 90, 7) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + + # Aggregate event counts + pipeline = [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": {"_id": "$event", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + ev_data = list(db.events.aggregate(pipeline)) + if ev_data: + df = pd.DataFrame(ev_data).rename(columns={"_id": "event"}) + fig = px.bar(df, x="event", y="count", color="event", title="Event Counts") + st.plotly_chart(fig, use_container_width=True) + + # Event timeline + st.subheader("Events Over Time") + tl_pipeline = [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": { + "_id": { + "date": {"$dateToString": {"format": "%Y-%m-%d", "date": "$timestamp"}}, + "event": "$event", + }, + "count": {"$sum": 1}, + }}, + {"$project": {"date": "$_id.date", "event": "$_id.event", "count": 1, "_id": 0}}, + {"$sort": {"date": 1}}, + ] + tl_data = list(db.events.aggregate(tl_pipeline)) + if tl_data: + df_tl = pd.DataFrame(tl_data) + fig2 = px.line(df_tl, x="date", y="count", color="event", title="Events Over Time") + st.plotly_chart(fig2, use_container_width=True) + else: + st.info("No events in the selected window.") + + +# ════════════════════════════════════════════════════════════ +# USERS +# ════════════════════════════════════════════════════════════ +elif page == "Users": + st.title("👤 User Directory") + + sort_by = st.selectbox("Sort by", ["last_seen", "first_seen", "total_opens"]) + limit = st.number_input("Limit", 10, 500, 50) + + users = list( + db.users.find({}, {"_id": 0}) + .sort(sort_by, -1) + .limit(limit) + ) + + if users: + df = pd.DataFrame(users) + st.dataframe(df, use_container_width=True) + + # Drill-down + st.markdown("---") + st.subheader("User Detail") + app_ids = [u.get("user_app_id", "?") for u in users] + selected = st.selectbox("Select user_app_id", app_ids) + + if selected: + col1, col2 = st.columns(2) + with col1: + st.markdown("**User record**") + user_doc = db.users.find_one({"user_app_id": selected}, {"_id": 0}) + st.json(user_doc or {}) + + with col2: + st.markdown("**Latest Usage Report**") + report = db.reports.find_one({"user_app_id": selected}, {"_id": 0}) + if report: + st.json(report) + else: + st.info("No report yet.") + + st.markdown("**Recent Events**") + evts = list( + db.events.find({"user_app_id": selected}, {"_id": 0}) + .sort("timestamp", -1) + .limit(30) + ) + if evts: + st.dataframe(pd.DataFrame(evts), use_container_width=True) + else: + st.info("No events for this user.") + else: + st.info("No users found.") + + +# ════════════════════════════════════════════════════════════ +# BACKUPS +# ════════════════════════════════════════════════════════════ +elif page == "Backups": + st.title("💾 Backup Explorer") + + backups = list( + db.backups.find({}, {"_id": 0, "sessions": 0, "routines": 0, + "targets": 0, "muscleGroups": 0, "customExercises": 0}) + .sort("backup_received_at", -1) + .limit(100) + ) + + if backups: + df = pd.DataFrame(backups) + st.dataframe(df, use_container_width=True) + + st.markdown("---") + st.subheader("Backup Detail") + ids = [b.get("user_app_id", "?") for b in backups] + sel = st.selectbox("Select user_app_id", ids, key="backup_user") + if sel: + # Fetch only lightweight metadata first (project out heavy arrays) + meta = db.backups.find_one( + {"user_app_id": sel}, + {"_id": 0, "sessions": 0, "routines": 0, + "targets": 0, "muscleGroups": 0, "customExercises": 0}, + ) + # Fetch counts via separate aggregation to avoid loading full arrays + counts_doc = db.backups.find_one( + {"user_app_id": sel}, + { + "_id": 0, + "sessions_count": {"$size": {"$ifNull": ["$sessions", []]}}, + "routines_count": {"$size": {"$ifNull": ["$routines", []]}}, + "custom_count": {"$size": {"$ifNull": ["$customExercises", []]}}, + }, + ) + # Fallback: if $size projection not supported, load counts differently + if counts_doc and "sessions_count" in counts_doc: + n_sessions = counts_doc["sessions_count"] + n_routines = counts_doc["routines_count"] + n_custom = counts_doc["custom_count"] + else: + # Lightweight fallback: just load count fields + full = db.backups.find_one({"user_app_id": sel}, {"_id": 0}) + n_sessions = len(full.get("sessions", [])) if full else 0 + n_routines = len(full.get("routines", [])) if full else 0 + n_custom = len(full.get("customExercises", [])) if full else 0 + + mc1, mc2, mc3 = st.columns(3) + mc1.metric("Sessions", n_sessions) + mc2.metric("Routines", n_routines) + mc3.metric("Custom Exercises", n_custom) + + if meta: + st.json(meta) + + # Only load full document on explicit user action + total_items = n_sessions + n_routines + n_custom + if total_items > 500: + st.warning(f"This backup contains {total_items} items. Loading the full document may be slow.") + with st.expander("Load full backup JSON"): + if st.button("Fetch full backup", key="load_full_backup"): + full_doc = db.backups.find_one({"user_app_id": sel}, {"_id": 0}) + if full_doc: + # Show truncated preview (first 5 items per array) + for arr_key in ("sessions", "routines", "targets", "muscleGroups", "customExercises"): + arr = full_doc.get(arr_key, []) + if len(arr) > 5: + full_doc[arr_key] = arr[:5] + [f"... and {len(arr) - 5} more"] + st.json(full_doc) + else: + st.info("No backups found.") diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt new file mode 100644 index 0000000..1543717 --- /dev/null +++ b/dashboard/requirements.txt @@ -0,0 +1,5 @@ +streamlit~=1.41.0 +pymongo[srv]~=4.10.0 +dnspython~=2.7.0 +pandas~=2.2.0 +plotly~=5.24.0 diff --git a/main.py b/main.py new file mode 100644 index 0000000..e635c40 --- /dev/null +++ b/main.py @@ -0,0 +1 @@ +from backend.main import app diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..87bd591 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +motor==3.6.0 +dnspython==2.7.0 +pydantic==2.9.2 diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 0df1048..3c62870 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -12,6 +12,7 @@ import 'services/ml_service.dart'; import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; import 'services/workout_provider.dart'; +import 'services/api_service.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; @@ -56,6 +57,8 @@ class WorkoutLoggerApp extends StatelessWidget { Provider.value(value: _storageService), // Provide the ML service interface for direct access if needed Provider.value(value: _mlService), + // Provide the ApiService singleton via DI + Provider.value(value: ApiService()), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( create: (_) => @@ -93,6 +96,20 @@ class _AppInitializerState extends State { try { final provider = context.read(); await provider.init(); + + // Fire-and-forget analytics in background + final api = context.read(); + api.sendHeartbeat(); + api.trackEvent('app_open'); + provider + .getQuickStats() + .then((stats) { + api.reportUsage(stats); + }) + .catchError((e) { + debugPrint('Failed to report usage: $e'); + }); + setState(() => _initialized = true); } catch (e) { setState(() => _error = e.toString()); diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index 45ab30a..feea401 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -1,6 +1,5 @@ // Edit Workout Session Screen - Modify recorded workout sessions -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index dec4c41..f9ff39f 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -11,6 +11,7 @@ import 'history_screen.dart'; import 'routines_screen.dart'; import 'analytics_screen.dart'; import 'exercise_library_screen.dart'; +import 'settings_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -134,19 +135,34 @@ class DashboardTab extends StatelessWidget { final now = DateTime.now(); final greeting = now.hour < 12 ? 'Good morning' : (now.hour < 17 ? 'Good afternoon' : 'Good evening'); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - greeting, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppTheme.textSecondary, - ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + greeting, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + 'Ready to crush it? 💪', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], ), - const SizedBox(height: 4), - Text( - 'Ready to crush it? 💪', - style: Theme.of(context).textTheme.headlineMedium, + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); + }, + icon: const Icon(Icons.settings_outlined), + color: AppTheme.textPrimary, ), ], ); diff --git a/workout-logger/lib/screens/settings_screen.dart b/workout-logger/lib/screens/settings_screen.dart new file mode 100644 index 0000000..f95fe04 --- /dev/null +++ b/workout-logger/lib/screens/settings_screen.dart @@ -0,0 +1,445 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:intl/intl.dart'; +import '../services/workout_provider.dart'; +import '../services/api_service.dart'; +import '../theme/app_theme.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + bool _isBackingUp = false; + bool _isExporting = false; + bool _isImporting = false; + + // ==================== Remote Backup ==================== + + Future _performBackup() async { + setState(() => _isBackingUp = true); + + try { + final provider = context.read(); + final jsonString = await provider.exportAllData(); + final data = jsonDecode(jsonString) as Map; + + final api = context.read(); + await api.trackEvent('backup_triggered').catchError((_) => null); + final success = await api.backupData(data); + + if (!mounted) return; + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Backup successful!'), + backgroundColor: AppTheme.success, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Backup failed. Please try again.'), + backgroundColor: AppTheme.error, + ), + ); + } + } catch (e, stackTrace) { + debugPrint('Backup error: $e\n$stackTrace'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Something went wrong. Please try again.'), + backgroundColor: AppTheme.error, + ), + ); + } finally { + if (mounted) { + setState(() => _isBackingUp = false); + } + } + } + + // ==================== Local Export ==================== + + Future _exportToFile() async { + setState(() => _isExporting = true); + + try { + final provider = context.read(); + final jsonString = await provider.exportAllData(); + + final tempDir = await getTemporaryDirectory(); + final dateStr = DateFormat('yyyy-MM-dd_HHmmss').format(DateTime.now()); + final fileName = 'repforge_backup_$dateStr.json'; + final file = File('${tempDir.path}/$fileName'); + await file.writeAsString(jsonString); + + final result = await Share.shareXFiles([ + XFile(file.path), + ], subject: 'RepForge Backup'); + + if (!mounted) return; + + if (result.status == ShareResultStatus.success || + result.status == ShareResultStatus.dismissed) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Backup file exported successfully!'), + backgroundColor: AppTheme.success, + ), + ); + } + } catch (e, stackTrace) { + debugPrint('Export error: $e\n$stackTrace'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export failed. Please try again.'), + backgroundColor: AppTheme.error, + ), + ); + } finally { + if (mounted) { + setState(() => _isExporting = false); + } + } + } + + // ==================== Local Import ==================== + + Future _importFromFile() async { + // Show confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.cardColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Import Backup', + style: TextStyle(color: AppTheme.textPrimary), + ), + content: const Text( + 'This will merge the backup data with your existing data. ' + 'Existing workouts, routines, and targets will be kept. ' + 'New items from the backup will be added.\n\n' + 'Select a .json backup file to continue.', + style: TextStyle(color: AppTheme.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text( + 'Cancel', + style: TextStyle(color: AppTheme.textSecondary), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('Choose File'), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _isImporting = true); + + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + + if (result == null || result.files.single.path == null) { + if (mounted) setState(() => _isImporting = false); + return; + } + + final file = File(result.files.single.path!); + final jsonString = await file.readAsString(); + + // Basic validation: ensure it's valid JSON with expected keys + final data = jsonDecode(jsonString) as Map; + if (!data.containsKey('sessions') && !data.containsKey('routines')) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid backup file. Expected a RepForge backup.'), + backgroundColor: AppTheme.error, + ), + ); + return; + } + + final provider = context.read(); + await provider.importData(jsonString); + + if (!mounted) return; + + final itemCount = (data['sessions'] as List?)?.length ?? 0; + final routineCount = (data['routines'] as List?)?.length ?? 0; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Import complete! Processed $itemCount sessions, $routineCount routines.', + ), + backgroundColor: AppTheme.success, + ), + ); + } catch (e, stackTrace) { + debugPrint('Import error: $e\n$stackTrace'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Import failed. Make sure you selected a valid backup file.', + ), + backgroundColor: AppTheme.error, + ), + ); + } finally { + if (mounted) { + setState(() => _isImporting = false); + } + } + } + + // ==================== Build ==================== + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + backgroundColor: AppTheme.surfaceColor, + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSectionHeader('Data Management'), + const SizedBox(height: 16), + _buildLocalBackupCard(), + const SizedBox(height: 16), + _buildBackupCard(), + ], + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ); + } + + Widget _buildLocalBackupCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.secondaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.save_alt_rounded, + color: AppTheme.secondaryColor, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Local Backup', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + Text( + 'Export or import a backup file to transfer data between devices', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isExporting ? null : _exportToFile, + icon: _isExporting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Icon(Icons.upload_file_rounded, size: 20), + label: Text(_isExporting ? 'Exporting...' : 'Export'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.secondaryColor, + foregroundColor: AppTheme.backgroundColor, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _isImporting ? null : _importFromFile, + icon: _isImporting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppTheme.secondaryColor, + ), + ), + ) + : const Icon(Icons.download_rounded, size: 20), + label: Text(_isImporting ? 'Importing...' : 'Import'), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.secondaryColor, + side: const BorderSide(color: AppTheme.secondaryColor), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildBackupCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.cloud_upload_outlined, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Remote Backup', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + Text( + 'Securely backup your workout data to the cloud', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isBackingUp ? null : _performBackup, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isBackingUp + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Backup Now'), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart new file mode 100644 index 0000000..e66213a --- /dev/null +++ b/workout-logger/lib/services/api_service.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'dart:async'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:uuid/uuid.dart'; + +// Conditional import: uses dart:io on native, stub on web. +import 'platform_stub.dart' if (dart.library.io) 'platform_io.dart'; + +/// Singleton service for communicating with the RepForge analytics backend. +class ApiService { + static const String _baseUrl = String.fromEnvironment( + 'API_URL', + defaultValue: 'https://workout-logger-production-1e93.up.railway.app', + ); + + static final ApiService _instance = ApiService._internal(); + + factory ApiService() => _instance; + ApiService._internal(); + + http.Client _client = http.Client(); + + /// Replace the HTTP client for testing only. + @visibleForTesting + static void setTestClient(http.Client client) { + _instance._client = client; + } + + String? _cachedAppId; + + /// Returns a stable installation ID (UUID v4) persisted in Hive. + Future get userAppId async { + if (_cachedAppId != null) return _cachedAppId!; + // Defensively open the box if it's not already open + final Box box; + if (Hive.isBoxOpen('settings')) { + box = Hive.box('settings'); + } else { + box = await Hive.openBox('settings'); + } + var id = box.get('user_app_id'); + if (id == null) { + id = const Uuid().v4(); + await box.put('user_app_id', id); + } + _cachedAppId = id; + return id; + } + + String get _platform { + if (kIsWeb) return 'web'; + return getPlatformName(); + } + + // ───────── ingest helpers ───────── + + Future sendHeartbeat() async { + try { + final id = await userAppId; + final body = { + 'user_app_id': id, + 'platform': _platform, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + }; + final res = await _client + .post( + Uri.parse('$_baseUrl/heartbeat'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 10)); + if (res.statusCode != 200) { + debugPrint('Heartbeat failed: ${res.body}'); + } + } on TimeoutException { + debugPrint('Heartbeat timeout after 10 seconds'); + } catch (e) { + debugPrint('Heartbeat error: $e'); + } + } + + Future trackEvent( + String event, { + Map? metadata, + }) async { + try { + final id = await userAppId; + final body = { + 'user_app_id': id, + 'event': event, + 'platform': _platform, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + if (metadata != null) 'metadata': metadata, + }; + final res = await _client + .post( + Uri.parse('$_baseUrl/event'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 10)); + if (res.statusCode != 200) { + debugPrint('Event tracking failed: ${res.body}'); + } + } on TimeoutException { + debugPrint('Event tracking timeout after 10 seconds'); + } catch (e) { + debugPrint('Event tracking error: $e'); + } + } + + Future reportUsage(Map stats) async { + try { + final id = await userAppId; + final payload = { + 'user_app_id': id, + 'total_workouts': stats['totalWorkouts'], + 'weekly_workouts': stats['weeklyWorkouts'], + 'weekly_volume': stats['weeklyVolume'], + 'exercises_this_week': stats['exercisesThisWeek'], + 'platform': _platform, + 'report_date': DateTime.now().toUtc().toIso8601String(), + }; + + final response = await _client + .post( + Uri.parse('$_baseUrl/report'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + debugPrint('Failed to report usage: ${response.body}'); + } + } on TimeoutException { + debugPrint('Usage report timeout after 10 seconds'); + } catch (e) { + debugPrint('Error reporting usage: $e'); + } + } + + Future backupData(Map data) async { + try { + final id = await userAppId; + + final payload = {'user_app_id': id, ...data}; + + final response = await _client + .post( + Uri.parse('$_baseUrl/backup'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ) + .timeout(const Duration(minutes: 2)); + + if (response.statusCode == 200) { + return true; + } else { + debugPrint('Failed to backup data: ${response.body}'); + return false; + } + } on TimeoutException { + debugPrint('Backup upload timeout after 2 minutes'); + return false; + } catch (e) { + debugPrint('Error backing up data: $e'); + return false; + } + } +} diff --git a/workout-logger/lib/services/platform_io.dart b/workout-logger/lib/services/platform_io.dart new file mode 100644 index 0000000..699b0c6 --- /dev/null +++ b/workout-logger/lib/services/platform_io.dart @@ -0,0 +1,11 @@ +import 'dart:io' show Platform; + +/// Returns the platform name using dart:io (native builds only). +String getPlatformName() { + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + if (Platform.isLinux) return 'linux'; + return 'unknown'; +} diff --git a/workout-logger/lib/services/platform_stub.dart b/workout-logger/lib/services/platform_stub.dart new file mode 100644 index 0000000..a04d169 --- /dev/null +++ b/workout-logger/lib/services/platform_stub.dart @@ -0,0 +1,2 @@ +/// Stub for web builds — dart:io is unavailable. +String getPlatformName() => 'web'; diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index dbf1ede..18ffa74 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../models/models.dart'; import '../data/exercise_database.dart'; import 'interfaces/storage_service_interface.dart'; @@ -30,12 +31,28 @@ class StorageService implements IStorageService { late Box _customExercisesBoxInstance; late Box _settingsBoxInstance; + String _appVersion = const String.fromEnvironment( + 'APP_VERSION', + defaultValue: 'unknown', + ); + bool _initialized = false; /// Initialize Hive and open boxes + @override Future init() async { if (_initialized) return; + try { + final packageInfo = await PackageInfo.fromPlatform(); + final version = packageInfo.version; + final buildNumber = packageInfo.buildNumber; + _appVersion = buildNumber.isNotEmpty ? '$version+$buildNumber' : version; + } catch (_) { + // Keep build-time fallback from APP_VERSION/unknown in environments + // where platform package metadata is unavailable. + } + await Hive.initFlutter(); _sessionsBox = await Hive.openBox(_workoutSessionsBox); @@ -64,10 +81,12 @@ class StorageService implements IStorageService { // ==================== WORKOUT SESSIONS ==================== + @override Future saveWorkoutSession(WorkoutSession session) async { await _sessionsBox.put(session.id, jsonEncode(session.toJson())); } + @override Future> getAllWorkoutSessions() async { final sessions = []; for (var json in _sessionsBox.values) { @@ -77,16 +96,19 @@ class StorageService implements IStorageService { return sessions; } + @override Future getWorkoutSession(String id) async { final json = _sessionsBox.get(id); if (json == null) return null; return WorkoutSession.fromJson(jsonDecode(json)); } + @override Future deleteWorkoutSession(String id) async { await _sessionsBox.delete(id); } + @override Future> getSessionsForExercise(String exerciseId) async { final allSessions = await getAllWorkoutSessions(); return allSessions @@ -96,6 +118,7 @@ class StorageService implements IStorageService { .toList(); } + @override Future> getSessionsInDateRange( DateTime start, DateTime end, @@ -111,10 +134,12 @@ class StorageService implements IStorageService { // ==================== ROUTINES ==================== + @override Future saveRoutine(Routine routine) async { await _routinesBoxInstance.put(routine.id, jsonEncode(routine.toJson())); } + @override Future> getAllRoutines() async { final routines = []; for (var json in _routinesBoxInstance.values) { @@ -123,22 +148,26 @@ class StorageService implements IStorageService { return routines; } + @override Future getRoutine(String id) async { final json = _routinesBoxInstance.get(id); if (json == null) return null; return Routine.fromJson(jsonDecode(json)); } + @override Future deleteRoutine(String id) async { await _routinesBoxInstance.delete(id); } // ==================== TARGETS ==================== + @override Future saveTarget(Target target) async { await _targetsBoxInstance.put(target.id, jsonEncode(target.toJson())); } + @override Future> getAllTargets() async { final targets = []; for (var json in _targetsBoxInstance.values) { @@ -147,16 +176,19 @@ class StorageService implements IStorageService { return targets; } + @override Future getTarget(String id) async { final json = _targetsBoxInstance.get(id); if (json == null) return null; return Target.fromJson(jsonDecode(json)); } + @override Future deleteTarget(String id) async { await _targetsBoxInstance.delete(id); } + @override Future> getTargetsForExercise(String exerciseId) async { final allTargets = await getAllTargets(); return allTargets.where((t) => t.exerciseId == exerciseId).toList(); @@ -164,6 +196,7 @@ class StorageService implements IStorageService { // ==================== MUSCLE GROUPS ==================== + @override Future updateMuscleGroupGrowthRate( String muscleGroupId, double rate, @@ -180,6 +213,7 @@ class StorageService implements IStorageService { } } + @override Future> getAllMuscleGroups() async { final groups = []; for (var json in _muscleGroupsBoxInstance.values) { @@ -188,6 +222,7 @@ class StorageService implements IStorageService { return groups; } + @override Future getMuscleGroup(String id) async { final json = _muscleGroupsBoxInstance.get(id); if (json == null) return null; @@ -196,6 +231,7 @@ class StorageService implements IStorageService { // ==================== CUSTOM EXERCISES ==================== + @override Future saveCustomExercise(Exercise exercise) async { await _customExercisesBoxInstance.put( exercise.id, @@ -203,6 +239,7 @@ class StorageService implements IStorageService { ); } + @override Future> getCustomExercises() async { final exercises = []; for (var json in _customExercisesBoxInstance.values) { @@ -211,11 +248,13 @@ class StorageService implements IStorageService { return exercises; } + @override Future deleteCustomExercise(String id) async { await _customExercisesBoxInstance.delete(id); } /// Get all exercises (built-in + custom) + @override Future> getAllExercises() async { final builtIn = ExerciseDatabase.getAll(); final custom = await getCustomExercises(); @@ -223,6 +262,7 @@ class StorageService implements IStorageService { } /// Get exercise by ID (built-in or custom) + @override Future getExercise(String id) async { // Check built-in first final builtIn = ExerciseDatabase.getById(id); @@ -239,58 +279,173 @@ class StorageService implements IStorageService { // ==================== SETTINGS ==================== + @override Future saveSetting(String key, String value) async { await _settingsBoxInstance.put(key, value); } + @override Future getSetting(String key) async { return _settingsBoxInstance.get(key); } // ==================== EXPORT / IMPORT ==================== + dynamic _normalizeExportValue(dynamic value) { + if (value is String) { + try { + return jsonDecode(value); + } catch (_) { + return value; + } + } + return value; + } + + Map? _normalizeImportItem(dynamic item) { + if (item is Map) { + return item; + } + if (item is Map) { + return Map.from(item); + } + if (item is String) { + // Backward compatibility for older exports that stored JSON strings. + try { + final decoded = jsonDecode(item); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) { + return null; + } + } + return null; + } + + @override Future exportAllData() async { + // Collect settings as a map + final settingsMap = {}; + for (final key in _settingsBoxInstance.keys) { + final value = _settingsBoxInstance.get(key); + if (value != null) { + settingsMap[key as String] = value; + } + } + final data = { - 'sessions': _sessionsBox.values.toList(), - 'routines': _routinesBoxInstance.values.toList(), - 'targets': _targetsBoxInstance.values.toList(), - 'muscleGroups': _muscleGroupsBoxInstance.values.toList(), - 'customExercises': _customExercisesBoxInstance.values.toList(), + 'sessions': _sessionsBox.values + .map(_normalizeExportValue) + .toList(growable: false), + 'routines': _routinesBoxInstance.values + .map(_normalizeExportValue) + .toList(growable: false), + 'targets': _targetsBoxInstance.values + .map(_normalizeExportValue) + .toList(growable: false), + 'muscleGroups': _muscleGroupsBoxInstance.values + .map(_normalizeExportValue) + .toList(growable: false), + 'customExercises': _customExercisesBoxInstance.values + .map(_normalizeExportValue) + .toList(growable: false), + 'settings': settingsMap, 'exportDate': DateTime.now().toIso8601String(), + 'appVersion': _appVersion, }; return jsonEncode(data); } + @override Future importData(String jsonData) async { final data = jsonDecode(jsonData) as Map; - // Import sessions - if (data['sessions'] != null) { - for (var json in data['sessions']) { - final session = WorkoutSession.fromJson(jsonDecode(json)); - await saveWorkoutSession(session); + // Import sessions (merge: skip if id already exists) + final sessions = data['sessions']; + if (sessions is List) { + for (var item in sessions) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final session = WorkoutSession.fromJson(map); + final existing = await getWorkoutSession(session.id); + if (existing == null) { + await saveWorkoutSession(session); + } + } + } + + // Import routines (merge: skip if id already exists) + final routines = data['routines']; + if (routines is List) { + for (var item in routines) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final routine = Routine.fromJson(map); + final existing = await getRoutine(routine.id); + if (existing == null) { + await saveRoutine(routine); + } + } + } + + // Import targets (merge: skip if id already exists) + final targets = data['targets']; + if (targets is List) { + for (var item in targets) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final target = Target.fromJson(map); + final existing = await getTarget(target.id); + if (existing == null) { + await saveTarget(target); + } + } + } + + // Import muscle groups (merge: skip if id already exists) + final muscleGroups = data['muscleGroups']; + if (muscleGroups is List) { + for (var item in muscleGroups) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final mg = MuscleGroup.fromJson(map); + final existing = await getMuscleGroup(mg.id); + if (existing == null) { + await _muscleGroupsBoxInstance.put(mg.id, jsonEncode(mg.toJson())); + } } } - // Import routines - if (data['routines'] != null) { - for (var json in data['routines']) { - final routine = Routine.fromJson(jsonDecode(json)); - await saveRoutine(routine); + // Import custom exercises (merge: skip if id already exists) + final customExercises = data['customExercises']; + if (customExercises is List) { + for (var item in customExercises) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final exercise = Exercise.fromJson(map); + final existing = _customExercisesBoxInstance.get(exercise.id); + if (existing == null) { + await saveCustomExercise(exercise); + } } } - // Import targets - if (data['targets'] != null) { - for (var json in data['targets']) { - final target = Target.fromJson(jsonDecode(json)); - await saveTarget(target); + // Import settings (merge: skip keys that already exist) + if (data['settings'] != null && data['settings'] is Map) { + final settings = data['settings'] as Map; + for (var entry in settings.entries) { + final existing = _settingsBoxInstance.get(entry.key); + if (existing == null) { + await _settingsBoxInstance.put(entry.key, entry.value.toString()); + } } } } // ==================== STATS ==================== + @override Future> getQuickStats() async { final sessions = await getAllWorkoutSessions(); final now = DateTime.now(); diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 4929edd..2f970ff 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -721,4 +721,16 @@ class WorkoutProvider extends ChangeNotifier { Future> getQuickStats() async { return await _storage.getQuickStats(); } + + // ==================== BACKUP ==================== + + Future exportAllData() async { + return await _storage.exportAllData(); + } + + Future importData(String jsonData) async { + await _storage.importData(jsonData); + await loadAllData(); + await _trainAllGrowthModels(); + } } diff --git a/workout-logger/pubspec.lock b/workout-logger/pubspec.lock deleted file mode 100644 index 08d743d..0000000 --- a/workout-logger/pubspec.lock +++ /dev/null @@ -1,725 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" - url: "https://pub.dev" - source: hosted - version: "93.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b - url: "https://pub.dev" - source: hosted - version: "10.0.1" - archive: - dependency: transitive - description: - name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" - url: "https://pub.dev" - source: hosted - version: "4.0.7" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: b4d854962a32fd9f8efc0b76f98214790b833af8b2e9b2df6bfc927c0415a072 - url: "https://pub.dev" - source: hosted - version: "2.10.5" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" - url: "https://pub.dev" - source: hosted - version: "8.12.3" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "8a0aa2b9bae196552b71575efc94580e447546c26c7120577bb6f81fbd33b52e" - url: "https://pub.dev" - source: hosted - version: "3.1.4" - equatable: - dependency: transitive - description: - name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" - url: "https://pub.dev" - source: hosted - version: "2.0.7" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - fl_chart: - dependency: "direct main" - description: - name: fl_chart - sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08" - url: "https://pub.dev" - source: hosted - version: "0.69.2" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" - url: "https://pub.dev" - source: hosted - version: "0.13.1" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.dev" - source: hosted - version: "5.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hive: - dependency: "direct main" - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_flutter: - dependency: "direct main" - description: - name: hive_flutter - sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc - url: "https://pub.dev" - source: hosted - version: "1.1.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" - url: "https://pub.dev" - source: hosted - version: "4.7.2" - intl: - dependency: "direct main" - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.dev" - source: hosted - version: "0.19.0" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.dev" - source: hosted - version: "5.1.1" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mockito: - dependency: "direct dev" - description: - name: mockito - sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 - url: "https://pub.dev" - source: hosted - version: "5.6.3" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e - url: "https://pub.dev" - source: hosted - version: "2.2.22" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" - url: "https://pub.dev" - source: hosted - version: "2.5.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" - url: "https://pub.dev" - source: hosted - version: "6.0.3" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" - url: "https://pub.dev" - source: hosted - version: "4.2.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" - url: "https://pub.dev" - source: hosted - version: "0.7.6" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - uuid: - dependency: "direct main" - description: - name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 - url: "https://pub.dev" - source: hosted - version: "4.5.2" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" - source: hosted - version: "15.0.2" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 4b9bfc4..f5d83f6 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -48,6 +48,13 @@ dependencies: # Utilities uuid: ^4.5.1 intl: ^0.19.0 + http: ^1.2.1 + package_info_plus: ^8.3.1 + + # Backup export/import + file_picker: ^10.3.10 + path_provider: ^2.1.5 + share_plus: ^12.0.1 dev_dependencies: flutter_test: