diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml new file mode 100644 index 0000000..0bd547a --- /dev/null +++ b/.github/workflows/linting.yml @@ -0,0 +1,31 @@ +name: Linting + +on: + pull_request: + branches: + - "*" + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.22.2' + channel: 'stable' + - run: flutter --version + + - name: Install dependencies + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + #- name: Analyze project source + # run: dart analyze --fatal-infos + + #- name: Run tests + # run: flutter test \ No newline at end of file diff --git a/README.md b/README.md index 297a71c..5bcaab3 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ [![GitHub Downloads](https://img.shields.io/github/downloads/andreped/IronFlow/total?label=GitHub%20downloads&logo=github)](https://github.com/andreped/IronFlow/releases) ![CI](https://github.com/andreped/IronFlow/workflows/Build%20APK/badge.svg) ![CI](https://github.com/andreped/IronFlow/workflows/Build%20IPA/badge.svg) +![CI](https://github.com/andreped/IronFlow/workflows/Linting/badge.svg) **IronFlow** was developed to allow free, private, and seemless tracking of training progress and activities. @@ -45,6 +46,7 @@ The app is compatible with both Android and iOS. | **Build APK** | ![CI](https://github.com/andreped/IronFlow/workflows/Build%20APK/badge.svg) | | **Build IPA** | ![CI](https://github.com/andreped/IronFlow/workflows/Build%20IPA/badge.svg) | | **Create Release** | ![CI](https://github.com/andreped/IronFlow/workflows/Create%20Release/badge.svg) | +| **Linting** | ![CI](https://github.com/andreped/IronFlow/workflows/Linting/badge.svg) | ## [Getting Started](https://github.com/andreped/IronFlow#Getting-Started) diff --git a/lib/core/database.dart b/lib/core/database.dart index d23cb1b..cacb5fa 100644 --- a/lib/core/database.dart +++ b/lib/core/database.dart @@ -41,7 +41,8 @@ class DatabaseHelper { await _initializePredefinedExercises(db); } - Future _upgradeDatabase(Database db, int oldVersion, int newVersion) async { + Future _upgradeDatabase( + Database db, int oldVersion, int newVersion) async { // Placeholder for future upgrade logic if (oldVersion < newVersion) { // Example: if (oldVersion < 2) { @@ -104,9 +105,8 @@ class DatabaseHelper { Future> getExerciseDates() async { final db = await database; - final List> datesResult = await db.rawQuery( - 'SELECT DISTINCT date(timestamp) as date FROM exercises' - ); + final List> datesResult = await db + .rawQuery('SELECT DISTINCT date(timestamp) as date FROM exercises'); return datesResult.map((row) { return DateTime.parse(row['date']); @@ -136,8 +136,8 @@ class DatabaseHelper { ); } - - Future isNewHighScore(String exerciseName, double newWeight, int newReps) async { + Future isNewHighScore( + String exerciseName, double newWeight, int newReps) async { final db = await database; // Query to get the current highest weight and corresponding reps for that weight @@ -157,7 +157,8 @@ class DatabaseHelper { final maxReps = row['reps'] as int; // Check if both weight and reps are greater than the current record - if (newWeight > maxWeight || (newWeight == maxWeight && newReps > maxReps)) { + if (newWeight > maxWeight || + (newWeight == maxWeight && newReps > maxReps)) { return true; // New record found } } @@ -234,7 +235,8 @@ class DatabaseHelper { Future> getPredefinedExercises() async { final db = await database; - final List> result = await db.query('predefined_exercises'); + final List> result = + await db.query('predefined_exercises'); return result.map((row) => row['name'] as String).toList(); } @@ -243,7 +245,8 @@ class DatabaseHelper { await db.insert( 'predefined_exercises', {'name': exerciseName}, - conflictAlgorithm: ConflictAlgorithm.ignore, // Handle if exercise already exists + conflictAlgorithm: + ConflictAlgorithm.ignore, // Handle if exercise already exists ); } @@ -251,8 +254,7 @@ class DatabaseHelper { final db = await database; // Query to get the maximum weight and corresponding highest reps for each exercise - final List> results = await db.rawQuery( - ''' + final List> results = await db.rawQuery(''' SELECT exercise, weight, reps FROM exercises WHERE (exercise, CAST(weight AS REAL)) IN ( @@ -261,8 +263,7 @@ class DatabaseHelper { GROUP BY exercise ) ORDER BY exercise, CAST(weight AS REAL) DESC, reps DESC - ''' - ); + '''); Map> maxWeights = {}; for (var result in results) { @@ -291,8 +292,8 @@ class DatabaseHelper { return maxWeights; } - - Future?> getLastLoggedExercise(String exerciseName) async { + Future?> getLastLoggedExercise( + String exerciseName) async { final db = await database; final List> result = await db.query( 'exercises', @@ -306,12 +307,12 @@ class DatabaseHelper { final row = result.first; return { 'exercise': row['exercise'], - 'weight': double.tryParse(row['weight']) ?? 0.0, // Convert weight to double + 'weight': + double.tryParse(row['weight']) ?? 0.0, // Convert weight to double 'reps': row['reps'] as int, 'sets': row['sets'] as int, }; } return null; } - } diff --git a/lib/tabs/home.dart b/lib/tabs/home.dart index b0e1d0f..2ba277f 100644 --- a/lib/tabs/home.dart +++ b/lib/tabs/home.dart @@ -24,7 +24,8 @@ class ExerciseStoreHomePage extends StatefulWidget { _ExerciseStoreHomePageState createState() => _ExerciseStoreHomePageState(); } -class _ExerciseStoreHomePageState extends State with SingleTickerProviderStateMixin { +class _ExerciseStoreHomePageState extends State + with SingleTickerProviderStateMixin { final DatabaseHelper _dbHelper = DatabaseHelper(); DateTime _selectedDay = DateTime.now(); late TabController _tabController; @@ -91,7 +92,8 @@ class _ExerciseStoreHomePageState extends State with Sing builder: (BuildContext context) { return AlertDialog( title: const Text('⚠️ Confirm Deletion'), - content: const Text('🚨 Clicking this button deletes all the recorded exercise data. Are you sure you want to do this?'), + content: const Text( + '🚨 Clicking this button deletes all the recorded exercise data. Are you sure you want to do this?'), actions: [ TextButton( child: const Text('No'), @@ -112,7 +114,8 @@ class _ExerciseStoreHomePageState extends State with Sing builder: (BuildContext context) { return AlertDialog( title: const Text('❗️ Are you really sure?'), - content: const Text('💥 Are you really sure you want to lose all your data? There is no going back!'), + content: const Text( + '💥 Are you really sure you want to lose all your data? There is no going back!'), actions: [ TextButton( child: const Text('No'), @@ -164,7 +167,9 @@ class _ExerciseStoreHomePageState extends State with Sing tabs: const [ Tab(icon: Icon(Icons.add), text: 'Log\nExercise'), Tab(icon: Icon(Icons.calendar_today), text: 'Summary'), - Tab(icon: Icon(Icons.celebration), text: 'Records'), // New Records tab + Tab( + icon: Icon(Icons.celebration), + text: 'Records'), // New Records tab Tab(icon: Icon(Icons.show_chart), text: 'Visualize\nData'), Tab(icon: Icon(Icons.table_chart), text: 'View\nTable'), ], @@ -174,7 +179,8 @@ class _ExerciseStoreHomePageState extends State with Sing bucket: bucket, child: PageView( controller: _pageController, - physics: const NeverScrollableScrollPhysics(), // Disable swipe gesture + physics: + const NeverScrollableScrollPhysics(), // Disable swipe gesture onPageChanged: (index) { _tabController.animateTo(index); }, @@ -213,43 +219,44 @@ class _ExerciseStoreHomePageState extends State with Sing } final exercises = snapshot.data!; return DataTable( - columns: const [ - DataColumn(label: Text('ID')), - DataColumn(label: Text('Exercise')), - DataColumn(label: Text('Weight')), - DataColumn(label: Text('Reps')), - DataColumn(label: Text('Sets')), - DataColumn(label: Text('Timestamp')), - DataColumn(label: Text('Actions')), - ], - rows: exercises.map((exercise) { - return DataRow(cells: [ - DataCell(Text(exercise['id'].toString())), - DataCell(Text(exercise['exercise'])), - DataCell(Text(exercise['weight'])), - DataCell(Text(exercise['reps'].toString())), - DataCell(Text(exercise['sets'].toString())), - DataCell(Text(exercise['timestamp'])), // Display the timestamp - DataCell( - Row( - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - _showEditDialog(exercise); - }, - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () async { - await _deleteExercise(exercise['id']); - }, - ), - ], - ), + columns: const [ + DataColumn(label: Text('ID')), + DataColumn(label: Text('Exercise')), + DataColumn(label: Text('Weight')), + DataColumn(label: Text('Reps')), + DataColumn(label: Text('Sets')), + DataColumn(label: Text('Timestamp')), + DataColumn(label: Text('Actions')), + ], + rows: exercises.map((exercise) { + return DataRow(cells: [ + DataCell(Text(exercise['id'].toString())), + DataCell(Text(exercise['exercise'])), + DataCell(Text(exercise['weight'])), + DataCell(Text(exercise['reps'].toString())), + DataCell(Text(exercise['sets'].toString())), + DataCell(Text( + exercise['timestamp'])), // Display the timestamp + DataCell( + Row( + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + _showEditDialog(exercise); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + await _deleteExercise(exercise['id']); + }, + ), + ], ), - ]); - }).toList(), + ), + ]); + }).toList(), ); }, ), diff --git a/lib/tabs/inputs.dart b/lib/tabs/inputs.dart index c762d52..17018fa 100644 --- a/lib/tabs/inputs.dart +++ b/lib/tabs/inputs.dart @@ -37,7 +37,8 @@ class _ExerciseSetterState extends State { List exercises = await _dbHelper.getPredefinedExercises(); setState(() { _predefinedExercises = exercises; - _selectedExercise = _predefinedExercises.isNotEmpty ? _predefinedExercises.first : null; + _selectedExercise = + _predefinedExercises.isNotEmpty ? _predefinedExercises.first : null; if (_selectedExercise != null) { _loadLastLoggedExercise(); } @@ -46,7 +47,8 @@ class _ExerciseSetterState extends State { Future _loadLastLoggedExercise() async { if (_selectedExercise != null) { - final lastLogged = await _dbHelper.getLastLoggedExercise(_selectedExercise!); + final lastLogged = + await _dbHelper.getLastLoggedExercise(_selectedExercise!); if (lastLogged != null) { setState(() { _lastExerciseName = lastLogged['exercise']; @@ -71,7 +73,8 @@ class _ExerciseSetterState extends State { final reps = int.parse(_repsController.text); final sets = int.parse(_setsController.text); - final isNewHighScore = await _dbHelper.isNewHighScore(exerciseName, weight, reps); + final isNewHighScore = + await _dbHelper.isNewHighScore(exerciseName, weight, reps); await _dbHelper.insertExercise( exercise: exerciseName, @@ -169,10 +172,16 @@ class _ExerciseSetterState extends State { child: GestureDetector( onTap: _openExerciseSelectionSheet, child: InputDecorator( - decoration: const InputDecoration(labelText: 'Select Exercise'), + decoration: + const InputDecoration(labelText: 'Select Exercise'), child: Text( - _isAddingNewExercise ? 'Add New Exercise' : _selectedExercise ?? 'Select Exercise', - style: TextStyle(color: _selectedExercise == null ? Colors.grey : Colors.black), + _isAddingNewExercise + ? 'Add New Exercise' + : _selectedExercise ?? 'Select Exercise', + style: TextStyle( + color: _selectedExercise == null + ? Colors.grey + : Colors.black), ), ), ), @@ -184,9 +193,11 @@ class _ExerciseSetterState extends State { padding: const EdgeInsets.only(top: 16.0), child: TextFormField( controller: _newExerciseController, - decoration: const InputDecoration(labelText: 'New Exercise Name'), + decoration: + const InputDecoration(labelText: 'New Exercise Name'), validator: (value) { - if (_isAddingNewExercise && (value == null || value.isEmpty)) { + if (_isAddingNewExercise && + (value == null || value.isEmpty)) { return 'Please enter a new exercise name'; } return null; diff --git a/lib/tabs/summary.dart b/lib/tabs/summary.dart index 36702cf..81786ea 100644 --- a/lib/tabs/summary.dart +++ b/lib/tabs/summary.dart @@ -39,7 +39,8 @@ class _SummaryTabState extends State { const Text('Select Day: '), TextButton( onPressed: () => _selectDate(context), - child: Text('${widget.selectedDay.year}-${widget.selectedDay.month}-${widget.selectedDay.day}'), + child: Text( + '${widget.selectedDay.year}-${widget.selectedDay.month}-${widget.selectedDay.day}'), ), ], ), @@ -65,7 +66,8 @@ class _SummaryTabState extends State { final totalSets = details['totalSets']; final totalReps = details['totalReps']; final avgWeight = details['avgWeight']; - final records = details['records'] as List>; + final records = + details['records'] as List>; return Card( child: ExpansionTile( diff --git a/lib/tabs/visualization.dart b/lib/tabs/visualization.dart index ffe5417..56363c1 100644 --- a/lib/tabs/visualization.dart +++ b/lib/tabs/visualization.dart @@ -27,7 +27,10 @@ class _VisualizationTabState extends State { Future _fetchExerciseNames() async { try { final variables = await _dbHelper.getExercises(); - final names = variables.map((exercise) => exercise['exercise'] as String).toSet().toList(); + final names = variables + .map((exercise) => exercise['exercise'] as String) + .toSet() + .toList(); setState(() { _exerciseNames = names; }); @@ -39,11 +42,14 @@ class _VisualizationTabState extends State { Future _fetchDataPoints(String exerciseName) async { try { final exercises = await _dbHelper.getExercises(); - final filteredExercises = exercises.where((exercise) => exercise['exercise'] == exerciseName).toList(); + final filteredExercises = exercises + .where((exercise) => exercise['exercise'] == exerciseName) + .toList(); final groupedByDate = >{}; for (var exercise in filteredExercises) { - final dateTime = DateUtils.dateOnly(DateTime.parse(exercise['timestamp'])); + final dateTime = + DateUtils.dateOnly(DateTime.parse(exercise['timestamp'])); final weight = double.parse(exercise['weight']); if (groupedByDate.containsKey(dateTime)) { groupedByDate[dateTime]!.add(weight); @@ -53,7 +59,8 @@ class _VisualizationTabState extends State { } final aggregatedDataPoints = []; - final earliestDate = DateUtils.dateOnly(DateTime.parse(filteredExercises.last['timestamp'])); + final earliestDate = DateUtils.dateOnly( + DateTime.parse(filteredExercises.last['timestamp'])); groupedByDate.forEach((date, weights) { double value; switch (_aggregationMethod) { @@ -208,7 +215,8 @@ class _VisualizationTabState extends State { scatterSpots: _dataPoints, scatterTouchData: ScatterTouchData( touchTooltipData: ScatterTouchTooltipData( - getTooltipColor: (ScatterSpot touchedSpot) => Colors.blueAccent, + getTooltipColor: (ScatterSpot touchedSpot) => + Colors.blueAccent, ), enabled: true, ), diff --git a/lib/widgets/exercise_edit_dialog.dart b/lib/widgets/exercise_edit_dialog.dart index 4657376..a34dd05 100644 --- a/lib/widgets/exercise_edit_dialog.dart +++ b/lib/widgets/exercise_edit_dialog.dart @@ -20,11 +20,16 @@ class _ExerciseEditDialogState extends State { @override void initState() { super.initState(); - _exerciseController = TextEditingController(text: widget.exerciseData['exercise']); - _weightController = TextEditingController(text: widget.exerciseData['weight']); - _repsController = TextEditingController(text: widget.exerciseData['reps'].toString()); - _setsController = TextEditingController(text: widget.exerciseData['sets'].toString()); - _timestampController = TextEditingController(text: widget.exerciseData['timestamp']); + _exerciseController = + TextEditingController(text: widget.exerciseData['exercise']); + _weightController = + TextEditingController(text: widget.exerciseData['weight']); + _repsController = + TextEditingController(text: widget.exerciseData['reps'].toString()); + _setsController = + TextEditingController(text: widget.exerciseData['sets'].toString()); + _timestampController = + TextEditingController(text: widget.exerciseData['timestamp']); } @override @@ -90,7 +95,8 @@ class _ExerciseEditDialogState extends State { ), TextFormField( controller: _timestampController, - decoration: const InputDecoration(labelText: 'Timestamp (ISO8601)'), + decoration: + const InputDecoration(labelText: 'Timestamp (ISO8601)'), keyboardType: TextInputType.datetime, validator: (value) { if (value == null || value.isEmpty) { diff --git a/pubspec.yaml b/pubspec.yaml index c186549..a48d05e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,8 @@ dev_dependencies: sdk: flutter # tests - mockito: ^5.0.0 # Adjust version as needed + test: ^1.17.0 + mockito: ^5.0.16 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/test/database_test.dart b/test/database_test.dart new file mode 100644 index 0000000..87eb779 --- /dev/null +++ b/test/database_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:IronFlow/core/database.dart'; // Adjust this import based on your project structure + +// Mock Database class using Mockito +class MockDatabase extends Mock implements Database {} + +// Mocking a function to simulate getting a database path +class MockDatabaseFactory extends Mock implements DatabaseFactory {} + +void main() { + late DatabaseHelper databaseHelper; + late MockDatabase mockDatabase; + late MockDatabaseFactory mockDatabaseFactory; + + setUp(() { + mockDatabase = MockDatabase(); + mockDatabaseFactory = MockDatabaseFactory(); + databaseHelper = DatabaseHelper(); + }); + + group('DatabaseHelper Tests', () { + test('Should initialize database and create tables', () async { + // Arrange + when(mockDatabaseFactory.openDatabase( + await getDatabasesPath(), + options: anyNamed('options'), + )).thenAnswer((_) async => mockDatabase); + + when(mockDatabase.execute( + 'CREATE TABLE exercises(id INTEGER PRIMARY KEY AUTOINCREMENT, exercise TEXT, weight TEXT, reps INTEGER, sets INTEGER, timestamp TEXT)')) + .thenAnswer((_) async => Future.value()); + + when(mockDatabase.execute( + 'CREATE TABLE IF NOT EXISTS predefined_exercises(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)')) + .thenAnswer((_) async => Future.value()); + + // Act + final db = await databaseHelper.database; + await databaseHelper.database; // Trigger database initialization + + // Assert + verify(mockDatabase.execute( + 'CREATE TABLE exercises(id INTEGER PRIMARY KEY AUTOINCREMENT, exercise TEXT, weight TEXT, reps INTEGER, sets INTEGER, timestamp TEXT)', + )).called(1); + verify(mockDatabase.execute( + 'CREATE TABLE IF NOT EXISTS predefined_exercises(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)', + )).called(1); + }); + + test('Should insert exercise into the database', () async { + // Arrange + when(mockDatabase.insert( + 'exercises', + { + 'exercise': 'Bench Press', + 'weight': '100', + 'reps': 10, + 'sets': 4, + 'timestamp': isA(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + )).thenAnswer((_) async => 1); + + // Act + await databaseHelper.insertExercise( + exercise: 'Bench Press', + weight: '100', + reps: 10, + sets: 4, + ); + + // Assert + verify(mockDatabase.insert( + 'exercises', + { + 'exercise': 'Bench Press', + 'weight': '100', + 'reps': 10, + 'sets': 4, + 'timestamp': isA(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + )).called(1); + }); + + test('Should delete exercise from the database', () async { + // Arrange + when(mockDatabase.delete( + 'exercises', + where: 'id = ?', + whereArgs: [1], + )).thenAnswer((_) async => 1); + + // Act + await databaseHelper.deleteExercise(1); + + // Assert + verify(mockDatabase.delete( + 'exercises', + where: 'id = ?', + whereArgs: [1], + )).called(1); + }); + + test('Should retrieve exercises from the database', () async { + // Arrange + when(mockDatabase.query('exercises', orderBy: 'timestamp DESC')) + .thenAnswer((_) async => [ + { + 'exercise': 'Bench Press', + 'weight': '100', + 'reps': 10, + 'sets': 4, + 'timestamp': DateTime.now().toString() + } + ]); + + // Act + final exercises = await databaseHelper.getExercises(); + + // Assert + expect(exercises.length, 1); + expect(exercises.first['exercise'], 'Bench Press'); + }); + + test('Should check for new high score correctly', () async { + // Arrange + when(mockDatabase.rawQuery( + 'SELECT weight, reps FROM exercises WHERE exercise = ? ORDER BY weight DESC, reps DESC LIMIT 1', + [ + 'Bench Press' + ])).thenAnswer((_) async => [ + {'weight': '100', 'reps': 10} + ]); + + // Act + final isNewHighScore = + await databaseHelper.isNewHighScore('Bench Press', 110, 8); + + // Assert + expect(isNewHighScore, true); + }); + + test('Should calculate total weight for a day correctly', () async { + // Arrange + final today = DateTime.now().toString().split(' ')[0]; + when(mockDatabase.query('exercises', + where: 'timestamp LIKE ?', whereArgs: ['$today%'])) + .thenAnswer((_) async => [ + { + 'exercise': 'Bench Press', + 'weight': '100', + 'reps': 10, + 'sets': 4, + 'timestamp': DateTime.now().toString() + } + ]); + + // Act + final totalWeight = + await databaseHelper.getTotalWeightForDay(DateTime.now()); + + // Assert + expect(totalWeight['Bench Press'], 100.0 * 10 * 4); + }); + + test('Should get the last logged exercise correctly', () async { + // Arrange + when(mockDatabase.query('exercises', + where: 'exercise = ?', + whereArgs: ['Bench Press'], + orderBy: 'timestamp DESC', + limit: 1)) + .thenAnswer((_) async => [ + { + 'exercise': 'Bench Press', + 'weight': '100', + 'reps': 10, + 'sets': 4, + 'timestamp': DateTime.now().toString() + } + ]); + + // Act + final lastLoggedExercise = + await databaseHelper.getLastLoggedExercise('Bench Press'); + + // Assert + expect(lastLoggedExercise, isNotNull); + expect(lastLoggedExercise!['exercise'], 'Bench Press'); + }); + }); +}