diff --git a/lib/core/database.dart b/lib/core/database.dart index ba29b7f..d23cb1b 100644 --- a/lib/core/database.dart +++ b/lib/core/database.dart @@ -119,6 +119,7 @@ class DatabaseHelper { required String weight, required int reps, required int sets, + required String timestamp, }) async { final db = await database; await db.update( @@ -128,13 +129,14 @@ class DatabaseHelper { 'weight': weight, 'reps': reps, 'sets': sets, - // Note: Do not update the timestamp here + 'timestamp': timestamp, }, where: 'id = ?', whereArgs: [id], ); } + Future isNewHighScore(String exerciseName, double newWeight, int newReps) async { final db = await database; diff --git a/lib/tabs/home.dart b/lib/tabs/home.dart index 7afe676..b0e1d0f 100644 --- a/lib/tabs/home.dart +++ b/lib/tabs/home.dart @@ -72,6 +72,7 @@ class _ExerciseStoreHomePageState extends State with Sing weight: result['weight'], reps: result['reps'], sets: result['sets'], + timestamp: result['timestamp'], ); setState(() {}); } @@ -212,43 +213,43 @@ 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'])), - 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/visualization.dart b/lib/tabs/visualization.dart index a25a792..ffe5417 100644 --- a/lib/tabs/visualization.dart +++ b/lib/tabs/visualization.dart @@ -9,8 +9,10 @@ class VisualizationTab extends StatefulWidget { _VisualizationTabState createState() => _VisualizationTabState(); } -class _VisualizationTabState extends State with AutomaticKeepAliveClientMixin { +class _VisualizationTabState extends State { String? _selectedExercise; + String _aggregationMethod = 'Max'; // Default aggregation method + String _chartType = 'Line'; // Default chart type List _exerciseNames = []; List _dataPoints = []; @@ -25,7 +27,6 @@ class _VisualizationTabState extends State with AutomaticKeepA Future _fetchExerciseNames() async { try { final variables = await _dbHelper.getExercises(); - print('Fetched exercises: $variables'); final names = variables.map((exercise) => exercise['exercise'] as String).toSet().toList(); setState(() { _exerciseNames = names; @@ -40,22 +41,39 @@ class _VisualizationTabState extends State with AutomaticKeepA final exercises = await _dbHelper.getExercises(); final filteredExercises = exercises.where((exercise) => exercise['exercise'] == exerciseName).toList(); - // Sort the filtered exercises by timestamp in descending order - filteredExercises.sort((a, b) => DateTime.parse(b['timestamp']).compareTo(DateTime.parse(a['timestamp']))); - - // Get the earliest date (ignoring the time part) - final earliestDate = DateUtils.dateOnly(DateTime.parse(filteredExercises.last['timestamp'])); - - final dataPoints = filteredExercises.map((exercise) { + final groupedByDate = >{}; + for (var exercise in filteredExercises) { final dateTime = DateUtils.dateOnly(DateTime.parse(exercise['timestamp'])); + final weight = double.parse(exercise['weight']); + if (groupedByDate.containsKey(dateTime)) { + groupedByDate[dateTime]!.add(weight); + } else { + groupedByDate[dateTime] = [weight]; + } + } - // Calculate the difference in days, ignoring hours time information - final dayDifference = dateTime.difference(earliestDate).inDays.toDouble(); - return ScatterSpot(dayDifference, double.parse(exercise['weight'])); - }).toList(); + final aggregatedDataPoints = []; + final earliestDate = DateUtils.dateOnly(DateTime.parse(filteredExercises.last['timestamp'])); + groupedByDate.forEach((date, weights) { + double value; + switch (_aggregationMethod) { + case 'Max': + value = weights.reduce((a, b) => a > b ? a : b); + break; + case 'Average': + value = weights.reduce((a, b) => a + b) / weights.length; + break; + case 'None': + default: + value = weights.last; + break; + } + final dayDifference = date.difference(earliestDate).inDays.toDouble(); + aggregatedDataPoints.add(ScatterSpot(dayDifference, value)); + }); setState(() { - _dataPoints = dataPoints; + _dataPoints = aggregatedDataPoints; }); } catch (e) { print('Error fetching data points: $e'); @@ -64,107 +82,198 @@ class _VisualizationTabState extends State with AutomaticKeepA @override Widget build(BuildContext context) { - super.build(context); // Required by AutomaticKeepAliveClientMixin return Padding( padding: const EdgeInsets.all(16.0), child: Column( children: [ - DropdownButton( - hint: const Text('Select an exercise'), - value: _selectedExercise, - onChanged: (newValue) { - if (newValue != null) { - setState(() { - _selectedExercise = newValue; - }); - _fetchDataPoints(newValue); - } - }, - items: _exerciseNames.map((exerciseName) { - return DropdownMenuItem( - value: exerciseName, - child: Text(exerciseName), - ); - }).toList(), - ), + _buildExerciseDropdown(), + const SizedBox(height: 16.0), + _buildAggregationDropdown(), + const SizedBox(height: 16.0), + _buildChartTypeToggle(), const SizedBox(height: 16.0), Expanded( child: _dataPoints.isEmpty ? const Center(child: Text('No data available')) - : ScatterChart( - ScatterChartData( - scatterSpots: _dataPoints, - scatterTouchData: ScatterTouchData( - touchTooltipData: ScatterTouchTooltipData( - getTooltipColor: (ScatterSpot touchedSpot) => Colors.blueAccent, - ), - enabled: true, - ), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, // Add padding on the left for numbers and text - getTitlesWidget: (value, meta) { - return Text( - value.toInt().toString(), - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ); - }, - ), - axisNameWidget: const Text( - 'Weights [kg]', - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) { - return Text( - value.toInt().toString(), - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ); - }, - ), - axisNameWidget: const Text( - 'Days', - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - ), - borderData: FlBorderData(show: true), - gridData: const FlGridData(show: true), - ), - ), + : _buildChart(), ), ], ), ); } - @override - bool get wantKeepAlive => true; // Required by AutomaticKeepAliveClientMixin + Widget _buildExerciseDropdown() { + return DropdownButton( + hint: const Text('Select an exercise'), + value: _selectedExercise, + onChanged: (newValue) { + if (newValue != null) { + setState(() { + _selectedExercise = newValue; + }); + _fetchDataPoints(newValue); + } + }, + items: _exerciseNames.map((exerciseName) { + return DropdownMenuItem( + value: exerciseName, + child: Text(exerciseName), + ); + }).toList(), + ); + } + + Widget _buildAggregationDropdown() { + return DropdownButton( + hint: const Text('Select aggregation method'), + value: _aggregationMethod, + onChanged: (newValue) { + if (newValue != null) { + setState(() { + _aggregationMethod = newValue; + // Automatically switch to Scatter if 'None' is selected + if (_aggregationMethod == 'None' && _chartType == 'Line') { + _chartType = 'Scatter'; + } + }); + if (_selectedExercise != null) { + _fetchDataPoints(_selectedExercise!); + } + } + }, + items: ['None', 'Max', 'Average'].map((method) { + return DropdownMenuItem( + value: method, + child: Text(method), + ); + }).toList(), + ); + } + + Widget _buildChartTypeToggle() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Chart Type:', + style: Theme.of(context).textTheme.titleMedium, + ), + DropdownButton( + value: _chartType, + onChanged: (newValue) { + if (newValue != null) { + setState(() { + _chartType = newValue; + }); + } + }, + items: _aggregationMethod == 'None' + ? ['Scatter'].map((type) { + return DropdownMenuItem( + value: type, + child: Text(type), + ); + }).toList() + : ['Line', 'Scatter'].map((type) { + return DropdownMenuItem( + value: type, + child: Text(type), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildChart() { + return _chartType == 'Line' + ? LineChart( + LineChartData( + lineBarsData: [ + LineChartBarData( + spots: _dataPoints, + isCurved: false, // Straight lines + color: Colors.blue, + dotData: FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ], + titlesData: _buildTitlesData(), + borderData: FlBorderData(show: true), + gridData: const FlGridData(show: true), + ), + ) + : ScatterChart( + ScatterChartData( + scatterSpots: _dataPoints, + scatterTouchData: ScatterTouchData( + touchTooltipData: ScatterTouchTooltipData( + getTooltipColor: (ScatterSpot touchedSpot) => Colors.blueAccent, + ), + enabled: true, + ), + titlesData: _buildTitlesData(), + borderData: FlBorderData(show: true), + gridData: const FlGridData(show: true), + ), + ); + } + + FlTitlesData _buildTitlesData() { + return FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ); + }, + ), + axisNameWidget: const Text( + 'Weights [kg]', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ); + }, + ), + axisNameWidget: const Text( + 'Days', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ); + } } diff --git a/lib/widgets/exercise_edit_dialog.dart b/lib/widgets/exercise_edit_dialog.dart index cea3e94..4657376 100644 --- a/lib/widgets/exercise_edit_dialog.dart +++ b/lib/widgets/exercise_edit_dialog.dart @@ -15,6 +15,7 @@ class _ExerciseEditDialogState extends State { late TextEditingController _weightController; late TextEditingController _repsController; late TextEditingController _setsController; + late TextEditingController _timestampController; @override void initState() { @@ -23,6 +24,7 @@ class _ExerciseEditDialogState extends State { _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 @@ -86,6 +88,22 @@ class _ExerciseEditDialogState extends State { return null; }, ), + TextFormField( + controller: _timestampController, + decoration: const InputDecoration(labelText: 'Timestamp (ISO8601)'), + keyboardType: TextInputType.datetime, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a timestamp'; + } + try { + DateTime.parse(value); + } catch (e) { + return 'Please enter a valid ISO8601 timestamp'; + } + return null; + }, + ), ], ), ), @@ -105,6 +123,7 @@ class _ExerciseEditDialogState extends State { 'weight': _weightController.text, 'reps': int.parse(_repsController.text), 'sets': int.parse(_setsController.text), + 'timestamp': _timestampController.text, }); } }, @@ -120,6 +139,7 @@ class _ExerciseEditDialogState extends State { _weightController.dispose(); _repsController.dispose(); _setsController.dispose(); + _timestampController.dispose(); super.dispose(); } }