From 2ee0659d0e0231cb0a8ad029fb1a4f7ceca91b4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Tue, 7 Jan 2025 09:16:42 +0100 Subject: [PATCH] Added overview page feature (#402) * Created simple floating overview button with page * Added toy overview page with fancy visuals * Tried to add relevant stuff to overview * Refactored code * Made overview working * Bump v0.13.0+63 * Fix axisSide issue * Linted code --- lib/core/database.dart | 126 +++++++++- lib/tabs/home_tab.dart | 93 ++++--- lib/tabs/overview_page.dart | 354 +++++++++++++++++++++++++++ lib/widgets/charts/chart_widget.dart | 9 +- lib/widgets/settings/settings.dart | 1 - lib/widgets/table/table_widget.dart | 6 +- macos/Runner/AppDelegate.swift | 6 +- pubspec.yaml | 2 +- 8 files changed, 539 insertions(+), 58 deletions(-) create mode 100644 lib/tabs/overview_page.dart diff --git a/lib/core/database.dart b/lib/core/database.dart index 67c4889..cd473ec 100644 --- a/lib/core/database.dart +++ b/lib/core/database.dart @@ -598,12 +598,12 @@ class DatabaseHelper { Future restoreDatabase(BuildContext context) async { try { - String file_path; + String filePath; if (Platform.isIOS) { final directory = await getApplicationDocumentsDirectory(); - file_path = '${directory.path}/backup_database.db'; + filePath = '${directory.path}/backup_database.db'; } else if (Platform.isAndroid) { - file_path = '/storage/emulated/0/Download/backup_database.db'; + filePath = '/storage/emulated/0/Download/backup_database.db'; // Use file picker to select the backup file FilePickerResult? result = await FilePicker.platform.pickFiles( @@ -616,13 +616,13 @@ class DatabaseHelper { return; } - file_path = result.files.single.path!; + filePath = result.files.single.path!; } else { throw Exception('Unsupported platform'); } // Check if the backup file exists - File selectedFile = File(file_path); + File selectedFile = File(filePath); if (!await selectedFile.exists()) { print('File does not exist'); await _showDialog(context, 'Restore Failed', 'File does not exist'); @@ -633,7 +633,7 @@ class DatabaseHelper { String dbPath = await _databasePath(); // Open the backup database - Database backupDb = await openDatabase(file_path); + Database backupDb = await openDatabase(filePath); // Open the destination database Database destinationDb = await openDatabase(dbPath); @@ -676,9 +676,9 @@ class DatabaseHelper { // Close the backup database after transferring records await backupDb.close(); - print('Database restored successfully from $file_path'); + print('Database restored successfully from $filePath'); await _showDialog(context, 'Restore Successful', - 'Database restored successfully from $file_path'); + 'Database restored successfully from $filePath'); } catch (e) { print('Failed to restore database: $e'); await _showDialog( @@ -708,4 +708,114 @@ class DatabaseHelper { }, ); } + + Future getTotalWeightLifted() async { + final db = await database; + final result = await db.rawQuery(''' + SELECT SUM(CAST(weight AS REAL) * reps * sets) as totalWeight + FROM exercises + '''); + return result.isNotEmpty ? result.first['totalWeight'] as double : 0.0; + } + + Future getMostCommonExercise() async { + final db = await database; + final result = await db.rawQuery(''' + SELECT exercise, COUNT(*) as count + FROM exercises + GROUP BY exercise + ORDER BY count DESC + LIMIT 1 + '''); + return result.isNotEmpty ? result.first['exercise'] as String : ''; + } + + Future> getPersonalRecords() async { + final db = await database; + final result = await db.rawQuery(''' + SELECT exercise, MAX(CAST(weight AS REAL)) as maxWeight + FROM exercises + GROUP BY exercise + ORDER BY maxWeight DESC + LIMIT 3 + '''); + + Map personalRecords = {}; + for (var row in result) { + personalRecords[row['exercise'] as String] = row['maxWeight'] as double; + } + + return personalRecords; + } + + Future>> getRecentActivity() async { + final db = await database; + final result = await db.rawQuery(''' + SELECT exercise, weight, reps, sets, timestamp + FROM exercises + ORDER BY timestamp DESC + LIMIT 1 + '''); + return result; + } + + Future>> getWeightProgress() async { + final db = await database; + final result = await db.rawQuery(''' + SELECT timestamp, CAST(weight AS REAL) as weight + FROM fitness + ORDER BY timestamp ASC + '''); + return result; + } + + Future>> getExerciseFrequency() async { + final db = await database; + final result = await db.rawQuery(''' + SELECT date(timestamp) as day, COUNT(*) as count + FROM exercises + GROUP BY day + ORDER BY day ASC + '''); + return result; + } + + Future>> getNewHighScores() async { + final db = await database; + final result = await db.rawQuery(''' + SELECT exercise, MAX(CAST(weight AS REAL)) as maxWeight + FROM exercises + GROUP BY exercise + ORDER BY timestamp DESC + LIMIT 5 + '''); + return result; + } + + Future> getTotalAndAverageTrainingTime() async { + final db = await database; + final result = await db.rawQuery(''' + SELECT date(timestamp) as day, MIN(timestamp) as firstExercise, MAX(timestamp) as lastExercise + FROM exercises + GROUP BY day + '''); + + double totalTrainingTime = 0.0; + int trainingDays = result.length; + + for (var row in result) { + DateTime firstExercise = DateTime.parse(row['firstExercise'] as String); + DateTime lastExercise = DateTime.parse(row['lastExercise'] as String); + totalTrainingTime += lastExercise.difference(firstExercise).inMinutes / + 60.0; // Convert minutes to hours + } + + double averageTrainingTime = + trainingDays > 0 ? totalTrainingTime / trainingDays : 0.0; + + return { + 'totalTrainingTime': totalTrainingTime, + 'averageTrainingTime': averageTrainingTime, + }; + } } diff --git a/lib/tabs/home_tab.dart b/lib/tabs/home_tab.dart index aafe1fa..f0198c3 100644 --- a/lib/tabs/home_tab.dart +++ b/lib/tabs/home_tab.dart @@ -6,6 +6,7 @@ import 'summary_tab.dart'; import '../widgets/settings/settings.dart'; import 'table_tab.dart'; import 'records_tab.dart'; +import 'overview_page.dart'; class ExerciseStoreApp extends StatelessWidget { final AppTheme appTheme; @@ -163,45 +164,63 @@ class ExerciseStoreHomePageState extends State ), ], ), - body: PageStorage( - bucket: bucket, - child: PageView( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - onPageChanged: (index) { - _tabController.animateTo(index); - }, - children: [ - RecordsTab( - isKg: widget.isKg, - bodyweightEnabledGlobal: widget.bodyweightEnabledGlobal), - SummaryTab( - selectedDay: _selectedDay, - onDateSelected: _onDateSelected, - isKg: widget.isKg, - bodyweightEnabledGlobal: widget.bodyweightEnabledGlobal, - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: ExerciseSetter( - isKg: widget.isKg, - onExerciseAdded: () { - setState(() {}); - }, - ), + body: Stack( + children: [ + PageStorage( + bucket: bucket, + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (index) { + _tabController.animateTo(index); + }, + children: [ + RecordsTab( + isKg: widget.isKg, + bodyweightEnabledGlobal: widget.bodyweightEnabledGlobal), + SummaryTab( + selectedDay: _selectedDay, + onDateSelected: _onDateSelected, + isKg: widget.isKg, + bodyweightEnabledGlobal: widget.bodyweightEnabledGlobal, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: ExerciseSetter( + isKg: widget.isKg, + onExerciseAdded: () { + setState(() {}); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: VisualizationTab( + isKg: widget.isKg, + bodyweightEnabledGlobal: widget.bodyweightEnabledGlobal, + defaultAggregationMethod: widget.aggregationMethod, + defaultChartType: widget.plotType, + ), + ), + TableTab(isKg: widget.isKg), + ], ), - Padding( - padding: const EdgeInsets.all(16.0), - child: VisualizationTab( - isKg: widget.isKg, - bodyweightEnabledGlobal: widget.bodyweightEnabledGlobal, - defaultAggregationMethod: widget.aggregationMethod, - defaultChartType: widget.plotType, - ), + ), + Positioned( + bottom: 10.0, + left: 30.0, + child: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => OverviewPage()), + ); + }, + backgroundColor: theme.colorScheme.primary, + child: const Icon(Icons.info_outline, color: Colors.white), ), - TableTab(isKg: widget.isKg), - ], - ), + ), + ], ), bottomNavigationBar: BottomAppBar( child: TabBar( diff --git a/lib/tabs/overview_page.dart b/lib/tabs/overview_page.dart new file mode 100644 index 0000000..c5b8a58 --- /dev/null +++ b/lib/tabs/overview_page.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../core/database.dart'; // Import the database helper + +class OverviewPage extends StatefulWidget { + @override + _OverviewPageState createState() => _OverviewPageState(); +} + +class _OverviewPageState extends State { + late Future> _userProfileData; + late Future> _exerciseSummaryData; + late Future> _weightProgressData; + late Future> _exerciseFrequencyData; + + @override + void initState() { + super.initState(); + _userProfileData = _fetchUserProfileData(); + _exerciseSummaryData = _fetchExerciseSummaryData(); + _weightProgressData = _fetchWeightProgressData(); + _exerciseFrequencyData = _fetchExerciseFrequencyData(); + } + + Future> _fetchUserProfileData() async { + final db = DatabaseHelper(); + final fitnessData = await db.getLastLoggedFitness(); + return fitnessData ?? {}; + } + + Future> _fetchExerciseSummaryData() async { + final db = DatabaseHelper(); + final totalWeightLifted = await db.getTotalWeightLifted(); + final mostCommonExercise = await db.getMostCommonExercise(); + final personalRecords = await db.getPersonalRecords(); + final trainingTimeData = await db.getTotalAndAverageTrainingTime(); + final totalTrainingTime = trainingTimeData['totalTrainingTime']; + final averageTrainingTime = trainingTimeData['averageTrainingTime']; + return { + 'totalWeightLifted': totalWeightLifted, + 'mostCommonExercise': mostCommonExercise, + 'personalRecords': personalRecords, + 'totalTrainingTime': totalTrainingTime, + 'averageTrainingTime': averageTrainingTime, + }; + } + + Future> _fetchWeightProgressData() async { + final db = DatabaseHelper(); + final weightProgress = await db.getWeightProgress(); + return weightProgress + .map((data) => FlSpot( + DateTime.parse(data['timestamp']) + .millisecondsSinceEpoch + .toDouble(), + data['weight'] as double, + )) + .toList(); + } + + Future> _fetchExerciseFrequencyData() async { + final db = DatabaseHelper(); + final exerciseFrequency = await db.getExerciseFrequency(); + return exerciseFrequency + .map((data) => BarChartGroupData( + x: DateTime.parse(data['day']).millisecondsSinceEpoch, + barRods: [ + BarChartRodData( + toY: (data['count'] as int).toDouble(), + color: Theme.of(context).colorScheme.primary) + ], + )) + .toList(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Overview'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User Profile Section + FutureBuilder>( + future: _userProfileData, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } else { + final data = snapshot.data!; + return _buildUserProfileSection(data); + } + }, + ), + const SizedBox(height: 16), + // Fitness Progress Section + FutureBuilder>( + future: _weightProgressData, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } else { + final data = snapshot.data!; + return _buildWeightProgressChart(data); + } + }, + ), + const SizedBox(height: 16), + // Exercise Summary Section + FutureBuilder>( + future: _exerciseSummaryData, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } else { + final data = snapshot.data!; + return _buildExerciseSummarySection(data); + } + }, + ), + const SizedBox(height: 16), + FutureBuilder>( + future: _exerciseFrequencyData, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } else { + final data = snapshot.data!; + return _buildExerciseFrequencyChart(data); + } + }, + ), + const SizedBox(height: 100), + ], + ), + ), + ); + } + + Widget _buildUserProfileSection(Map data) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('User Profile', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('Age: ${data['age']}', style: const TextStyle(fontSize: 18)), + Text('Weight: ${data['weight']} kg', + style: const TextStyle(fontSize: 18)), + Text('Height: ${data['height']} cm', + style: const TextStyle(fontSize: 18)), + ], + ), + ), + ); + } + + Widget _buildExerciseSummarySection(Map data) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Exercise Summary', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('Most Common Exercise: ${data['mostCommonExercise']}', + style: const TextStyle(fontSize: 18)), + const Text('Top 3 heaviest lifts:', style: TextStyle(fontSize: 18)), + ...data['personalRecords'].entries.map((entry) => Text( + ' - ${entry.key}: ${entry.value} kg', + style: const TextStyle(fontSize: 18))), + Text( + 'Total Weight Lifted: ${(data['totalWeightLifted'] / 1000).toStringAsFixed(3)} tons', + style: const TextStyle(fontSize: 18)), + Text( + 'Total Training Time: ${data['totalTrainingTime'].toStringAsFixed(3)} hours', + style: const TextStyle(fontSize: 18)), + Text( + 'Average Training Time: ${data['averageTrainingTime'].toStringAsFixed(3)} hours', + style: const TextStyle(fontSize: 18)), + ], + ), + ), + ); + } + + Widget _buildWeightProgressChart(List data) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center( + child: Text('Weight Progress', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold))), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0), // Add padding to the left and right + child: SizedBox( + height: 200, + child: LineChart( + LineChartData( + gridData: const FlGridData(show: true), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, // Add more space for y-axis labels + getTitlesWidget: (double value, TitleMeta meta) { + return Text( + value.toString(), + style: const TextStyle(fontSize: 12), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, // Add more space for x-axis labels + interval: + 7 * 24 * 60 * 60 * 1000, // Show titles every week + getTitlesWidget: (double value, TitleMeta meta) { + final DateTime date = + DateTime.fromMillisecondsSinceEpoch(value.toInt()); + return Padding( + padding: const EdgeInsets.only( + top: + 16.0), // Add more padding between x-axis and labels + child: Transform.rotate( + angle: -60 * + (3.141592653589793 / + 180), // Rotate by -60 degrees + child: Text('${date.month}/${date.day}'), + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: true), + lineBarsData: [ + LineChartBarData( + spots: data, + isCurved: true, + color: Theme.of(context).colorScheme.primary, + barWidth: 4, + belowBarData: BarAreaData( + show: true, + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.3)), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildExerciseFrequencyChart(List data) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center( + child: Text('Exercise Frequency', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold))), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0), // Add padding to the left and right + child: SizedBox( + height: 200, + child: BarChart( + BarChartData( + gridData: const FlGridData(show: true), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, // Add more space for y-axis labels + getTitlesWidget: (double value, TitleMeta meta) { + return Text( + value.toString(), + style: const TextStyle(fontSize: 12), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, // Add more space for x-axis labels + interval: + 7 * 24 * 60 * 60 * 1000, // Show titles every week + getTitlesWidget: (double value, TitleMeta meta) { + final DateTime date = + DateTime.fromMillisecondsSinceEpoch(value.toInt()); + return Padding( + padding: const EdgeInsets.only( + top: + 16.0), // Add more padding between x-axis and labels + child: Transform.rotate( + angle: -60 * + (3.141592653589793 / + 180), // Rotate by -60 degrees + child: Text('${date.month}/${date.day}'), + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: true), + barGroups: data, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/charts/chart_widget.dart b/lib/widgets/charts/chart_widget.dart index baca88d..205b46a 100644 --- a/lib/widgets/charts/chart_widget.dart +++ b/lib/widgets/charts/chart_widget.dart @@ -129,7 +129,7 @@ class ChartWidget extends StatelessWidget { return touchedSpots.map((touchedSpot) { return LineTooltipItem( _formatTooltip(touchedSpot.x, touchedSpot.y), - TextStyle(color: Colors.white), + const TextStyle(color: Colors.white), ); }).toList(); }, @@ -235,7 +235,7 @@ class ChartWidget extends StatelessWidget { getTooltipItems: (ScatterSpot touchedSpot) { return ScatterTooltipItem( _formatTooltip(touchedSpot.x, touchedSpot.y), - textStyle: TextStyle(color: Colors.white), + textStyle: const TextStyle(color: Colors.white), ); }, ), @@ -251,9 +251,8 @@ class ChartWidget extends StatelessWidget { ); final date = DateTime.now().add(Duration(days: value.toInt())); final formattedDate = DateFormat('dd/MM').format(date); - return SideTitleWidget( - axisSide: meta.axisSide, - space: 8.0, // Increased space to move the labels downwards + return Padding( + padding: const EdgeInsets.only(top: 8.0), child: Transform.rotate( angle: -45 * 3.1415927 / 180, child: Text(formattedDate, style: style), diff --git a/lib/widgets/settings/settings.dart b/lib/widgets/settings/settings.dart index 525fb81..522fa98 100644 --- a/lib/widgets/settings/settings.dart +++ b/lib/widgets/settings/settings.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'dart:io'; import '../../core/database.dart'; import '../../core/theme/app_themes.dart'; diff --git a/lib/widgets/table/table_widget.dart b/lib/widgets/table/table_widget.dart index c2e28d0..c06481d 100644 --- a/lib/widgets/table/table_widget.dart +++ b/lib/widgets/table/table_widget.dart @@ -35,12 +35,10 @@ class TableWidgetState extends State { final int _limit = 50; bool _isLoading = false; bool _hasMoreData = true; - bool _isSyncing = false; + final bool _isSyncing = false; ScrollController? _activeRowController; final List _rowControllers = []; String _searchQuery = ''; // Add search query state - double _globalHorizontalScrollPosition = - 0.0; // Global horizontal scroll position @override void initState() { @@ -48,8 +46,6 @@ class TableWidgetState extends State { _verticalScrollController .addListener(() => onScroll(_verticalScrollController, _loadNextChunk)); _horizontalScrollController.addListener(() { - _globalHorizontalScrollPosition = - _horizontalScrollController.position.pixels; onHorizontalScroll(_horizontalScrollController, _rowControllers, _isSyncing, _activeRowController); }); diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index d53ef64..b3c1761 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/pubspec.yaml b/pubspec.yaml index 4e4b2e5..b60c257 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: IronFlow description: "Strength training and fitness app for mobile devices." publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 0.12.2+62 +version: 0.13.0+63 environment: sdk: '>=3.4.3 <4.0.0'