diff --git a/lib/cloud_io/firestore_sync.dart b/lib/cloud_io/firestore_sync.dart index 88388f6..edd9ef3 100644 --- a/lib/cloud_io/firestore_sync.dart +++ b/lib/cloud_io/firestore_sync.dart @@ -6,25 +6,23 @@ import 'package:open_fitness_tracker/DOM/training_metadata.dart'; import 'package:open_fitness_tracker/DOM/basic_user_info.dart'; import 'package:shared_preferences/shared_preferences.dart'; -//todo -//turn on auth persistance! firesbase auth?a - MyStorage myStorage = MyStorage(); class MyStorage { MyStorage() { _firestore.settings = const Settings( - persistenceEnabled: true, cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED); + persistenceEnabled: true, + cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED, + ); if (kIsWeb) { - //haven't tested w/o this. doc is not up to date. idc. - _firestore - // ignore: deprecated_member_use - .enablePersistence(const PersistenceSettings(synchronizeTabs: true)); + // ignore: deprecated_member_use + _firestore.enablePersistence( + const PersistenceSettings(synchronizeTabs: true), + ); } _userDoc = FirebaseFirestore.instance .collection('users') .doc(FirebaseAuth.instance.currentUser!.uid); - // getBasicUserInfo(); } final FirebaseFirestore _firestore = FirebaseFirestore.instance; @@ -33,8 +31,50 @@ class MyStorage { static const _historyKey = 'TrainingHistory'; static const _basicUserInfoKey = 'BasicUserInfo'; + // Helper method for retries with exponential backoff + Future _retryWithExponentialBackoff( + Future Function() operation, { + int maxRetries = 5, + int delayMilliseconds = 500, + }) async { + int retryCount = 0; + int delay = delayMilliseconds; + + while (true) { + try { + return await operation(); + } catch (e) { + if (retryCount >= maxRetries) { + try { + // Firestore operation + } on FirebaseException catch (e) { + if (e.code == 'permission-denied') { + // Handle permission error + rethrow; + } else if (e.code == 'unavailable') { + var user = FirebaseAuth.instance.currentUser; + int qq; + // Handle network unavailable error + } else { + // Handle other Firebase exceptions + rethrow; + } + } catch (e) { + // Handle other exceptions + rethrow; + } + } + await Future.delayed(Duration(milliseconds: delay)); + delay *= 2; // Exponential backoff + retryCount++; + } + } + } + Future addTrainingSessionToHistory(TrainingSession session) async { - _userDoc.collection(_historyKey).add(session.toJson()); + await _retryWithExponentialBackoff(() async { + await _userDoc.collection(_historyKey).add(session.toJson()); + }); } Stream> getUserTrainingHistoryStream({ @@ -62,55 +102,60 @@ class MyStorage { Future refreshTrainingHistoryCacheIfItsBeenXHours(int hours) async { if (await _historyCacheClock.timeSinceCacheWasUpdated() > Duration(hours: hours)) { - getEntireUserTrainingHistory(useCache: false); + await getEntireUserTrainingHistory(useCache: false); } } - Future> getEntireUserTrainingHistory( - {required bool useCache}) async { - QuerySnapshot cloudTrainingHistory; - if (useCache) { - cloudTrainingHistory = await _userDoc - .collection(_historyKey) - .get(const GetOptions(source: Source.cache)); - } else { - cloudTrainingHistory = await _userDoc - .collection(_historyKey) - .get(const GetOptions(source: Source.server)); - - _historyCacheClock.resetClock(); - } + Future> getEntireUserTrainingHistory({ + required bool useCache, + }) async { + return await _retryWithExponentialBackoff(() async { + QuerySnapshot cloudTrainingHistory; + if (useCache) { + cloudTrainingHistory = await _userDoc + .collection(_historyKey) + .get(const GetOptions(source: Source.cache)); + } else { + cloudTrainingHistory = await _userDoc + .collection(_historyKey) + .get(const GetOptions(source: Source.server)); + _historyCacheClock.resetClock(); + } - List sessions = []; - for (var doc in cloudTrainingHistory.docs) { - sessions.add(TrainingSession.fromJson(doc.data() as Map)); - } + List sessions = []; + for (var doc in cloudTrainingHistory.docs) { + sessions.add( + TrainingSession.fromJson(doc.data() as Map), + ); + } - return sessions; + return sessions; + }); } - /// will remove the history data corresponding to the id of the training session Future removeTrainingSessionFromHistory(final TrainingSession sesh) async { if (sesh.id == '') { - return Future.error("no session id!"); + throw Exception("No session ID!"); } - return _userDoc.collection(_historyKey).doc(sesh.id).delete(); + await _retryWithExponentialBackoff(() async { + await _userDoc.collection(_historyKey).doc(sesh.id).delete(); + }); } - //should we back this up?..maybe! Future deleteEntireTrainingHistory() async { - CollectionReference historyCollection = _userDoc.collection(_historyKey); - QuerySnapshot snapshot = await historyCollection.get(); - WriteBatch batch = FirebaseFirestore.instance.batch(); - // Add delete operations for each document to the batch - for (DocumentSnapshot doc in snapshot.docs) { - batch.delete(doc.reference); - } - await batch.commit(); + await _retryWithExponentialBackoff(() async { + CollectionReference historyCollection = _userDoc.collection(_historyKey); + QuerySnapshot snapshot = await historyCollection.get(); + WriteBatch batch = FirebaseFirestore.instance.batch(); + for (DocumentSnapshot doc in snapshot.docs) { + batch.delete(doc.reference); + } + await batch.commit(); + }); } Future getBasicUserInfo() async { - try { + return await _retryWithExponentialBackoff(() async { final docSnapshot = await _userDoc.get(const GetOptions(source: Source.server)); final data = docSnapshot.data() as Map?; @@ -118,39 +163,22 @@ class MyStorage { final basicUserInfoJson = data[_basicUserInfoKey] as Map; return BasicUserInfo.fromJson(basicUserInfoJson); } else { - //DNE return BasicUserInfo(); } - } catch (e) { - throw Exception('Failed to get basic user info: $e'); - } + }); } Future setBasicUserInfo(BasicUserInfo userInfo) async { - _userDoc.update({_basicUserInfoKey: userInfo.toJson()}); + await _retryWithExponentialBackoff(() async { + await _userDoc.update({_basicUserInfoKey: userInfo.toJson()}); + }); } - - // void addFieldToDocument(String docId, String collectionName) async { - // // Get a reference to the Firestore document - // DocumentReference documentReference = - // FirebaseFirestore.instance.collection(collectionName).doc(docId); - - // // Add a new field to the document (or update it if it already exists) - // try { - // await documentReference.update({ - // 'newFieldName': 'newValue', // Field name and value you want to add - // }); - // print('Field added successfully'); - // } catch (e) { - // print('Error updating document: $e'); - // } - // } } -//todo I should be selective about how I call this...gets esp. to server can fail! class CollectionCacheUpdateClock { final String _sharedPrefsLabel; late final Future _prefs; + CollectionCacheUpdateClock(String collectionName) : _sharedPrefsLabel = 'last_set_$collectionName' { _prefs = SharedPreferences.getInstance(); @@ -158,8 +186,10 @@ class CollectionCacheUpdateClock { Future resetClock() async { var prefs = await _prefs; - return prefs.setInt(_sharedPrefsLabel, DateTime.now().millisecondsSinceEpoch); - //todo err handling + return prefs.setInt( + _sharedPrefsLabel, + DateTime.now().millisecondsSinceEpoch, + ); } Future timeSinceCacheWasUpdated() async { @@ -174,7 +204,6 @@ class CollectionCacheUpdateClock { } } -// https://github.com/furkansarihan/firestore_collection/blob/master/lib/firestore_document.dart extension FirestoreDocumentExtension on DocumentReference { Future getSavy() async { try { @@ -187,7 +216,6 @@ extension FirestoreDocumentExtension on DocumentReference { } } -// https://github.com/furkansarihan/firestore_collection/blob/master/lib/firestore_query.dart extension FirestoreQueryExtension on Query { Future getSavy() async { try { diff --git a/lib/exercises/ex_search_page.dart b/lib/exercises/ex_search_page.dart index 5f1b515..a7fde23 100644 --- a/lib/exercises/ex_search_page.dart +++ b/lib/exercises/ex_search_page.dart @@ -41,121 +41,126 @@ class _ExerciseSearchPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'Exercises', - style: Theme.of(context).textTheme.displayLarge, - ), - //todo scrolling on web is not perfect.using slider is a bit janky. - Expanded( - child: ScrollConfiguration( - behavior: GenericScrollBehavior(), - child: Scrollbar( - controller: scrollController, - thumbVisibility: true, - child: ListView.builder( - controller: scrollController, - key: ValueKey(state.filteredExercises.length), - itemCount: state.filteredExercises.length, - itemBuilder: (context, index) { - return ExerciseTile( - exercise: state.filteredExercises[index], - isSelectable: widget.useForAddingToTraining, - isSelected: selectedExercises - .contains(state.filteredExercises[index]) || - newlySelectedExercises - .contains(state.filteredExercises[index]), - onSelectionChanged: (bool isSelected) { - if (isSelected) { - newlySelectedExercises.add(state.filteredExercises[index]); - } else { - newlySelectedExercises - .remove(state.filteredExercises[index]); - } - setState(() {}); - }); - }), - ), - ), - ), - if (widget.useForAddingToTraining) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), - child: MyGenericButton( - label: "Add Selected - ${newlySelectedExercises.length}", - color: Theme.of(context).colorScheme.primary, - onPressed: () { - var trainingCubit = context.read(); - for (var ex in newlySelectedExercises) { - // print(trainingCubit.state.toJson()); - // var ii = 1; - trainingCubit.addExercise(ex); - } - Navigator.pop(context); - }, - ), - ), + Text('Exercises', style: Theme.of(context).textTheme.displayLarge), + _exercisesListView(scrollController, state), + if (widget.useForAddingToTraining) _addSelectedButton(context), const SearchBar(), - Padding( - padding: const EdgeInsets.fromLTRB(5, 2, 5, 11), - child: Row( - children: [ - Expanded( - child: MyGenericButton( - label: state.musclesFilter.isEmpty - ? 'Any Muscle' - : state.musclesFilter - .map((e) => e.capTheFirstLetter()) - .join(", "), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return const SearchMultiSelectModal(isForMuscleSelection: true); - }, - ); - }, - ), - ), - const SizedBox(width: 8.0), - Expanded( - child: MyGenericButton( - label: state.categoriesFilter.isEmpty - ? 'Any Category' - : state.categoriesFilter - .map((e) => e.capTheFirstLetter()) - .join(", "), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return const SearchMultiSelectModal( - isForMuscleSelection: false); - }, - ); - }, - ), - ), - ], + _muscleAndCategoryFilterButtons(state, context), + _createNewExButton(context), + ], + ), + ); + } + + Padding _createNewExButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(5, 0, 5, 6), + child: MyGenericButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return const CreateNewExerciseModal(); + }, + ); + }, + label: 'Create New Exercise', + ), + ); + } + + Expanded _exercisesListView(ScrollController scrollController, ExSearchState state) { + //todo scrolling on web is not perfect.using slider is a bit janky. + return Expanded( + child: ScrollConfiguration( + behavior: GenericScrollBehavior(), + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: ListView.builder( + controller: scrollController, + key: ValueKey(state.filteredExercises.length), + itemCount: state.filteredExercises.length, + itemBuilder: (context, index) { + return ExerciseTile( + exercise: state.filteredExercises[index], + isSelectable: widget.useForAddingToTraining, + isSelected: selectedExercises + .contains(state.filteredExercises[index]) || + newlySelectedExercises.contains(state.filteredExercises[index]), + onSelectionChanged: (bool isSelected) { + if (isSelected) { + newlySelectedExercises.add(state.filteredExercises[index]); + } else { + newlySelectedExercises.remove(state.filteredExercises[index]); + } + setState(() {}); + }); + }), + ), + ), + ); + } + + Padding _muscleAndCategoryFilterButtons(ExSearchState state, BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(5, 2, 5, 11), + child: Row( + children: [ + Expanded( + child: MyGenericButton( + label: state.musclesFilter.isEmpty + ? 'Any Muscle' + : state.musclesFilter.map((e) => e.capTheFirstLetter()).join(", "), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return const SearchMultiSelectModal(isForMuscleSelection: true); + }, + ); + }, ), ), - Padding( - padding: const EdgeInsets.fromLTRB(5, 0, 5, 6), + const SizedBox(width: 8.0), + Expanded( child: MyGenericButton( + label: state.categoriesFilter.isEmpty + ? 'Any Category' + : state.categoriesFilter.map((e) => e.capTheFirstLetter()).join(", "), onPressed: () { showDialog( context: context, builder: (BuildContext context) { - return const CreateNewExerciseModal(); + return const SearchMultiSelectModal(isForMuscleSelection: false); }, ); }, - label: 'Create New Exercise', ), ), ], ), ); } + + Padding _addSelectedButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + child: MyGenericButton( + label: "Add Selected - ${newlySelectedExercises.length}", + color: Theme.of(context).colorScheme.primary, + onPressed: () { + var trainingCubit = context.read(); + for (var ex in newlySelectedExercises) { + // print(trainingCubit.state.toJson()); + // var ii = 1; + trainingCubit.addExercise(ex); + } + Navigator.pop(context); + }, + ), + ); + } } class SearchMultiSelectModal extends StatelessWidget { diff --git a/lib/importing/import_inspection_page.dart b/lib/importing/import_inspection_page.dart new file mode 100644 index 0000000..5b5f8bc --- /dev/null +++ b/lib/importing/import_inspection_page.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:fuzzywuzzy/model/extracted_result.dart'; +import 'package:open_fitness_tracker/DOM/exercise_db.dart'; +import 'package:open_fitness_tracker/DOM/exercise_metadata.dart'; +import 'package:open_fitness_tracker/DOM/training_metadata.dart'; +import 'package:open_fitness_tracker/exercises/ex_tile.dart'; +import 'package:open_fitness_tracker/utils/utils.dart'; + +class ImportInspectionPage extends StatefulWidget { + const ImportInspectionPage({super.key, required this.newTrainingSessions}); + final List newTrainingSessions; + + @override + State createState() => _ImportInspectionPageState(); +} + +class _ImportInspectionPageState extends State { + List idealMatchExs = []; + List similarMatchExs = []; + + @override + void initState() { + super.initState(); + List newExs = []; + List newExNames = []; + + for (var sesh in widget.newTrainingSessions) { + for (SetsOfAnExercise setsOfAnExercise in sesh.trainingData) { + Exercise ex = setsOfAnExercise.ex; + if (!newExNames.contains(ex.name)) { + newExs.add(ex); + newExNames.add(ex.name); + } + } + } + + idealMatchExs = _exerciseMatcher(newExs, 100); + List newExsSansMatches = + newExs.where((ex) => !idealMatchExs.contains(ex)).toList(); + similarMatchExs = _exerciseMatcher(newExsSansMatches, 90); + } + + @override + Widget build(BuildContext context) { + /*first iteration ideas: + show the number of training sessions & then a list of + all the new exercises you're about to import. + + impl: + for exercises, + if the name matches exactly.. then no problem, just add the history in. + else + get the muscles and the name + do fuzzy search to find similar names + if there is any decent overlap in muscles and name similarity w/ other ex, + map this ex to the existing one & assign this name in the new ex's alternateNames. + OR + ask ML to do the whole search + else, it's a new ex.. no problem. + + */ + return Scaffold( + appBar: AppBar( + title: Text("Imported ${widget.newTrainingSessions.length} sessions."), + ), + body: SafeArea( + minimum: const EdgeInsets.symmetric(horizontal: 5), + child: SingleChildScrollView( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PerfectMatchExercises(exercises: idealMatchExs), + MatchExercises(exercises: similarMatchExs), + ], + ), + ), + ), + ), + ); + } + + List _exerciseMatcher(List foreignExercises, int similarityCutoff) { + List similarExercises = []; + List nameMatches = []; + + for (var ex in foreignExercises) { + List exNames = ExDB.names; + List> res = extractTop( + query: ex.name, choices: exNames, cutoff: similarityCutoff, limit: 1); + + if (res.isNotEmpty) { + nameMatches.addIfDNE(res.first.choice); + } + } + + for (var exName in nameMatches) { + for (var ex in ExDB.exercises) { + if (exName == ex.name) { + similarExercises.add(ex); + } + } + } + + return similarExercises; + } +} + +class MatchExercises extends StatelessWidget { + final List exercises; + + const MatchExercises({super.key, required this.exercises}); + + @override + Widget build(BuildContext context) { + if (exercises.isEmpty) return Container(); + return Column( + children: [ + const Text( + textAlign: TextAlign.center, + "confirm these matches:", + style: TextStyle( + fontSize: 20, // Adjust this value for your desired "medium" size + fontWeight: FontWeight.w600, // Makes the text semi-bold + color: Colors.black87, // Optional: adjust the color as needed + ), + ), + const SizedBox(height: 20), + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Container( + decoration: BoxDecoration(border: Border.all()), + height: 400, + child: ListView.builder( + itemCount: exercises.length, + itemBuilder: (context, index) { + return ExerciseTile(exercise: exercises[index]); + }, + ), + ); + }, + ), + ], + ); + } +} + +class PerfectMatchExercises extends StatelessWidget { + final List exercises; + + const PerfectMatchExercises({super.key, required this.exercises}); + + @override + Widget build(BuildContext context) { + if (exercises.isEmpty) return Container(); + return Column( + children: [ + const Text( + textAlign: TextAlign.center, + "perfect match exercises:", + style: TextStyle( + fontSize: 20, // Adjust this value for your desired "medium" size + fontWeight: FontWeight.w600, // Makes the text semi-bold + color: Colors.black87, // Optional: adjust the color as needed + ), + ), + const SizedBox(height: 20), + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Container( + decoration: BoxDecoration(border: Border.all()), + height: 200, + child: ListView.builder( + itemCount: exercises.length, + itemBuilder: (context, index) { + return ExerciseTile(exercise: exercises[index]); + }, + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/importing/import_training_ui.dart b/lib/importing/import_training_ui.dart index e7877c9..e889f0d 100644 --- a/lib/importing/import_training_ui.dart +++ b/lib/importing/import_training_ui.dart @@ -4,6 +4,7 @@ import 'package:open_fitness_tracker/DOM/basic_user_info.dart'; import 'package:open_fitness_tracker/DOM/history_importing_logic.dart'; import 'package:open_fitness_tracker/DOM/training_metadata.dart'; import 'package:open_fitness_tracker/common/common_widgets.dart'; +import 'package:open_fitness_tracker/importing/import_inspection_page.dart'; import 'package:open_fitness_tracker/styles.dart'; import 'package:open_fitness_tracker/utils/utils.dart'; @@ -37,38 +38,6 @@ class ExternalAppImportSelectionDialog extends StatelessWidget { ), ); } -/* - void _importTrainingDataDialog( - String filepath, OtherTrainingApps originApp, BuildContext context) async { - var scaffoldMessenger = ScaffoldMessenger.of(context); - - //todo for web - // https://github.com/miguelpruivo/flutter_file_picker/wiki/FAQ - - Units units = Units(); // Or fetch existing units - final unitsResult = await showDialog( - context: context, - builder: (BuildContext context) { - return UnitsDialog(units: units); - }, - ); - - List sessions = []; - if (originApp == OtherTrainingApps.strong) { - sessions = importStrongCsv(filepath, units); - } - - for (var session in sessions) { - myStorage.addTrainingSessionToHistory(session); - } - - //todo (low-priority) tell the user if they are importing duplicates..we already discard them. maybe it doesn't matter. - - scaffoldMessenger.showSnackBar( - SnackBar(content: Text("imported ${sessions.length} sessions.")), - ); - } - */ } class ImportTrainingDataPage extends StatefulWidget { @@ -180,11 +149,7 @@ class _ImportTrainingDataPageState extends State { return; } - List sessions = importStrongCsv(filepath!, units); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("imported ${sessions.length} sessions.")), - ); + List sessions = importStrongCsv(filepath!, units); if (setAsStandard) { var userInfoCubit = context.read(); @@ -193,7 +158,9 @@ class _ImportTrainingDataPageState extends State { userInfo.preferredMassUnit = units.preferredMassUnit; userInfoCubit.set(userInfo); } - // Navigator.of(context).pop(units); + Navigator.of(context).push(MaterialPageRoute( + builder: (BuildContext context) => + ImportInspectionPage(newTrainingSessions: sessions))); }, ), ], @@ -207,10 +174,7 @@ class _ImportTrainingDataPageState extends State { for (var session in sessions) { myStorage.addTrainingSessionToHistory(session); } - - //todo (low-priority) tell the user if they are importing duplicates..we already discard them. maybe it doesn't matter. - - + */ } }