From 0e8f430378b7797adf4fdad35fee3a383ba15a61 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:41:08 +0000 Subject: [PATCH 01/14] Add backend for usage stats and backup, integrate with Flutter app. - Created backend/ directory with FastAPI server. - Added MongoDB integration and Pydantic models. - Configured Railway deployment with railway.toml. - Added http dependency to Flutter app. - Implemented ApiService for reporting usage and backing up data. - Added exportAllData method to WorkoutProvider. - Integrated usage reporting in app initialization. - Added Settings screen with Backup functionality. Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com> --- backend/__init__.py | 0 backend/__pycache__/database.cpython-312.pyc | Bin 0 -> 437 bytes backend/__pycache__/main.cpython-312.pyc | Bin 0 -> 2014 bytes backend/__pycache__/models.cpython-312.pyc | Bin 0 -> 1300 bytes backend/database.py | 10 ++ backend/main.py | 31 ++++ backend/models.py | 19 ++ backend/requirements.txt | 5 + railway.toml | 6 + workout-logger/lib/main.dart | 9 + workout-logger/lib/screens/home_screen.dart | 38 ++-- .../lib/screens/settings_screen.dart | 165 ++++++++++++++++++ workout-logger/lib/services/api_service.dart | 58 ++++++ .../lib/services/workout_provider.dart | 6 + workout-logger/pubspec.lock | 16 +- workout-logger/pubspec.yaml | 1 + 16 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/__pycache__/database.cpython-312.pyc create mode 100644 backend/__pycache__/main.cpython-312.pyc create mode 100644 backend/__pycache__/models.cpython-312.pyc create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 railway.toml create mode 100644 workout-logger/lib/screens/settings_screen.dart create mode 100644 workout-logger/lib/services/api_service.dart diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/__pycache__/database.cpython-312.pyc b/backend/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef0cbffb43de09b29fdd882fc4240817ca6649c2 GIT binary patch literal 437 zcmX@j%ge<81nZT0GG7Df#~=<2Fhd!iyMT=83@HpLj5!QZAet$MF_$TdiIE|dA&U*B z96@JsBG@oi3iE142s?^3m9@+Ja6%p=NDz?mzKomQ%cfGhR+~({1Q$|OwLZtOVNi(>J?Pp;;_lhPbtkwwJQ<;ssZ`D*a1j< zU}j`wyv@LQm%-=(pFn?BXI6h+XWoq1%Y51m+z*-gZt}`6;J(bO)8KZ4lm9xW%tcO_ W3$nVGIrSQy9&qwDIu-E&4FLeBLg{UG$2w$v?-zHku_F8Xu zNgEQBCKUv!65>KQaNxj!DsX}W*POX*lt`(biYTH7atjR-wDiEtZoCPI!au;uJ3H^q z$D23voAGy`^?@JFspy}>YYCApnGIfncE0(a7X^Cmql-MTq5F0(VNm?q{qz>7n z!RFY3|2D_cu}#M{5}%N{k|`KFs8o=IHg39J>B1`_^YX=u7hbwtwOXF-GV{yX*(0kP|bJX?2qMD z`8`#IxR}CJmiSbfB&W#rzqW-H=2Y^XI#js>3XiV3udvb)mU_ zwQ9NUTsAC1KFo1G%6Mp%h^&Ox04*rf&aZGxLE}0E$pOS#Az zi+dHPX^CW9aUr`NFUTCTC(@B_g+auES}qnVpU>a#oB{|~O;&7}cJt}h716P;=;QhP zQBcFWyFQSg$?uEgj+R`UyJhr!AqT^+Z5b1RKCvsQ>gdhxzU}V8jqbtC?vpF&TgH(O zYai6Mje!kg;Is3a#?ZDt6zD@Yj}HVx;~Sl41O4o-Op?#syOYk|B~%>^U4HYzxAg1r zE#pj}pZQ}~hV2J?&%wz{+0rOoeYTV%>q%PbQr1&C@<%CbuXj=8bIH<(yx!kk8kX0G zRpduxkOHFdhJH&Zc=i7Q{SZL&G^Ck#Ai<8{M}W+AM)Ls>TD9!AEyf=~Q5MKyO!6FT z2rWvp%X?6#7YPn9z~5siPDSDhbE>=#HrfE>Cs3MhOQA%30CtX|iVNi5F_$~GTKTB5 znHyY5{S|TNHjU!8UJUeNh`7>5=UAYRA>xMq6LGvBL!!kheBuX(*)qI)7+Ey@aLn0s zl7u5cp5XYuFxUU4!!J3$mw(lnf&R&32BE$3ulvoW-JH2W_#k?Lmqb@rxZ`;IB&@O5 zTy!7a`F2Mx=s6XnPY25BdoK3=lB&^(f+4hN81QDx=Gep0R7iwV!R0tSIrgj25$!md z!r38YvjrW{a$q9hF=38{JYz*ChcWRdQ4p3x9|eDY-FluEVGsWU+*u&dkd*#Fo`T=^ zr1!@}X4zhpZ&T&{-mASUg-x2>m2^7#N$oCy&z?-AT)45DC6f9#opb-F({r_FrEB@b OFA}9AXm5mE7}%e;GrA)H literal 0 HcmV?d00001 diff --git a/backend/__pycache__/models.cpython-312.pyc b/backend/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bdbf69958416c1b4f20905438f33daaa4e5a58ef GIT binary patch literal 1300 zcmZvbzi$*r6vt<8XMgzco$>kS34?@#EEX)IL@6jNOV~sp#h3z7u+6f*eVE1j<1#a6 z`x=Bq(ovi%FG>+dDI&!GK|>SkC{80G3Mx7zn}&)vyJs74M!N65`M%k?_vSPEx>6|< z9Af%ec&|jrdo?*6MnBlUkHHQJNT5X|)?7_faVaXrx~s?3rLo}}vFVz*uSZsFxt2ys z#|G*TaU8`n9Aj z?b}TerimZnN;~ich%kmmv3sHYzasmqINu>aTrD7O=`H~sXrRrbn@6QQGSJhHJgTA+ z0rOF{*z9Y4*`K&JnD~~V*0xQ@lI6SHUxPcs7hG0FD*VXXOxb$c!uil9zkpTzT?qXYvib#3m14|Aq)f zSB?EwDBlwS1I!1dI%d=ltYbnaG6gZDcp`H|@I)pjs(7Es z%V6VLE2oavRhAd=^jgj9zVM}u{J1)DX*0wgCcvd7e6|L71qS05Z$xkdGflh@jTRSa z{L3MBS%xhI8}|WOQ$$)#k3j>%4G276tT?CGX{?D2D^Ue9b)HNCk~$ZRnQA(!L}6y* zN~V=eD9Mqiqh)6fWe@}2MnSgKTIc6X&+cUQ_QGCux=W}3&#DY}E_8Ud)Y;me+pC=K z((}FAct>PQyCd1n?Z6Op4o5ck406gpcSVq?Pfdh6A?C;`gWx$+RZRoJ8rY+(865NVSY*TZochM+VjQ7 xvA||tS`r#IEDZK literal 0 HcmV?d00001 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..87294e4 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,10 @@ +import os +from motor.motor_asyncio import AsyncIOMotorClient + +MONGODB_URI = os.environ.get("MONGODB_URI") + +if MONGODB_URI: + client = AsyncIOMotorClient(MONGODB_URI) + db = client.get_database("workout_logger") +else: + db = None diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..aeb7a5d --- /dev/null +++ b/backend/main.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI, HTTPException +from .models import UsageStats, BackupData +from .database import db + +app = FastAPI() + +@app.post("/report") +async def report_usage(stats: UsageStats): + if db is None: + raise HTTPException(status_code=503, detail="Database not configured") + try: + report_data = stats.model_dump() + await db.reports.insert_one(report_data) + return {"status": "success", "message": "Usage stats reported"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/backup") +async def backup_data(data: BackupData): + if db is None: + raise HTTPException(status_code=503, detail="Database not configured") + try: + backup_doc = data.model_dump() + await db.backups.insert_one(backup_doc) + return {"status": "success", "message": "Backup received"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/") +async def root(): + return {"message": "Workout Logger Backend Running"} diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..606a3ed --- /dev/null +++ b/backend/models.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from typing import List, Dict, Any, Optional +from datetime import datetime + +class UsageStats(BaseModel): + total_workouts: int + weekly_workouts: int + weekly_volume: float + exercises_this_week: int + report_date: datetime = datetime.now() + +class BackupData(BaseModel): + sessions: List[Dict[str, Any]] + routines: List[Dict[str, Any]] + targets: List[Dict[str, Any]] + muscleGroups: List[Dict[str, Any]] + customExercises: List[Dict[str, Any]] + exportDate: str # Since it's ISO string from Dart + backup_received_at: datetime = datetime.now() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..144e90f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +motor +dnspython +pydantic diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..ea09bc6 --- /dev/null +++ b/railway.toml @@ -0,0 +1,6 @@ +[build] +builder = "nixpacks" +buildCommand = "pip install -r backend/requirements.txt" + +[deploy] +startCommand = "uvicorn backend.main:app --host 0.0.0.0 --port $PORT" diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 0df1048..ceb06b4 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,14 @@ class _AppInitializerState extends State { try { final provider = context.read(); await provider.init(); + + // Report usage stats in background + provider.getQuickStats().then((stats) { + ApiService().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/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..1a550f6 --- /dev/null +++ b/workout-logger/lib/screens/settings_screen.dart @@ -0,0 +1,165 @@ +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 success = await ApiService().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..71a0277 --- /dev/null +++ b/workout-logger/lib/services/api_service.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; + +class ApiService { + // TODO: Replace with your Railway URL + static const String _baseUrl = 'http://10.0.2.2:8000'; // For Android Emulator + // static const String _baseUrl = 'http://localhost:8000'; // For iOS Simulator + + final http.Client _client; + + ApiService({http.Client? client}) : _client = client ?? http.Client(); + + Future reportUsage(Map stats) async { + try { + // Map Dart camelCase stats to Python snake_case model + final payload = { + 'total_workouts': stats['totalWorkouts'], + 'weekly_workouts': stats['weeklyWorkouts'], + 'weekly_volume': stats['weeklyVolume'], + 'exercises_this_week': stats['exercisesThisWeek'], + 'report_date': DateTime.now().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 response = await _client.post( + Uri.parse('$_baseUrl/backup'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(data), + ); + + 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/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: From cfd4535aee3c97b22530ebcc029b599264ce9ca5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:29:35 +0000 Subject: [PATCH 02/14] Add usage stats and backup backend with Flutter integration. - Create backend/ directory with FastAPI and MongoDB logic. - Move requirements.txt to root for Railpack detection. - Configure railway.toml for Railpack deployment. - Implement ApiService in Flutter for reporting and backups. - Update WorkoutProvider to expose exportAllData. - Add SettingsScreen to UI for manual backups. - Fix Pydantic models to use default_factory. - Make API URL configurable via environment variable. Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com> --- .gitignore | 158 +++---------------- backend/models.py | 6 +- railway.toml | 4 - backend/requirements.txt => requirements.txt | 0 workout-logger/lib/services/api_service.dart | 9 +- 5 files changed, 29 insertions(+), 148 deletions(-) rename backend/requirements.txt => requirements.txt (100%) diff --git a/.gitignore b/.gitignore index 9a5aced..f49ded9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,139 +1,21 @@ -# Logs -logs -*.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 +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* .cache - -# 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-* +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesise diff --git a/backend/models.py b/backend/models.py index 606a3ed..490d696 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import List, Dict, Any, Optional from datetime import datetime @@ -7,7 +7,7 @@ class UsageStats(BaseModel): weekly_workouts: int weekly_volume: float exercises_this_week: int - report_date: datetime = datetime.now() + report_date: datetime = Field(default_factory=datetime.now) class BackupData(BaseModel): sessions: List[Dict[str, Any]] @@ -16,4 +16,4 @@ class BackupData(BaseModel): muscleGroups: List[Dict[str, Any]] customExercises: List[Dict[str, Any]] exportDate: str # Since it's ISO string from Dart - backup_received_at: datetime = datetime.now() + backup_received_at: datetime = Field(default_factory=datetime.now) diff --git a/railway.toml b/railway.toml index ea09bc6..b2450ff 100644 --- a/railway.toml +++ b/railway.toml @@ -1,6 +1,2 @@ -[build] -builder = "nixpacks" -buildCommand = "pip install -r backend/requirements.txt" - [deploy] startCommand = "uvicorn backend.main:app --host 0.0.0.0 --port $PORT" diff --git a/backend/requirements.txt b/requirements.txt similarity index 100% rename from backend/requirements.txt rename to requirements.txt diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index 71a0277..801d6e6 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -3,9 +3,12 @@ import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; class ApiService { - // TODO: Replace with your Railway URL - static const String _baseUrl = 'http://10.0.2.2:8000'; // For Android Emulator - // static const String _baseUrl = 'http://localhost:8000'; // For iOS Simulator + // TODO: Update this URL with your Railway deployment URL + // Example: https://repforge-backend-production.up.railway.app + static const String _baseUrl = String.fromEnvironment( + 'API_URL', + defaultValue: 'http://10.0.2.2:8000', // Default to Android Emulator for dev + ); final http.Client _client; From 70f19b4bd57f17b382a9fdea49fba5f1d9a0d7cc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:45:19 +0000 Subject: [PATCH 03/14] Add usage stats and backup backend with Flutter integration. - Create backend/ directory with FastAPI and MongoDB logic. - Move requirements.txt to root for Railpack detection. - Add main.py to root for Railpack detection. - Remove railway.toml to use default Railpack configuration. - Implement ApiService in Flutter for reporting and backups. - Update WorkoutProvider to expose exportAllData. - Add SettingsScreen to UI for manual backups. - Fix Pydantic models to use default_factory. - Make API URL configurable via environment variable. - Update .gitignore to include Python and Flutter artifacts. Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com> --- .gitignore | 64 ++++++++++++++++++- backend/__pycache__/database.cpython-312.pyc | Bin 437 -> 0 bytes backend/__pycache__/main.cpython-312.pyc | Bin 2014 -> 0 bytes backend/__pycache__/models.cpython-312.pyc | Bin 1300 -> 0 bytes main.py | 1 + railway.toml | 2 - 6 files changed, 62 insertions(+), 5 deletions(-) delete mode 100644 backend/__pycache__/database.cpython-312.pyc delete mode 100644 backend/__pycache__/main.cpython-312.pyc delete mode 100644 backend/__pycache__/models.cpython-312.pyc create mode 100644 main.py delete mode 100644 railway.toml diff --git a/.gitignore b/.gitignore index f49ded9..455a439 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,62 @@ -__pycache__/ +# Miscellaneous +*.class +*.log +*.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/ +android/gradlew.bat +android/local.properties +.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 @@ -14,8 +71,9 @@ pip-delete-this-directory.txt nosetests.xml coverage.xml *.cover -*.log -.git .mypy_cache .pytest_cache .hypothesise + +# Environment Variables +.env diff --git a/backend/__pycache__/database.cpython-312.pyc b/backend/__pycache__/database.cpython-312.pyc deleted file mode 100644 index ef0cbffb43de09b29fdd882fc4240817ca6649c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 437 zcmX@j%ge<81nZT0GG7Df#~=<2Fhd!iyMT=83@HpLj5!QZAet$MF_$TdiIE|dA&U*B z96@JsBG@oi3iE142s?^3m9@+Ja6%p=NDz?mzKomQ%cfGhR+~({1Q$|OwLZtOVNi(>J?Pp;;_lhPbtkwwJQ<;ssZ`D*a1j< zU}j`wyv@LQm%-=(pFn?BXI6h+XWoq1%Y51m+z*-gZt}`6;J(bO)8KZ4lm9xW%tcO_ W3$nVGIrSQy9&qwDIu-E&4FLeBLg{UG$2w$v?-zHku_F8Xu zNgEQBCKUv!65>KQaNxj!DsX}W*POX*lt`(biYTH7atjR-wDiEtZoCPI!au;uJ3H^q z$D23voAGy`^?@JFspy}>YYCApnGIfncE0(a7X^Cmql-MTq5F0(VNm?q{qz>7n z!RFY3|2D_cu}#M{5}%N{k|`KFs8o=IHg39J>B1`_^YX=u7hbwtwOXF-GV{yX*(0kP|bJX?2qMD z`8`#IxR}CJmiSbfB&W#rzqW-H=2Y^XI#js>3XiV3udvb)mU_ zwQ9NUTsAC1KFo1G%6Mp%h^&Ox04*rf&aZGxLE}0E$pOS#Az zi+dHPX^CW9aUr`NFUTCTC(@B_g+auES}qnVpU>a#oB{|~O;&7}cJt}h716P;=;QhP zQBcFWyFQSg$?uEgj+R`UyJhr!AqT^+Z5b1RKCvsQ>gdhxzU}V8jqbtC?vpF&TgH(O zYai6Mje!kg;Is3a#?ZDt6zD@Yj}HVx;~Sl41O4o-Op?#syOYk|B~%>^U4HYzxAg1r zE#pj}pZQ}~hV2J?&%wz{+0rOoeYTV%>q%PbQr1&C@<%CbuXj=8bIH<(yx!kk8kX0G zRpduxkOHFdhJH&Zc=i7Q{SZL&G^Ck#Ai<8{M}W+AM)Ls>TD9!AEyf=~Q5MKyO!6FT z2rWvp%X?6#7YPn9z~5siPDSDhbE>=#HrfE>Cs3MhOQA%30CtX|iVNi5F_$~GTKTB5 znHyY5{S|TNHjU!8UJUeNh`7>5=UAYRA>xMq6LGvBL!!kheBuX(*)qI)7+Ey@aLn0s zl7u5cp5XYuFxUU4!!J3$mw(lnf&R&32BE$3ulvoW-JH2W_#k?Lmqb@rxZ`;IB&@O5 zTy!7a`F2Mx=s6XnPY25BdoK3=lB&^(f+4hN81QDx=Gep0R7iwV!R0tSIrgj25$!md z!r38YvjrW{a$q9hF=38{JYz*ChcWRdQ4p3x9|eDY-FluEVGsWU+*u&dkd*#Fo`T=^ zr1!@}X4zhpZ&T&{-mASUg-x2>m2^7#N$oCy&z?-AT)45DC6f9#opb-F({r_FrEB@b OFA}9AXm5mE7}%e;GrA)H diff --git a/backend/__pycache__/models.cpython-312.pyc b/backend/__pycache__/models.cpython-312.pyc deleted file mode 100644 index bdbf69958416c1b4f20905438f33daaa4e5a58ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1300 zcmZvbzi$*r6vt<8XMgzco$>kS34?@#EEX)IL@6jNOV~sp#h3z7u+6f*eVE1j<1#a6 z`x=Bq(ovi%FG>+dDI&!GK|>SkC{80G3Mx7zn}&)vyJs74M!N65`M%k?_vSPEx>6|< z9Af%ec&|jrdo?*6MnBlUkHHQJNT5X|)?7_faVaXrx~s?3rLo}}vFVz*uSZsFxt2ys z#|G*TaU8`n9Aj z?b}TerimZnN;~ich%kmmv3sHYzasmqINu>aTrD7O=`H~sXrRrbn@6QQGSJhHJgTA+ z0rOF{*z9Y4*`K&JnD~~V*0xQ@lI6SHUxPcs7hG0FD*VXXOxb$c!uil9zkpTzT?qXYvib#3m14|Aq)f zSB?EwDBlwS1I!1dI%d=ltYbnaG6gZDcp`H|@I)pjs(7Es z%V6VLE2oavRhAd=^jgj9zVM}u{J1)DX*0wgCcvd7e6|L71qS05Z$xkdGflh@jTRSa z{L3MBS%xhI8}|WOQ$$)#k3j>%4G276tT?CGX{?D2D^Ue9b)HNCk~$ZRnQA(!L}6y* zN~V=eD9Mqiqh)6fWe@}2MnSgKTIc6X&+cUQ_QGCux=W}3&#DY}E_8Ud)Y;me+pC=K z((}FAct>PQyCd1n?Z6Op4o5ck406gpcSVq?Pfdh6A?C;`gWx$+RZRoJ8rY+(865NVSY*TZochM+VjQ7 xvA||tS`r#IEDZK 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/railway.toml b/railway.toml deleted file mode 100644 index b2450ff..0000000 --- a/railway.toml +++ /dev/null @@ -1,2 +0,0 @@ -[deploy] -startCommand = "uvicorn backend.main:app --host 0.0.0.0 --port $PORT" From 2f7cb5016f7b944006534c122eb1c32269888713 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:03:18 +0000 Subject: [PATCH 04/14] Add usage stats and backup backend with Flutter integration. - Create backend/ directory with FastAPI and MongoDB logic. - Move requirements.txt to root for Railpack detection. - Add main.py to root for Railpack detection. - Remove railway.toml to use default Railpack configuration. - Implement ApiService in Flutter for reporting and backups. - Update WorkoutProvider to expose exportAllData. - Add SettingsScreen to UI for manual backups. - Fix Pydantic models to use default_factory. - Make API URL configurable via environment variable. - Update .gitignore to include Python and Flutter artifacts. - Add CORS middleware to backend for cross-origin requests. Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com> --- backend/main.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/main.py b/backend/main.py index aeb7a5d..b9ad639 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,18 @@ from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware from .models import UsageStats, BackupData from .database import db app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + @app.post("/report") async def report_usage(stats: UsageStats): if db is None: From 8d90de63bcfd234d73010c508bb4bb31177414c2 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:03:33 +0530 Subject: [PATCH 05/14] Add analytics dashboard and enhance backend for usage tracking and event logging --- .gitignore | 3 + backend/main.py | 210 ++++++++++++- backend/models.py | 36 ++- dashboard/app.py | 288 ++++++++++++++++++ dashboard/requirements.txt | 5 + workout-logger/lib/main.dart | 18 +- .../screens/edit_workout_session_screen.dart | 1 - .../lib/screens/settings_screen.dart | 9 +- workout-logger/lib/services/api_service.dart | 97 +++++- .../lib/services/storage_service.dart | 29 ++ 10 files changed, 661 insertions(+), 35 deletions(-) create mode 100644 dashboard/app.py create mode 100644 dashboard/requirements.txt diff --git a/.gitignore b/.gitignore index 455a439..3caa03e 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ coverage.xml # Environment Variables .env + +# Streamlit secrets +dashboard/.streamlit/secrets.toml diff --git a/backend/main.py b/backend/main.py index b9ad639..fa266cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,13 @@ -from fastapi import FastAPI, HTTPException +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from .models import UsageStats, BackupData + +from .models import UsageStats, BackupData, AppEvent, HeartbeatPayload from .database import db -app = FastAPI() +app = FastAPI(title="RepForge Analytics API", version="1.1.0") app.add_middleware( CORSMiddleware, @@ -13,28 +17,210 @@ allow_headers=["*"], ) -@app.post("/report") -async def report_usage(stats: UsageStats): + +# ────────────────────── 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: - report_data = stats.model_dump() - await db.reports.insert_one(report_data) + 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": doc}, + upsert=True, + ) + # also keep a time-series log for trend analysis + await db.report_log.insert_one(doc) return {"status": "success", "message": "Usage stats reported"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @app.post("/backup") async def backup_data(data: BackupData): - if db is None: - raise HTTPException(status_code=503, detail="Database not configured") + """Receive a full data backup from a device.""" + _ensure_db() try: - backup_doc = data.model_dump() - await db.backups.insert_one(backup_doc) + doc = data.model_dump() + # 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 Exception as e: raise HTTPException(status_code=500, detail=str(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 Exception as e: + raise HTTPException(status_code=500, detail=str(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 Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ────────────────────── analytics / read endpoints ────────────────────── + +@app.get("/analytics/overview") +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 = await db.heartbeats.count_documents( + {"timestamp": {"$gte": day_ago}}, + ) + # distinct user_app_ids in last 7 / 30 days + 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": dau, + "wau": len(wau_ids), + "mau": len(mau_ids), + "total_workouts_all_users": total_workouts, + } + + +@app.get("/analytics/retention") +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") +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") +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 individual user records 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}") +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": "Workout Logger Backend Running"} + return {"message": "RepForge Analytics Backend Running", "version": "1.1.0"} diff --git a/backend/models.py b/backend/models.py index 490d696..942eb93 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,19 +1,47 @@ from pydantic import BaseModel, Field from typing import List, Dict, Any, Optional -from datetime import datetime +from datetime import datetime, timezone + + +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 - report_date: datetime = Field(default_factory=datetime.now) + 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[Dict[str, Any]] routines: List[Dict[str, Any]] targets: List[Dict[str, Any]] muscleGroups: List[Dict[str, Any]] customExercises: List[Dict[str, Any]] - exportDate: str # Since it's ISO string from Dart - backup_received_at: datetime = Field(default_factory=datetime.now) + exportDate: str # ISO string from Dart + backup_received_at: datetime = Field(default_factory=_utcnow) + + +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/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index ceb06b4..feb3b86 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -95,12 +95,18 @@ class _AppInitializerState extends State { final provider = context.read(); await provider.init(); - // Report usage stats in background - provider.getQuickStats().then((stats) { - ApiService().reportUsage(stats); - }).catchError((e) { - debugPrint('Failed to report usage: $e'); - }); + // 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) { 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/settings_screen.dart b/workout-logger/lib/screens/settings_screen.dart index 1a550f6..78085de 100644 --- a/workout-logger/lib/screens/settings_screen.dart +++ b/workout-logger/lib/screens/settings_screen.dart @@ -23,7 +23,9 @@ class _SettingsScreenState extends State { final jsonString = await provider.exportAllData(); final data = jsonDecode(jsonString) as Map; - final success = await ApiService().backupData(data); + final api = ApiService(); + api.trackEvent('backup_triggered'); + final success = await api.backupData(data); if (!mounted) return; @@ -45,10 +47,7 @@ class _SettingsScreenState extends State { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: AppTheme.error, - ), + SnackBar(content: Text('Error: $e'), backgroundColor: AppTheme.error), ); } finally { if (mounted) { diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index 801d6e6..77e2c7e 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -1,28 +1,108 @@ 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 { - // TODO: Update this URL with your Railway deployment URL - // Example: https://repforge-backend-production.up.railway.app static const String _baseUrl = String.fromEnvironment( 'API_URL', - defaultValue: 'http://10.0.2.2:8000', // Default to Android Emulator for dev + defaultValue: 'https://workout-logger-production-1e93.up.railway.app', ); - final http.Client _client; + 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'; + } - ApiService({http.Client? client}) : _client = client ?? http.Client(); + // ───────── 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 { - // Map Dart camelCase stats to Python snake_case model + 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'], - 'report_date': DateTime.now().toIso8601String(), + 'platform': _platform, + 'report_date': DateTime.now().toUtc().toIso8601String(), }; final response = await _client.post( @@ -41,6 +121,9 @@ class ApiService { Future backupData(Map data) async { try { + final id = await userAppId; + data['user_app_id'] = id; + final response = await _client.post( Uri.parse('$_baseUrl/backup'), headers: {'Content-Type': 'application/json'}, 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(); From a67bb7a034a7e6b829c3739686fb73e8a3b19e9f Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:33:32 +0530 Subject: [PATCH 06/14] fix: decode Hive string items in backup payload & accept List[Any] in backend - Flutter: decode JSON-string list items to Maps before sending to /backup - Backend: BackupData model accepts List[Any] with parsed_backup() helper - Fixes 'Input should be a valid dictionary' validation error --- backend/main.py | 2 +- backend/models.py | 30 ++++++++++++++++---- workout-logger/lib/services/api_service.dart | 23 +++++++++++++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/backend/main.py b/backend/main.py index fa266cd..e1eeb68 100644 --- a/backend/main.py +++ b/backend/main.py @@ -51,7 +51,7 @@ async def backup_data(data: BackupData): """Receive a full data backup from a device.""" _ensure_db() try: - doc = data.model_dump() + 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}, diff --git a/backend/models.py b/backend/models.py index 942eb93..dda2e3c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -20,14 +20,34 @@ class UsageStats(BaseModel): class BackupData(BaseModel): user_app_id: str - sessions: List[Dict[str, Any]] - routines: List[Dict[str, Any]] - targets: List[Dict[str, Any]] - muscleGroups: List[Dict[str, Any]] - customExercises: List[Dict[str, Any]] + 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.""" diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index 77e2c7e..e0f2b29 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -122,12 +122,31 @@ class ApiService { Future backupData(Map data) async { try { final id = await userAppId; - data['user_app_id'] = id; + + // 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(data), + body: jsonEncode(decoded), ); if (response.statusCode == 200) { From eb8df391c10a4590d065e0d728e2734aa9dc9ff2 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:00:19 +0530 Subject: [PATCH 07/14] fix: address PR review comments across all files - .gitignore: remove duplicate entries, fix .hypothesise typo - backend/database.py: always define client and db (avoid NameError) - backend/main.py: add API key auth on analytics routes, fix CORS allow_credentials, exception chaining with logging, DAU as distinct users, prevent doc mutation via _id leak - backend/models.py: use built-in generics, add list size limits and JSON depth guard in parsed_backup - requirements.txt: pin all backend dependencies - dashboard/requirements.txt: pin all dashboard dependencies - dashboard/app.py: wrap get_db with error handling, fix DAU to distinct users, lazy-load backup detail to prevent OOM - api_service.dart: conditional import for web compat, @visibleForTesting setTestClient, defensive Hive box open - main.dart: register ApiService in MultiProvider, use DI lookup - settings_screen.dart: use DI for ApiService, await trackEvent, sanitize error messages shown to users --- .gitignore | 4 +- backend/database.py | 5 +- backend/main.py | 72 ++++++++++++----- backend/models.py | 75 +++++++++++------ dashboard/app.py | 80 ++++++++++++++++--- dashboard/requirements.txt | 10 +-- requirements.txt | 10 +-- workout-logger/lib/main.dart | 4 +- .../lib/screens/settings_screen.dart | 12 ++- workout-logger/lib/services/api_service.dart | 29 ++++--- workout-logger/lib/services/platform_io.dart | 11 +++ .../lib/services/platform_stub.dart | 2 + workout-logger/pubspec.lock | 8 +- 13 files changed, 235 insertions(+), 87 deletions(-) create mode 100644 workout-logger/lib/services/platform_io.dart create mode 100644 workout-logger/lib/services/platform_stub.dart diff --git a/.gitignore b/.gitignore index 3caa03e..529c1be 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,6 @@ android/gradlew.bat android/local.properties android/.gradle android/captures/ -android/gradlew.bat -android/local.properties .gradle /android/.gradle/ @@ -73,7 +71,7 @@ coverage.xml *.cover .mypy_cache .pytest_cache -.hypothesise +.hypothesis/ # Environment Variables .env diff --git a/backend/database.py b/backend/database.py index 87294e4..b1ff8e8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -3,8 +3,9 @@ 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") -else: - db = None diff --git a/backend/main.py b/backend/main.py index e1eeb68..a1a4539 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,23 +1,43 @@ +import logging +import os from datetime import datetime, timedelta, timezone from typing import Optional -from fastapi import FastAPI, HTTPException, Query +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 -app = FastAPI(title="RepForge Analytics API", version="1.1.0") +logger = logging.getLogger(__name__) + +app = FastAPI(title="RepForge Analytics API", version="1.2.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_credentials=True, + 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(): @@ -36,14 +56,18 @@ async def report_usage(stats: UsageStats): # upsert: latest report per user_app_id replaces older one await db.reports.update_one( {"user_app_id": stats.user_app_id}, - {"$set": doc}, + {"$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: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("report_usage failed") + raise HTTPException(status_code=500, detail="Internal server error") from e @app.post("/backup") @@ -59,8 +83,11 @@ async def backup_data(data: BackupData): upsert=True, ) return {"status": "success", "message": "Backup received"} + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("backup_data failed") + raise HTTPException(status_code=500, detail="Internal server error") from e @app.post("/event") @@ -70,8 +97,11 @@ async def track_event(event: AppEvent): try: await db.events.insert_one(event.model_dump()) return {"status": "success"} + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logger.exception("track_event failed") + raise HTTPException(status_code=500, detail="Internal server error") from e @app.post("/heartbeat") @@ -97,13 +127,17 @@ async def heartbeat(payload: HeartbeatPayload): # 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: - raise HTTPException(status_code=500, detail=str(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") +@app.get("/analytics/overview", dependencies=[Depends(require_admin)]) async def analytics_overview(): """High-level numbers: total users, DAU, WAU, MAU, total workouts.""" _ensure_db() @@ -113,10 +147,10 @@ async def analytics_overview(): month_ago = now - timedelta(days=30) total_users = await db.users.count_documents({}) - dau = await db.heartbeats.count_documents( - {"timestamp": {"$gte": day_ago}}, + # DAU = distinct users with heartbeat in last 24 h + dau_ids = await db.heartbeats.distinct( + "user_app_id", {"timestamp": {"$gte": day_ago}} ) - # distinct user_app_ids in last 7 / 30 days wau_ids = await db.heartbeats.distinct( "user_app_id", {"timestamp": {"$gte": week_ago}} ) @@ -132,14 +166,14 @@ async def analytics_overview(): return { "total_users": total_users, - "dau": dau, + "dau": len(dau_ids), "wau": len(wau_ids), "mau": len(mau_ids), "total_workouts_all_users": total_workouts, } -@app.get("/analytics/retention") +@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() @@ -169,7 +203,7 @@ async def analytics_retention(days: int = Query(30, ge=1, le=365)): return {"days": days, "retention": results} -@app.get("/analytics/events") +@app.get("/analytics/events", dependencies=[Depends(require_admin)]) async def analytics_events( event: Optional[str] = None, days: int = Query(7, ge=1, le=365), @@ -192,12 +226,12 @@ async def analytics_events( return {"days": days, "events": [{"event": r["_id"], "count": r["count"]} for r in results]} -@app.get("/analytics/users") +@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 individual user records for drill-down.""" + """List user records (PII-redacted) for drill-down.""" _ensure_db() cursor = db.users.find( {}, {"_id": 0} @@ -206,7 +240,7 @@ async def analytics_users( return {"count": len(users), "users": users} -@app.get("/analytics/user/{user_app_id}") +@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() @@ -223,4 +257,4 @@ async def analytics_user_detail(user_app_id: str): @app.get("/") async def root(): - return {"message": "RepForge Analytics Backend Running", "version": "1.1.0"} + return {"message": "RepForge Analytics Backend Running", "version": "1.2.0"} diff --git a/backend/models.py b/backend/models.py index dda2e3c..ac34ed5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,7 +1,12 @@ -from pydantic import BaseModel, Field -from typing import List, Dict, Any, Optional +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) @@ -20,40 +25,62 @@ class UsageStats(BaseModel): 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] + 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) - 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: + @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) - return out + 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] = _parse_list(data.get(key, [])) + 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 + metadata: Optional[dict[str, Any]] = None app_version: Optional[str] = None platform: Optional[str] = None timestamp: datetime = Field(default_factory=_utcnow) @@ -65,3 +92,5 @@ class HeartbeatPayload(BaseModel): 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 index 3208b0a..8649cce 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -10,6 +10,7 @@ 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 ─── @@ -22,12 +23,24 @@ # ─── MongoDB connection (cached) ─── @st.cache_resource def get_db(): - uri = st.secrets["mongo"]["uri"] - client = MongoClient(uri) - return client.get_database("workout_logger") + 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") @@ -51,7 +64,7 @@ def get_db(): month_ago = now - timedelta(days=30) total_users = db.users.count_documents({}) - total_heartbeats_today = db.heartbeats.count_documents({"timestamp": {"$gte": day_ago}}) + 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}})) @@ -65,7 +78,7 @@ def get_db(): # KPI cards c1, c2, c3, c4 = st.columns(4) c1.metric("Total Installs", total_users) - c2.metric("DAU (heartbeats today)", total_heartbeats_today) + c2.metric("DAU", dau) c3.metric("WAU", wau) c4.metric("MAU", mau) @@ -277,12 +290,55 @@ def get_db(): 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) + # 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 index 8626a1e..1543717 100644 --- a/dashboard/requirements.txt +++ b/dashboard/requirements.txt @@ -1,5 +1,5 @@ -streamlit -pymongo[srv] -dnspython -pandas -plotly +streamlit~=1.41.0 +pymongo[srv]~=4.10.0 +dnspython~=2.7.0 +pandas~=2.2.0 +plotly~=5.24.0 diff --git a/requirements.txt b/requirements.txt index 144e90f..87bd591 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -fastapi -uvicorn -motor -dnspython -pydantic +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 feb3b86..3c62870 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -57,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: (_) => @@ -96,7 +98,7 @@ class _AppInitializerState extends State { await provider.init(); // Fire-and-forget analytics in background - final api = ApiService(); + final api = context.read(); api.sendHeartbeat(); api.trackEvent('app_open'); provider diff --git a/workout-logger/lib/screens/settings_screen.dart b/workout-logger/lib/screens/settings_screen.dart index 78085de..0949729 100644 --- a/workout-logger/lib/screens/settings_screen.dart +++ b/workout-logger/lib/screens/settings_screen.dart @@ -23,8 +23,8 @@ class _SettingsScreenState extends State { final jsonString = await provider.exportAllData(); final data = jsonDecode(jsonString) as Map; - final api = ApiService(); - api.trackEvent('backup_triggered'); + final api = context.read(); + await api.trackEvent('backup_triggered').catchError((_) => null); final success = await api.backupData(data); if (!mounted) return; @@ -44,10 +44,14 @@ class _SettingsScreenState extends State { ), ); } - } catch (e) { + } catch (e, stackTrace) { + debugPrint('Backup error: $e\n$stackTrace'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e'), backgroundColor: AppTheme.error), + const SnackBar( + content: Text('Something went wrong. Please try again.'), + backgroundColor: AppTheme.error, + ), ); } finally { if (mounted) { diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index e0f2b29..395b8ff 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -1,10 +1,13 @@ 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'; +// 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( @@ -13,20 +16,30 @@ class ApiService { ); static final ApiService _instance = ApiService._internal(); - factory ApiService({http.Client? client}) { - if (client != null) _instance._client = client; - return _instance; - } + + 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!; - final box = Hive.box('settings'); + // 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(); @@ -38,9 +51,7 @@ class ApiService { String get _platform { if (kIsWeb) return 'web'; - if (Platform.isAndroid) return 'android'; - if (Platform.isIOS) return 'ios'; - return 'unknown'; + return getPlatformName(); } // ───────── ingest helpers ───────── 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/pubspec.lock b/workout-logger/pubspec.lock index 07e7c95..7b890b9 100644 --- a/workout-logger/pubspec.lock +++ b/workout-logger/pubspec.lock @@ -399,10 +399,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -636,10 +636,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" typed_data: dependency: transitive description: From 112b636d9a5e9abac1dcd0eda888f0e5845f0ae2 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:03:55 +0530 Subject: [PATCH 08/14] style: format import statement for consistency --- workout-logger/lib/services/api_service.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index 395b8ff..c865558 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -5,8 +5,7 @@ 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'; +import 'platform_stub.dart' if (dart.library.io) 'platform_io.dart'; /// Singleton service for communicating with the RepForge analytics backend. class ApiService { From f9fc19fff0e63da4fe17b59138889b06bf0bea12 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 12 Feb 2026 00:19:12 +0530 Subject: [PATCH 09/14] feat: implement local backup and import/export functionality in settings --- .../lib/screens/settings_screen.dart | 279 +++++++++++++++++- .../lib/services/storage_service.dart | 90 +++++- .../lib/services/workout_provider.dart | 6 + workout-logger/pubspec.yaml | 5 + 4 files changed, 367 insertions(+), 13 deletions(-) diff --git a/workout-logger/lib/screens/settings_screen.dart b/workout-logger/lib/screens/settings_screen.dart index 0949729..f95fe04 100644 --- a/workout-logger/lib/screens/settings_screen.dart +++ b/workout-logger/lib/screens/settings_screen.dart @@ -1,6 +1,11 @@ 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'; @@ -14,6 +19,10 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { bool _isBackingUp = false; + bool _isExporting = false; + bool _isImporting = false; + + // ==================== Remote Backup ==================== Future _performBackup() async { setState(() => _isBackingUp = true); @@ -60,6 +69,161 @@ class _SettingsScreenState extends State { } } + // ==================== 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( @@ -73,6 +237,8 @@ class _SettingsScreenState extends State { children: [ _buildSectionHeader('Data Management'), const SizedBox(height: 16), + _buildLocalBackupCard(), + const SizedBox(height: 16), _buildBackupCard(), ], ), @@ -89,6 +255,117 @@ class _SettingsScreenState extends State { ); } + 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), @@ -126,7 +403,7 @@ class _SettingsScreenState extends State { ), ), Text( - 'Securely backup your workout data', + 'Securely backup your workout data to the cloud', style: TextStyle( fontSize: 12, color: AppTheme.textSecondary, diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 192a828..5c96336 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -277,42 +277,108 @@ class StorageService implements IStorageService { @override Future exportAllData() async { + // Collect settings as a map + final settingsMap = {}; + for (var 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(), + 'settings': settingsMap, 'exportDate': DateTime.now().toIso8601String(), + 'appVersion': '1.0.6', }; return jsonEncode(data); } + /// Decode a backup item that may be a JSON string or an already-decoded Map. + Map _decodeItem(dynamic item) { + if (item is String) { + return jsonDecode(item) as Map; + } + return item as Map; + } + @override Future importData(String jsonData) async { final data = jsonDecode(jsonData) as Map; - // Import sessions + // Import sessions (merge: skip if id already exists) if (data['sessions'] != null) { - for (var json in data['sessions']) { - final session = WorkoutSession.fromJson(jsonDecode(json)); - await saveWorkoutSession(session); + for (var item in data['sessions']) { + final map = _decodeItem(item); + final session = WorkoutSession.fromJson(map); + final existing = await getWorkoutSession(session.id); + if (existing == null) { + await saveWorkoutSession(session); + } } } - // Import routines + // Import routines (merge: skip if id already exists) if (data['routines'] != null) { - for (var json in data['routines']) { - final routine = Routine.fromJson(jsonDecode(json)); - await saveRoutine(routine); + for (var item in data['routines']) { + final map = _decodeItem(item); + final routine = Routine.fromJson(map); + final existing = await getRoutine(routine.id); + if (existing == null) { + await saveRoutine(routine); + } } } - // Import targets + // Import targets (merge: skip if id already exists) if (data['targets'] != null) { - for (var json in data['targets']) { - final target = Target.fromJson(jsonDecode(json)); - await saveTarget(target); + for (var item in data['targets']) { + final map = _decodeItem(item); + 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) + if (data['muscleGroups'] != null) { + for (var item in data['muscleGroups']) { + final map = _decodeItem(item); + final mg = MuscleGroup.fromJson(map); + final existing = await getMuscleGroup(mg.id); + if (existing == null) { + await _muscleGroupsBoxInstance.put(mg.id, jsonEncode(mg.toJson())); + } + } + } + + // Import custom exercises (merge: skip if id already exists) + if (data['customExercises'] != null) { + for (var item in data['customExercises']) { + final map = _decodeItem(item); + final exercise = Exercise.fromJson(map); + final existing = _customExercisesBoxInstance.get(exercise.id); + if (existing == null) { + await saveCustomExercise(exercise); + } + } + } + + // 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()); + } } } } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 385e438..2f970ff 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -727,4 +727,10 @@ class WorkoutProvider extends ChangeNotifier { 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.yaml b/workout-logger/pubspec.yaml index 006440f..812e9d4 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -50,6 +50,11 @@ dependencies: intl: ^0.19.0 http: ^1.2.1 + # Backup export/import + file_picker: ^8.0.0 + path_provider: ^2.1.0 + share_plus: ^9.0.0 + dev_dependencies: flutter_test: sdk: flutter From d5486837cb600d67400d06eb527b51daedaae9e1 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:44:33 +0530 Subject: [PATCH 10/14] Update workout-logger/lib/services/storage_service.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- workout-logger/lib/services/storage_service.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 5c96336..bdc9bd3 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -299,12 +299,14 @@ class StorageService implements IStorageService { return jsonEncode(data); } - /// Decode a backup item that may be a JSON string or an already-decoded Map. Map _decodeItem(dynamic item) { if (item is String) { return jsonDecode(item) as Map; } - return item as Map; + if (item is Map) { + return Map.from(item); + } + throw FormatException('Unexpected backup item type: ${item.runtimeType}'); } @override From 25a83c11d42c022201cccc4156330e3b0790217c Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:48:41 +0530 Subject: [PATCH 11/14] feat: add timeout handling and data normalization for API service and storage service --- workout-logger/lib/services/api_service.dart | 78 +++++++------- .../lib/services/storage_service.dart | 102 ++++++++++++------ 2 files changed, 111 insertions(+), 69 deletions(-) diff --git a/workout-logger/lib/services/api_service.dart b/workout-logger/lib/services/api_service.dart index c865558..e66213a 100644 --- a/workout-logger/lib/services/api_service.dart +++ b/workout-logger/lib/services/api_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:async'; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; @@ -63,14 +64,18 @@ class ApiService { 'platform': _platform, 'timestamp': DateTime.now().toUtc().toIso8601String(), }; - final res = await _client.post( - Uri.parse('$_baseUrl/heartbeat'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(body), - ); + 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'); } @@ -89,14 +94,18 @@ class ApiService { '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), - ); + 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'); } @@ -115,15 +124,19 @@ class ApiService { 'report_date': DateTime.now().toUtc().toIso8601String(), }; - final response = await _client.post( - Uri.parse('$_baseUrl/report'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(payload), - ); + 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'); } @@ -133,31 +146,15 @@ class ApiService { 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 payload = {'user_app_id': id, ...data}; - final response = await _client.post( - Uri.parse('$_baseUrl/backup'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(decoded), - ); + 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; @@ -165,6 +162,9 @@ class ApiService { 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/storage_service.dart b/workout-logger/lib/services/storage_service.dart index bdc9bd3..d3f59a3 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -275,6 +275,38 @@ class StorageService implements IStorageService { // ==================== 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 @@ -287,11 +319,21 @@ class StorageService implements IStorageService { } 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': '1.0.6', @@ -299,24 +341,16 @@ class StorageService implements IStorageService { return jsonEncode(data); } - Map _decodeItem(dynamic item) { - if (item is String) { - return jsonDecode(item) as Map; - } - if (item is Map) { - return Map.from(item); - } - throw FormatException('Unexpected backup item type: ${item.runtimeType}'); - } - @override Future importData(String jsonData) async { final data = jsonDecode(jsonData) as Map; // Import sessions (merge: skip if id already exists) - if (data['sessions'] != null) { - for (var item in data['sessions']) { - final map = _decodeItem(item); + 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) { @@ -326,9 +360,11 @@ class StorageService implements IStorageService { } // Import routines (merge: skip if id already exists) - if (data['routines'] != null) { - for (var item in data['routines']) { - final map = _decodeItem(item); + 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) { @@ -338,9 +374,11 @@ class StorageService implements IStorageService { } // Import targets (merge: skip if id already exists) - if (data['targets'] != null) { - for (var item in data['targets']) { - final map = _decodeItem(item); + 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) { @@ -350,9 +388,11 @@ class StorageService implements IStorageService { } // Import muscle groups (merge: skip if id already exists) - if (data['muscleGroups'] != null) { - for (var item in data['muscleGroups']) { - final map = _decodeItem(item); + 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) { @@ -362,9 +402,11 @@ class StorageService implements IStorageService { } // Import custom exercises (merge: skip if id already exists) - if (data['customExercises'] != null) { - for (var item in data['customExercises']) { - final map = _decodeItem(item); + 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) { From 0adbdeaad49e4f1a6a4a8d8b760cac45cb363c01 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:54:37 +0530 Subject: [PATCH 12/14] feat: update dependencies for file_picker, path_provider, and share_plus --- workout-logger/pubspec.lock | 733 ------------------------------------ workout-logger/pubspec.yaml | 6 +- 2 files changed, 3 insertions(+), 736 deletions(-) delete mode 100644 workout-logger/pubspec.lock diff --git a/workout-logger/pubspec.lock b/workout-logger/pubspec.lock deleted file mode 100644 index 7b890b9..0000000 --- a/workout-logger/pubspec.lock +++ /dev/null @@ -1,733 +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: - dependency: "direct main" - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.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 812e9d4..5e386c8 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -51,9 +51,9 @@ dependencies: http: ^1.2.1 # Backup export/import - file_picker: ^8.0.0 - path_provider: ^2.1.0 - share_plus: ^9.0.0 + file_picker: ^10.3.10 + path_provider: ^2.1.5 + share_plus: ^12.0.1 dev_dependencies: flutter_test: From 5528dad462f3a6dfeadcd9ff3211521d91ef8b01 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:06:55 +0530 Subject: [PATCH 13/14] feat: add app version tracking using package_info_plus --- .../lib/services/storage_service.dart | 18 +++++++++++++++++- workout-logger/pubspec.yaml | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index d3f59a3..53adca6 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,6 +31,11 @@ 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 @@ -37,6 +43,16 @@ class StorageService implements IStorageService { 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); @@ -336,7 +352,7 @@ class StorageService implements IStorageService { .toList(growable: false), 'settings': settingsMap, 'exportDate': DateTime.now().toIso8601String(), - 'appVersion': '1.0.6', + 'appVersion': _appVersion, }; return jsonEncode(data); } diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 5e386c8..f5d83f6 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: 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 From 5bbbdc61d406856bb26be98ce46071439235defb Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:13:32 +0530 Subject: [PATCH 14/14] Update workout-logger/lib/services/storage_service.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- workout-logger/lib/services/storage_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 53adca6..18ffa74 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -327,7 +327,7 @@ class StorageService implements IStorageService { Future exportAllData() async { // Collect settings as a map final settingsMap = {}; - for (var key in _settingsBoxInstance.keys) { + for (final key in _settingsBoxInstance.keys) { final value = _settingsBoxInstance.get(key); if (value != null) { settingsMap[key as String] = value;