diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6168cc4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", + "customizations": { + "codespaces": { + "openFiles": [ + "README.md", + "dashboard/app.py" + ] + }, + "vscode": { + "settings": {}, + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] + } + }, + "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y 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] # May arrive as List[str] or List[dict] + routines: List[Any] + targets: List[Any] + muscleGroups: List[Any] + customExercises: List[Any] + exportDate: str # ISO string from Dart + backup_received_at: datetime = Field(default_factory=_utcnow) + + def parsed_backup(self) -> dict: + """Return a copy with any JSON-string items decoded to dicts.""" + import json + def _parse_list(items: list) -> list: + out = [] + for item in items: + if isinstance(item, str): + try: + out.append(json.loads(item)) + except (json.JSONDecodeError, TypeError): + out.append(item) + else: + out.append(item) + return out + + data = self.model_dump() + for key in ('sessions', 'routines', 'targets', 'muscleGroups', 'customExercises'): + data[key] = _parse_list(data.get(key, [])) + return data + + +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) diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..3208b0a --- /dev/null +++ b/dashboard/app.py @@ -0,0 +1,288 @@ +""" +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 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(): + uri = st.secrets["mongo"]["uri"] + client = MongoClient(uri) + return client.get_database("workout_logger") + + +db = get_db() + +# ─── 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({}) + total_heartbeats_today = db.heartbeats.count_documents({"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 (heartbeats today)", total_heartbeats_today) + 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: + doc = db.backups.find_one({"user_app_id": sel}, {"_id": 0}) + if doc: + st.metric("Sessions", len(doc.get("sessions", []))) + st.metric("Routines", len(doc.get("routines", []))) + st.metric("Custom Exercises", len(doc.get("customExercises", []))) + with st.expander("Raw backup JSON"): + st.json(doc) + else: + st.info("No backups found.") diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt new file mode 100644 index 0000000..8626a1e --- /dev/null +++ b/dashboard/requirements.txt @@ -0,0 +1,5 @@ +streamlit +pymongo[srv] +dnspython +pandas +plotly 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..144e90f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +motor +dnspython +pydantic diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 0df1048..feb3b86 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'; @@ -93,6 +94,20 @@ class _AppInitializerState extends State { try { final provider = context.read(); await provider.init(); + + // Fire-and-forget analytics in background + final api = ApiService(); + 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..78085de --- /dev/null +++ b/workout-logger/lib/screens/settings_screen.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.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; + + Future _performBackup() async { + setState(() => _isBackingUp = true); + + try { + final provider = context.read(); + final jsonString = await provider.exportAllData(); + final data = jsonDecode(jsonString) as Map; + + final api = ApiService(); + api.trackEvent('backup_triggered'); + 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) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: AppTheme.error), + ); + } finally { + if (mounted) { + setState(() => _isBackingUp = false); + } + } + } + + @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), + _buildBackupCard(), + ], + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ); + } + + 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', + 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..e0f2b29 --- /dev/null +++ b/workout-logger/lib/services/api_service.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:io' show Platform; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:uuid/uuid.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({http.Client? client}) { + if (client != null) _instance._client = client; + return _instance; + } + ApiService._internal(); + + http.Client _client = http.Client(); + + String? _cachedAppId; + + /// Returns a stable installation ID (UUID v4) persisted in Hive. + Future get userAppId async { + if (_cachedAppId != null) return _cachedAppId!; + final box = Hive.box('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'; + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + return 'unknown'; + } + + // ───────── 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), + ); + if (res.statusCode != 200) { + debugPrint('Heartbeat failed: ${res.body}'); + } + } 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), + ); + if (res.statusCode != 200) { + debugPrint('Event tracking failed: ${res.body}'); + } + } 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), + ); + + if (response.statusCode != 200) { + debugPrint('Failed to report usage: ${response.body}'); + } + } catch (e) { + debugPrint('Error reporting usage: $e'); + } + } + + Future backupData(Map data) async { + try { + final id = await userAppId; + + // Hive stores items as JSON strings – decode them to Maps for the API + final decoded = {'user_app_id': id}; + for (final key in data.keys) { + final value = data[key]; + if (value is List) { + decoded[key] = value.map((item) { + if (item is String) { + try { + return jsonDecode(item); + } catch (_) { + return item; + } + } + return item; + }).toList(); + } else { + decoded[key] = value; + } + } + + final response = await _client.post( + Uri.parse('$_baseUrl/backup'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(decoded), + ); + + if (response.statusCode == 200) { + return true; + } else { + debugPrint('Failed to backup data: ${response.body}'); + return false; + } + } catch (e) { + debugPrint('Error backing up data: $e'); + return false; + } + } +} diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index dbf1ede..192a828 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -33,6 +33,7 @@ class StorageService implements IStorageService { bool _initialized = false; /// Initialize Hive and open boxes + @override Future init() async { if (_initialized) return; @@ -64,10 +65,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 +80,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 +102,7 @@ class StorageService implements IStorageService { .toList(); } + @override Future> getSessionsInDateRange( DateTime start, DateTime end, @@ -111,10 +118,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 +132,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 +160,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 +180,7 @@ class StorageService implements IStorageService { // ==================== MUSCLE GROUPS ==================== + @override Future updateMuscleGroupGrowthRate( String muscleGroupId, double rate, @@ -180,6 +197,7 @@ class StorageService implements IStorageService { } } + @override Future> getAllMuscleGroups() async { final groups = []; for (var json in _muscleGroupsBoxInstance.values) { @@ -188,6 +206,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 +215,7 @@ class StorageService implements IStorageService { // ==================== CUSTOM EXERCISES ==================== + @override Future saveCustomExercise(Exercise exercise) async { await _customExercisesBoxInstance.put( exercise.id, @@ -203,6 +223,7 @@ class StorageService implements IStorageService { ); } + @override Future> getCustomExercises() async { final exercises = []; for (var json in _customExercisesBoxInstance.values) { @@ -211,11 +232,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 +246,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,16 +263,19 @@ 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 ==================== + @override Future exportAllData() async { final data = { 'sessions': _sessionsBox.values.toList(), @@ -261,6 +288,7 @@ class StorageService implements IStorageService { return jsonEncode(data); } + @override Future importData(String jsonData) async { final data = jsonDecode(jsonData) as Map; @@ -291,6 +319,7 @@ class StorageService implements IStorageService { // ==================== 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..385e438 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -721,4 +721,10 @@ class WorkoutProvider extends ChangeNotifier { Future> getQuickStats() async { return await _storage.getQuickStats(); } + + // ==================== BACKUP ==================== + + Future exportAllData() async { + return await _storage.exportAllData(); + } } diff --git a/workout-logger/pubspec.lock b/workout-logger/pubspec.lock index 08d743d..07e7c95 100644 --- a/workout-logger/pubspec.lock +++ b/workout-logger/pubspec.lock @@ -283,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -391,10 +399,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -628,10 +636,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 4b9bfc4..006440f 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: # Utilities uuid: ^4.5.1 intl: ^0.19.0 + http: ^1.2.1 dev_dependencies: flutter_test: