mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 15:43:02 +02:00
feat: overhaul workout app with progress tracking and UX improvements
- Remove automatic rest timer after each set - Add inline threshold controls (success/fail streaks) on each exercise card during an active workout - Settings: auto-save on change with 600 ms debounce; replace Save button with Reset to defaults - Fix weight display asymmetry in settings (fixed 72 px width, centred) - Progress screen (renamed from History): per-exercise view shows streak counters, weight chart (kg Y-axis, date X-axis, rolling-2 avg for total volume), exercise-filtered calendar, and per-exercise session tiles - Total view shows rolling-2-session average volume chart + full calendar + all-session list - Add WorkoutCalendar widget with monthly navigation - Store warmupDone in ExerciseResult JSON; surface warmup per session tile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
735f900bf8
commit
d8062a601f
4
CLAUDE.md
Normal file
4
CLAUDE.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
do NOT run tests unless specifically instructed to do so or before committing
|
||||||
|
If tests fail on the same issue twice in a row, STOP and ask the user how to proceed instead of continuing to fix and retry.
|
||||||
|
ALWAYS confirm that the feature you add / bug you fixed behaves as it should by running the program after your changes (not tests!) and inspecting output comparing it with what user wanted, after confirming by yourself ask user if the program behaves as they intended
|
||||||
|
After running tests fix all coverage gaps and issues, do not ignore unless specifically instructed to do so
|
||||||
@ -8,10 +8,12 @@ class ExerciseResult {
|
|||||||
const ExerciseResult({
|
const ExerciseResult({
|
||||||
required this.exercise,
|
required this.exercise,
|
||||||
required this.sets,
|
required this.sets,
|
||||||
|
this.warmupDone = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Exercise exercise;
|
final Exercise exercise;
|
||||||
final List<SetResult> sets;
|
final List<SetResult> sets;
|
||||||
|
final bool warmupDone;
|
||||||
|
|
||||||
/// True when every set was fully completed.
|
/// True when every set was fully completed.
|
||||||
bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded);
|
bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded);
|
||||||
@ -21,6 +23,7 @@ class ExerciseResult {
|
|||||||
'targetSets': exercise.sets,
|
'targetSets': exercise.sets,
|
||||||
'targetReps': exercise.reps,
|
'targetReps': exercise.reps,
|
||||||
'targetWeight': exercise.weight,
|
'targetWeight': exercise.weight,
|
||||||
|
'warmupDone': warmupDone,
|
||||||
'sets': sets.map((s) => s.toJson()).toList(),
|
'sets': sets.map((s) => s.toJson()).toList(),
|
||||||
'succeeded': succeeded,
|
'succeeded': succeeded,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
/// History screen: past workout list with per-exercise weight progress chart.
|
/// Progress screen: total-load view plus per-exercise drill-down.
|
||||||
|
///
|
||||||
|
/// "Total" (default): total-volume chart, full calendar, all sessions.
|
||||||
|
/// Specific exercise: streak card, weight chart, exercise-only calendar,
|
||||||
|
/// exercise-only session list.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:workout_app/models/exercise.dart';
|
||||||
import 'package:workout_app/services/storage_service.dart';
|
import 'package:workout_app/services/storage_service.dart';
|
||||||
|
import 'package:workout_app/widgets/calendar_widget.dart';
|
||||||
|
|
||||||
|
const _kTotal = 'Total (all workouts)';
|
||||||
|
|
||||||
class HistoryScreen extends StatefulWidget {
|
class HistoryScreen extends StatefulWidget {
|
||||||
const HistoryScreen({super.key});
|
const HistoryScreen({super.key});
|
||||||
@ -16,8 +24,11 @@ class HistoryScreen extends StatefulWidget {
|
|||||||
class _HistoryScreenState extends State<HistoryScreen> {
|
class _HistoryScreenState extends State<HistoryScreen> {
|
||||||
List<Map<String, dynamic>> _rows = [];
|
List<Map<String, dynamic>> _rows = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _selectedExercise;
|
String _selected = _kTotal;
|
||||||
List<String> _exerciseNames = [];
|
List<String> _exerciseNames = [];
|
||||||
|
ExerciseState? _selectedState;
|
||||||
|
DateTime _calendarMonth =
|
||||||
|
DateTime(DateTime.now().year, DateTime.now().month);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -26,59 +37,134 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final rows = await StorageService.instance.getWorkoutHistory();
|
final rows = await StorageService.instance.getWorkoutHistory(limit: 200);
|
||||||
final names = <String>{};
|
final names = <String>[];
|
||||||
|
final seen = <String>{};
|
||||||
for (final row in rows) {
|
for (final row in rows) {
|
||||||
final json =
|
final json =
|
||||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
for (final ex in (json['exercises'] as List)) {
|
for (final ex in (json['exercises'] as List)) {
|
||||||
names.add((ex as Map<String, dynamic>)['name'] as String);
|
final name = (ex as Map<String, dynamic>)['name'] as String;
|
||||||
|
if (seen.add(name)) names.add(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ExerciseState? state;
|
||||||
|
if (_selected != _kTotal) {
|
||||||
|
state = await StorageService.instance.getExerciseState(_selected);
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_rows = rows;
|
_rows = rows;
|
||||||
_exerciseNames = names.toList();
|
_exerciseNames = names;
|
||||||
_selectedExercise =
|
_selectedState = state;
|
||||||
_exerciseNames.isNotEmpty ? _exerciseNames.first : null;
|
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(int secs) {
|
Future<void> _pickExercise(String name) async {
|
||||||
final m = (secs ~/ 60).toString().padLeft(2, '0');
|
ExerciseState? state;
|
||||||
final s = (secs % 60).toString().padLeft(2, '0');
|
if (name != _kTotal) {
|
||||||
return '${secs ~/ 3600 > 0 ? '${secs ~/ 3600}h ' : ''}${m}m ${s}s';
|
state = await StorageService.instance.getExerciseState(name);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_selected = name;
|
||||||
|
_selectedState = state;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract (date, weight) points for the selected exercise from history.
|
// ── Data helpers ────────────────────────────────────────────────────────────
|
||||||
List<(DateTime, double)> _buildChartPoints(String exerciseName) {
|
|
||||||
|
/// All workout dates (YYYY-MM-DD) across all sessions.
|
||||||
|
Set<String> get _allWorkoutDates =>
|
||||||
|
_rows.map((r) => r['date'] as String).toSet();
|
||||||
|
|
||||||
|
/// Dates when the selected exercise appeared.
|
||||||
|
Set<String> _exerciseDates(String name) {
|
||||||
|
final result = <String>{};
|
||||||
|
for (final row in _rows) {
|
||||||
|
final json =
|
||||||
|
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
|
for (final ex in (json['exercises'] as List)) {
|
||||||
|
if ((ex as Map<String, dynamic>)['name'] == name) {
|
||||||
|
result.add(row['date'] as String);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (date, total volume) per session – sum of targetWeight × targetSets.
|
||||||
|
List<(DateTime, double)> _totalVolumePoints() {
|
||||||
|
final points = <(DateTime, double)>[];
|
||||||
|
for (final row in _rows.reversed) {
|
||||||
|
final json =
|
||||||
|
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
|
double total = 0;
|
||||||
|
for (final ex in (json['exercises'] as List)) {
|
||||||
|
final m = ex as Map<String, dynamic>;
|
||||||
|
final w = (m['targetWeight'] as num?)?.toDouble() ?? 0;
|
||||||
|
final s = (m['targetSets'] as num?)?.toInt() ?? 0;
|
||||||
|
final r = (m['targetReps'] as num?)?.toInt() ?? 0;
|
||||||
|
total += w * s * r;
|
||||||
|
}
|
||||||
|
final date = DateTime.tryParse(row['date'] as String);
|
||||||
|
if (date != null) points.add((date, total));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (date, weight) for the selected exercise.
|
||||||
|
List<(DateTime, double)> _exerciseWeightPoints(String name) {
|
||||||
final points = <(DateTime, double)>[];
|
final points = <(DateTime, double)>[];
|
||||||
for (final row in _rows.reversed) {
|
for (final row in _rows.reversed) {
|
||||||
final json =
|
final json =
|
||||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
for (final ex in (json['exercises'] as List)) {
|
for (final ex in (json['exercises'] as List)) {
|
||||||
final m = ex as Map<String, dynamic>;
|
final m = ex as Map<String, dynamic>;
|
||||||
if (m['name'] == exerciseName) {
|
if (m['name'] == name) {
|
||||||
final date = DateTime.tryParse(row['date'] as String);
|
final date = DateTime.tryParse(row['date'] as String);
|
||||||
final weight = (m['targetWeight'] as num?)?.toDouble();
|
final w = (m['targetWeight'] as num?)?.toDouble();
|
||||||
if (date != null && weight != null) {
|
if (date != null && w != null) points.add((date, w));
|
||||||
points.add((date, weight));
|
break;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sessions filtered to those containing the selected exercise, newest first.
|
||||||
|
List<Map<String, dynamic>> _sessionsForExercise(String name) {
|
||||||
|
final result = <Map<String, dynamic>>[];
|
||||||
|
for (final row in _rows) {
|
||||||
|
final json =
|
||||||
|
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
|
for (final ex in (json['exercises'] as List)) {
|
||||||
|
final m = ex as Map<String, dynamic>;
|
||||||
|
if (m['name'] == name) {
|
||||||
|
result.add({...row, 'exerciseData': m});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final allNames = [_kTotal, ..._exerciseNames];
|
||||||
|
final isTotal = _selected == _kTotal;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.grey.shade900,
|
backgroundColor: Colors.grey.shade900,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.grey.shade800,
|
backgroundColor: Colors.grey.shade800,
|
||||||
title: const Text('History', style: TextStyle(color: Colors.white)),
|
title: const Text('Progress', style: TextStyle(color: Colors.white)),
|
||||||
iconTheme: const IconThemeData(color: Colors.white),
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
),
|
),
|
||||||
body: _loading
|
body: _loading
|
||||||
@ -93,39 +179,113 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
if (_selectedExercise != null) ...[
|
|
||||||
_ExercisePicker(
|
_ExercisePicker(
|
||||||
names: _exerciseNames,
|
names: allNames,
|
||||||
selected: _selectedExercise!,
|
selected: _selected,
|
||||||
onChanged: (v) =>
|
onChanged: _pickExercise,
|
||||||
setState(() => _selectedExercise = v),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
_WeightChart(
|
if (isTotal) ..._buildTotalView()
|
||||||
points: _buildChartPoints(_selectedExercise!),
|
else ..._buildExerciseView(_selected),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
const Text(
|
|
||||||
'SESSIONS',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white54,
|
|
||||||
fontSize: 11,
|
|
||||||
letterSpacing: 1.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
..._rows.map((row) => _SessionTile(
|
|
||||||
row: row,
|
|
||||||
formatDuration: _formatDuration,
|
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rolling average of 2 consecutive points to smooth A/B alternation.
|
||||||
|
static List<(DateTime, double)> _rollingAvg2(
|
||||||
|
List<(DateTime, double)> pts,
|
||||||
|
) {
|
||||||
|
if (pts.length < 2) return pts;
|
||||||
|
return [
|
||||||
|
for (int i = 0; i < pts.length; i++)
|
||||||
|
(pts[i].$1, i == 0 ? pts[0].$2 : (pts[i].$2 + pts[i - 1].$2) / 2),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sub-widgets ────────────────────────────────────────────────────────────────
|
List<Widget> _buildTotalView() => [
|
||||||
|
_SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_WeightChart(
|
||||||
|
points: _rollingAvg2(_totalVolumePoints()),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
WorkoutCalendar(
|
||||||
|
workoutDates: _allWorkoutDates,
|
||||||
|
month: _calendarMonth,
|
||||||
|
onPrevMonth: () => setState(() {
|
||||||
|
_calendarMonth = DateTime(
|
||||||
|
_calendarMonth.year,
|
||||||
|
_calendarMonth.month - 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
onNextMonth: () => setState(() {
|
||||||
|
_calendarMonth = DateTime(
|
||||||
|
_calendarMonth.year,
|
||||||
|
_calendarMonth.month + 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_SectionLabel('ALL SESSIONS'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._rows.map((row) => _AllSessionTile(row: row)),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Widget> _buildExerciseView(String name) => [
|
||||||
|
if (_selectedState != null) ...[
|
||||||
|
_ProgressStatsCard(state: _selectedState!),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
_SectionLabel('WEIGHT OVER TIME'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_WeightChart(
|
||||||
|
points: _exerciseWeightPoints(name),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
WorkoutCalendar(
|
||||||
|
workoutDates: _exerciseDates(name),
|
||||||
|
month: _calendarMonth,
|
||||||
|
onPrevMonth: () => setState(() {
|
||||||
|
_calendarMonth = DateTime(
|
||||||
|
_calendarMonth.year,
|
||||||
|
_calendarMonth.month - 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
onNextMonth: () => setState(() {
|
||||||
|
_calendarMonth = DateTime(
|
||||||
|
_calendarMonth.year,
|
||||||
|
_calendarMonth.month + 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_SectionLabel(name.toUpperCase()),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._sessionsForExercise(name)
|
||||||
|
.map((s) => _ExerciseSessionTile(session: s)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared sub-widgets ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _SectionLabel extends StatelessWidget {
|
||||||
|
const _SectionLabel(this.text);
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white54,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 1.3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ExercisePicker extends StatelessWidget {
|
class _ExercisePicker extends StatelessWidget {
|
||||||
const _ExercisePicker({
|
const _ExercisePicker({
|
||||||
@ -150,7 +310,14 @@ class _ExercisePicker extends StatelessWidget {
|
|||||||
.map(
|
.map(
|
||||||
(n) => DropdownMenuItem(
|
(n) => DropdownMenuItem(
|
||||||
value: n,
|
value: n,
|
||||||
child: Text(n, style: const TextStyle(color: Colors.white)),
|
child: Text(
|
||||||
|
n,
|
||||||
|
style: TextStyle(
|
||||||
|
color: n == _kTotal ? Colors.white70 : Colors.white,
|
||||||
|
fontStyle:
|
||||||
|
n == _kTotal ? FontStyle.italic : FontStyle.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
@ -161,6 +328,121 @@ class _ExercisePicker extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ProgressStatsCard extends StatelessWidget {
|
||||||
|
const _ProgressStatsCard({required this.state});
|
||||||
|
|
||||||
|
final ExerciseState state;
|
||||||
|
|
||||||
|
String _nextWeightLabel(double current, double max, double inc) {
|
||||||
|
if (current >= max) return '+1 rep';
|
||||||
|
return '+${inc}kg (${(current + inc).clamp(0.0, max)}kg)';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _prevWeightLabel(double current, double inc) {
|
||||||
|
return '-${inc}kg (${(current - inc).clamp(0.0, double.infinity)}kg)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final successLeft = state.successThreshold - state.successStreak;
|
||||||
|
final failLeft = state.failThreshold - state.failStreak;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${state.name} — ${state.weight}kg',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_StreakRow(
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
color: Colors.greenAccent,
|
||||||
|
current: state.successStreak,
|
||||||
|
threshold: state.successThreshold,
|
||||||
|
leftLabel: '$successLeft more',
|
||||||
|
actionLabel: _nextWeightLabel(
|
||||||
|
state.weight,
|
||||||
|
state.maxWeight,
|
||||||
|
kWeightIncrement,
|
||||||
|
),
|
||||||
|
direction: '↑',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_StreakRow(
|
||||||
|
icon: Icons.trending_down,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
current: state.failStreak,
|
||||||
|
threshold: state.failThreshold,
|
||||||
|
leftLabel: '$failLeft more',
|
||||||
|
actionLabel: _prevWeightLabel(state.weight, kWeightIncrement),
|
||||||
|
direction: '↓',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StreakRow extends StatelessWidget {
|
||||||
|
const _StreakRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.current,
|
||||||
|
required this.threshold,
|
||||||
|
required this.leftLabel,
|
||||||
|
required this.actionLabel,
|
||||||
|
required this.direction,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final int current;
|
||||||
|
final int threshold;
|
||||||
|
final String leftLabel;
|
||||||
|
final String actionLabel;
|
||||||
|
final String direction;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
...List.generate(
|
||||||
|
threshold,
|
||||||
|
(i) => Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(right: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: i < current ? color : Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$leftLabel to $direction $actionLabel',
|
||||||
|
style: const TextStyle(color: Colors.white60, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _WeightChart extends StatelessWidget {
|
class _WeightChart extends StatelessWidget {
|
||||||
const _WeightChart({required this.points});
|
const _WeightChart({required this.points});
|
||||||
|
|
||||||
@ -170,16 +452,16 @@ class _WeightChart extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (points.length < 2) {
|
if (points.length < 2) {
|
||||||
return Container(
|
return Container(
|
||||||
height: 100,
|
height: 80,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Not enough data',
|
'Not enough data for chart',
|
||||||
style: TextStyle(color: Colors.white38),
|
style: TextStyle(color: Colors.white38),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 120,
|
height: 140,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _ChartPainter(points),
|
painter: _ChartPainter(points),
|
||||||
size: Size.infinite,
|
size: Size.infinite,
|
||||||
@ -193,6 +475,18 @@ class _ChartPainter extends CustomPainter {
|
|||||||
|
|
||||||
final List<(DateTime, double)> points;
|
final List<(DateTime, double)> points;
|
||||||
|
|
||||||
|
// Layout constants
|
||||||
|
static const _topPad = 14.0; // room for top Y label
|
||||||
|
static const _bottomPad = 22.0; // room for X-axis dates
|
||||||
|
static const _hPad = 8.0;
|
||||||
|
|
||||||
|
static const _months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
|
||||||
|
static String _shortDate(DateTime d) => '${_months[d.month - 1]} ${d.day}';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final minW = points.map((p) => p.$2).reduce(min);
|
final minW = points.map((p) => p.$2).reduce(min);
|
||||||
@ -202,12 +496,19 @@ class _ChartPainter extends CustomPainter {
|
|||||||
final wRange = maxW - minW;
|
final wRange = maxW - minW;
|
||||||
final tRange = maxMs - minMs;
|
final tRange = maxMs - minMs;
|
||||||
|
|
||||||
double xOf(DateTime t) =>
|
final plotTop = _topPad;
|
||||||
tRange == 0 ? size.width / 2 :
|
final plotBottom = size.height - _bottomPad;
|
||||||
(t.millisecondsSinceEpoch - minMs) / tRange * (size.width - 16) + 8;
|
final plotLeft = _hPad;
|
||||||
double yOf(double w) =>
|
final plotRight = size.width - _hPad;
|
||||||
wRange == 0 ? size.height / 2 :
|
final plotHeight = plotBottom - plotTop;
|
||||||
(1 - (w - minW) / wRange) * (size.height - 16) + 8;
|
final plotWidth = plotRight - plotLeft;
|
||||||
|
|
||||||
|
double xOf(DateTime t) => tRange == 0
|
||||||
|
? (plotLeft + plotRight) / 2
|
||||||
|
: (t.millisecondsSinceEpoch - minMs) / tRange * plotWidth + plotLeft;
|
||||||
|
double yOf(double w) => wRange == 0
|
||||||
|
? (plotTop + plotBottom) / 2
|
||||||
|
: (1 - (w - minW) / wRange) * plotHeight + plotTop;
|
||||||
|
|
||||||
final linePaint = Paint()
|
final linePaint = Paint()
|
||||||
..color = Colors.indigoAccent
|
..color = Colors.indigoAccent
|
||||||
@ -223,45 +524,65 @@ class _ChartPainter extends CustomPainter {
|
|||||||
path.lineTo(xOf(p.$1), yOf(p.$2));
|
path.lineTo(xOf(p.$1), yOf(p.$2));
|
||||||
}
|
}
|
||||||
canvas.drawPath(path, linePaint);
|
canvas.drawPath(path, linePaint);
|
||||||
|
|
||||||
for (final p in points) {
|
for (final p in points) {
|
||||||
canvas.drawCircle(Offset(xOf(p.$1), yOf(p.$2)), 4, dotPaint);
|
canvas.drawCircle(Offset(xOf(p.$1), yOf(p.$2)), 4, dotPaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label min/max weight
|
// Y-axis labels
|
||||||
final tp = TextPainter(textDirection: TextDirection.ltr);
|
final tp = TextPainter(textDirection: TextDirection.ltr);
|
||||||
void drawLabel(String text, Offset offset) {
|
void drawText(String text, Offset offset, {double fontSize = 10}) {
|
||||||
tp
|
tp
|
||||||
..text = TextSpan(
|
..text = TextSpan(
|
||||||
text: text,
|
text: text,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 10),
|
style: TextStyle(color: Colors.white54, fontSize: fontSize),
|
||||||
)
|
)
|
||||||
..layout()
|
..layout()
|
||||||
..paint(canvas, offset);
|
..paint(canvas, offset);
|
||||||
}
|
}
|
||||||
drawLabel('${maxW}kg', Offset(8, 0));
|
|
||||||
drawLabel('${minW}kg', Offset(8, size.height - 14));
|
drawText('${maxW.round()}kg', Offset(plotLeft, 0));
|
||||||
|
drawText('${minW.round()}kg', Offset(plotLeft, plotBottom + 2));
|
||||||
|
|
||||||
|
// X-axis date labels: first, middle, last
|
||||||
|
final n = points.length;
|
||||||
|
final xIndices = n <= 2 ? [0, n - 1] : [0, n ~/ 2, n - 1];
|
||||||
|
for (final i in xIndices) {
|
||||||
|
final p = points[i];
|
||||||
|
final label = _shortDate(p.$1);
|
||||||
|
tp
|
||||||
|
..text = TextSpan(
|
||||||
|
text: label,
|
||||||
|
style: const TextStyle(color: Colors.white38, fontSize: 9),
|
||||||
|
)
|
||||||
|
..layout();
|
||||||
|
final cx = xOf(p.$1);
|
||||||
|
final dx = (cx - tp.width / 2).clamp(plotLeft, plotRight - tp.width);
|
||||||
|
tp.paint(canvas, Offset(dx, size.height - tp.height));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_ChartPainter old) => old.points != points;
|
bool shouldRepaint(_ChartPainter old) => old.points != points;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SessionTile extends StatelessWidget {
|
class _AllSessionTile extends StatelessWidget {
|
||||||
const _SessionTile({
|
const _AllSessionTile({required this.row});
|
||||||
required this.row,
|
|
||||||
required this.formatDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Map<String, dynamic> row;
|
final Map<String, dynamic> row;
|
||||||
final String Function(int) formatDuration;
|
|
||||||
|
String _formatDuration(int secs) {
|
||||||
|
final h = secs ~/ 3600;
|
||||||
|
final m = (secs ~/ 60).remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = (secs % 60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '${h}h ${m}m ${s}s' : '${m}m ${s}s';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final succeeded = (row['succeeded'] as int) == 1;
|
final succeeded = (row['succeeded'] as int) == 1;
|
||||||
final type = row['workout_type'] as String;
|
final type = row['workout_type'] as String;
|
||||||
final date = row['date'] as String;
|
final date = row['date'] as String;
|
||||||
final dur = formatDuration(row['duration_seconds'] as int);
|
final dur = _formatDuration(row['duration_seconds'] as int);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
@ -295,7 +616,104 @@ class _SessionTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
dur,
|
dur,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style:
|
||||||
|
const TextStyle(color: Colors.white54, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExerciseSessionTile extends StatelessWidget {
|
||||||
|
const _ExerciseSessionTile({required this.session});
|
||||||
|
|
||||||
|
final Map<String, dynamic> session;
|
||||||
|
|
||||||
|
String _formatDuration(int secs) {
|
||||||
|
final h = secs ~/ 3600;
|
||||||
|
final m = (secs ~/ 60).remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = (secs % 60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '${h}h ${m}m ${s}s' : '${m}m ${s}s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final exData = session['exerciseData'] as Map<String, dynamic>;
|
||||||
|
final succeeded = (exData['succeeded'] as bool?) == true;
|
||||||
|
final date = session['date'] as String;
|
||||||
|
final dur = _formatDuration(session['duration_seconds'] as int);
|
||||||
|
final weight = (exData['targetWeight'] as num?)?.toDouble();
|
||||||
|
final warmupDone = exData['warmupDone'] as bool? ?? false;
|
||||||
|
final sets =
|
||||||
|
(exData['sets'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
final targetSets = exData['targetSets'] as int? ?? sets.length;
|
||||||
|
final doneSets = sets.where((s) => s['succeeded'] == true).length;
|
||||||
|
final repsSummary = sets.map((s) => '${s['doneReps']}').join(', ');
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
succeeded ? Icons.check_circle : Icons.cancel,
|
||||||
|
color: succeeded ? Colors.greenAccent : Colors.redAccent,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
date,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${weight ?? '?'}kg · $doneSets/$targetSets sets'
|
||||||
|
' · ${warmupDone ? '⬤ warmup' : '○ no warmup'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (repsSummary.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'reps: $repsSummary',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white54,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'workout: $dur',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
/// Settings screen: per-exercise streak thresholds and manual weight overrides.
|
/// Settings screen: per-exercise streak thresholds and manual weight overrides.
|
||||||
|
/// Changes are saved immediately; a "Reset to defaults" button reverts all.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:workout_app/models/exercise.dart';
|
import 'package:workout_app/models/exercise.dart';
|
||||||
import 'package:workout_app/models/workout_plan.dart';
|
import 'package:workout_app/models/workout_plan.dart';
|
||||||
@ -16,18 +18,28 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
List<ExerciseState>? _states;
|
List<ExerciseState>? _states;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _saving = false;
|
|
||||||
|
|
||||||
final Map<String, int> _successThresholds = {};
|
final Map<String, int> _successThresholds = {};
|
||||||
final Map<String, int> _failThresholds = {};
|
final Map<String, int> _failThresholds = {};
|
||||||
final Map<String, double> _weights = {};
|
final Map<String, double> _weights = {};
|
||||||
|
|
||||||
|
// Debounce weight saves to avoid resetting streaks on every tap.
|
||||||
|
final Map<String, Timer> _weightTimers = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final t in _weightTimers.values) {
|
||||||
|
t.cancel();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final states = await StorageService.instance.getAllExerciseStates();
|
final states = await StorageService.instance.getAllExerciseStates();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@ -43,21 +55,60 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
void _onWeightChanged(String name, double value) {
|
||||||
setState(() => _saving = true);
|
setState(() => _weights[name] = value);
|
||||||
final storage = StorageService.instance;
|
_weightTimers[name]?.cancel();
|
||||||
for (final s in _states!) {
|
_weightTimers[name] = Timer(const Duration(milliseconds: 600), () {
|
||||||
await storage.setExerciseThresholds(
|
StorageService.instance.setExerciseWeight(name, value);
|
||||||
s.name,
|
});
|
||||||
successThreshold: _successThresholds[s.name]!,
|
}
|
||||||
failThreshold: _failThresholds[s.name]!,
|
|
||||||
|
Future<void> _onThresholdChanged(String name, int success, int fail) async {
|
||||||
|
setState(() {
|
||||||
|
_successThresholds[name] = success;
|
||||||
|
_failThresholds[name] = fail;
|
||||||
|
});
|
||||||
|
await StorageService.instance.setExerciseThresholds(
|
||||||
|
name,
|
||||||
|
successThreshold: success,
|
||||||
|
failThreshold: fail,
|
||||||
);
|
);
|
||||||
final newWeight = _weights[s.name] ?? s.weight;
|
|
||||||
if ((newWeight - s.weight).abs() > 0.001) {
|
|
||||||
await storage.setExerciseWeight(s.name, newWeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _resetToDefaults() async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
backgroundColor: Colors.grey.shade900,
|
||||||
|
title: const Text(
|
||||||
|
'Reset to defaults?',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'All weights and thresholds will be reset. '
|
||||||
|
'Streak counters will be cleared.',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child:
|
||||||
|
const Text('Cancel', style: TextStyle(color: Colors.white70)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child:
|
||||||
|
const Text('Reset', style: TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
for (final name in _orderedNames) {
|
||||||
|
await StorageService.instance.resetExerciseToDefaults(name);
|
||||||
|
}
|
||||||
|
await _load();
|
||||||
}
|
}
|
||||||
if (mounted) Navigator.of(context).pop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> get _orderedNames {
|
List<String> get _orderedNames {
|
||||||
@ -78,8 +129,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
iconTheme: const IconThemeData(color: Colors.white),
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: (_loading || _saving) ? null : _save,
|
onPressed: _loading ? null : _resetToDefaults,
|
||||||
child: const Text('Save', style: TextStyle(color: Colors.white)),
|
child: const Text(
|
||||||
|
'Reset defaults',
|
||||||
|
style: TextStyle(color: Colors.redAccent),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -102,7 +156,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return _WeightRow(
|
return _WeightRow(
|
||||||
name: name,
|
name: name,
|
||||||
weight: w,
|
weight: w,
|
||||||
onChanged: (v) => setState(() => _weights[name] = v),
|
onChanged: (v) => _onWeightChanged(name, v),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
@ -122,9 +176,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
successThreshold: sThresh,
|
successThreshold: sThresh,
|
||||||
failThreshold: fThresh,
|
failThreshold: fThresh,
|
||||||
onSuccessChanged: (v) =>
|
onSuccessChanged: (v) =>
|
||||||
setState(() => _successThresholds[name] = v),
|
_onThresholdChanged(name, v, _failThresholds[name]!),
|
||||||
onFailChanged: (v) =>
|
onFailChanged: (v) =>
|
||||||
setState(() => _failThresholds[name] = v),
|
_onThresholdChanged(name, _successThresholds[name]!, v),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@ -180,10 +234,12 @@ class _WeightRow extends StatelessWidget {
|
|||||||
(weight - kWeightIncrement).clamp(0.0, 999.0),
|
(weight - kWeightIncrement).clamp(0.0, 999.0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
// Fixed-width container supports up to "999.9kg" (7 chars).
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
child: Text(
|
child: Text(
|
||||||
'${weight}kg',
|
'${weight}kg',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@ -1,25 +1,18 @@
|
|||||||
/// Active workout screen: per-rep breaks, warmup, back-button protection,
|
/// Active workout screen: per-rep tracking, warmup, back-button protection,
|
||||||
/// and crash-safe session persistence.
|
/// and crash-safe session persistence.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
|
||||||
import 'package:vibration/vibration.dart';
|
|
||||||
import 'package:workout_app/models/exercise.dart';
|
import 'package:workout_app/models/exercise.dart';
|
||||||
import 'package:workout_app/models/exercise_result.dart';
|
import 'package:workout_app/models/exercise_result.dart';
|
||||||
import 'package:workout_app/models/set_result.dart';
|
import 'package:workout_app/models/set_result.dart';
|
||||||
import 'package:workout_app/models/workout_session.dart';
|
import 'package:workout_app/models/workout_session.dart';
|
||||||
import 'package:workout_app/services/storage_service.dart';
|
import 'package:workout_app/services/storage_service.dart';
|
||||||
import 'package:workout_app/services/sync_service.dart';
|
import 'package:workout_app/services/sync_service.dart';
|
||||||
import 'package:workout_app/widgets/break_banner.dart';
|
|
||||||
import 'package:workout_app/widgets/exercise_tile.dart';
|
import 'package:workout_app/widgets/exercise_tile.dart';
|
||||||
import 'package:workout_app/widgets/workout_summary_dialog.dart';
|
import 'package:workout_app/widgets/workout_summary_dialog.dart';
|
||||||
|
|
||||||
const _successBreakSecs = 180; // 3 min after successful rep
|
|
||||||
const _failBreakSecs = 300; // 5 min after failed rep
|
|
||||||
const _warmupBreakSecs = 180; // 3 min after warmup
|
|
||||||
|
|
||||||
class WorkoutScreen extends StatefulWidget {
|
class WorkoutScreen extends StatefulWidget {
|
||||||
const WorkoutScreen({
|
const WorkoutScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@ -30,8 +23,6 @@ class WorkoutScreen extends StatefulWidget {
|
|||||||
|
|
||||||
final String workoutType;
|
final String workoutType;
|
||||||
final List<Exercise> exercises;
|
final List<Exercise> exercises;
|
||||||
|
|
||||||
/// Non-null when resuming a previously interrupted session.
|
|
||||||
final Map<String, dynamic>? savedState;
|
final Map<String, dynamic>? savedState;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -46,18 +37,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
late Timer _elapsedTimer;
|
late Timer _elapsedTimer;
|
||||||
Duration _elapsed = Duration.zero;
|
Duration _elapsed = Duration.zero;
|
||||||
|
|
||||||
// Break state
|
Map<String, ExerciseState> _exerciseStates = {};
|
||||||
int _breakRemaining = 0;
|
|
||||||
int _breakDurationSecs = 0;
|
|
||||||
DateTime? _breakStartTime;
|
|
||||||
Timer? _breakTimer;
|
|
||||||
String _breakLabel = '';
|
|
||||||
int _breakForExIdx = -1;
|
|
||||||
int _breakForRepIdx = -1; // -1 = warmup break
|
|
||||||
|
|
||||||
bool get _inBreak => _breakRemaining > 0;
|
|
||||||
|
|
||||||
final _audio = AudioPlayer();
|
|
||||||
final _sync = SyncService();
|
final _sync = SyncService();
|
||||||
bool _finished = false;
|
bool _finished = false;
|
||||||
|
|
||||||
@ -73,6 +54,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
setState(() => _elapsed = DateTime.now().difference(_startTime));
|
setState(() => _elapsed = DateTime.now().difference(_startTime));
|
||||||
});
|
});
|
||||||
|
_loadExerciseStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initFresh() {
|
void _initFresh() {
|
||||||
@ -97,29 +79,20 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
.map((row) => (row as List).cast<int>())
|
.map((row) => (row as List).cast<int>())
|
||||||
.toList();
|
.toList();
|
||||||
_warmupTapped = (s['warmupTapped'] as List).cast<bool>();
|
_warmupTapped = (s['warmupTapped'] as List).cast<bool>();
|
||||||
|
|
||||||
final breakEndMs = s['breakEndMs'] as int? ?? 0;
|
|
||||||
final breakDur = s['breakDurationSecs'] as int? ?? 0;
|
|
||||||
if (breakEndMs > 0 && breakDur > 0) {
|
|
||||||
final endTime = DateTime.fromMillisecondsSinceEpoch(breakEndMs);
|
|
||||||
final remaining = endTime.difference(DateTime.now()).inSeconds;
|
|
||||||
if (remaining > 0) {
|
|
||||||
_breakForExIdx = s['breakForExIdx'] as int? ?? -1;
|
|
||||||
_breakForRepIdx = s['breakForRepIdx'] as int? ?? -1;
|
|
||||||
_breakLabel = s['breakLabel'] as String? ?? 'Rest';
|
|
||||||
_breakDurationSecs = breakDur;
|
|
||||||
_breakStartTime = endTime.subtract(Duration(seconds: breakDur));
|
|
||||||
_breakRemaining = remaining;
|
|
||||||
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadExerciseStates() async {
|
||||||
|
final states = await StorageService.instance.getAllExerciseStates();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_exerciseStates = {for (final s in states) s.name: s};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_elapsedTimer.cancel();
|
_elapsedTimer.cancel();
|
||||||
_breakTimer?.cancel();
|
|
||||||
_audio.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,15 +105,6 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
'tapped': _tapped,
|
'tapped': _tapped,
|
||||||
'doneReps': _doneReps,
|
'doneReps': _doneReps,
|
||||||
'warmupTapped': _warmupTapped,
|
'warmupTapped': _warmupTapped,
|
||||||
'breakForExIdx': _breakForExIdx,
|
|
||||||
'breakForRepIdx': _breakForRepIdx,
|
|
||||||
'breakLabel': _breakLabel,
|
|
||||||
'breakDurationSecs': _breakDurationSecs,
|
|
||||||
'breakEndMs': _breakStartTime != null
|
|
||||||
? _breakStartTime!
|
|
||||||
.add(Duration(seconds: _breakDurationSecs))
|
|
||||||
.millisecondsSinceEpoch
|
|
||||||
: 0,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,60 +118,24 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
|
|
||||||
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t));
|
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t));
|
||||||
|
|
||||||
bool _isLastUntappedCircle(int exIdx, int repIdx) {
|
|
||||||
int remaining = 0;
|
|
||||||
for (int i = 0; i < widget.exercises.length; i++) {
|
|
||||||
for (int s = 0; s < widget.exercises[i].sets; s++) {
|
|
||||||
if (!_tapped[i][s]) remaining++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return remaining == 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Interaction ────────────────────────────────────────────────────────────
|
// ── Interaction ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void _tapCircle(int exIdx, int repIdx) {
|
void _tapCircle(int exIdx, int repIdx) {
|
||||||
if (_finished) return;
|
if (_finished) return;
|
||||||
|
|
||||||
final wasNotTapped = !_tapped[exIdx][repIdx];
|
|
||||||
if (wasNotTapped && _inBreak) return;
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (wasNotTapped) {
|
if (!_tapped[exIdx][repIdx]) {
|
||||||
_tapped[exIdx][repIdx] = true;
|
_tapped[exIdx][repIdx] = true;
|
||||||
} else {
|
} else {
|
||||||
// Subsequent taps decrement reps (records actual reps done).
|
|
||||||
_doneReps[exIdx][repIdx] =
|
_doneReps[exIdx][repIdx] =
|
||||||
(_doneReps[exIdx][repIdx] - 1).clamp(0, 999);
|
(_doneReps[exIdx][repIdx] - 1).clamp(0, 999);
|
||||||
_recomputeBreakIfNeeded(exIdx, repIdx);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (wasNotTapped) {
|
|
||||||
final isLast = _isLastUntappedCircle(exIdx, repIdx);
|
|
||||||
if (!isLast) {
|
|
||||||
final succeeded =
|
|
||||||
_doneReps[exIdx][repIdx] >= widget.exercises[exIdx].reps;
|
|
||||||
_startBreak(
|
|
||||||
succeeded ? _successBreakSecs : _failBreakSecs,
|
|
||||||
succeeded
|
|
||||||
? 'Rest (3 min — well done!)'
|
|
||||||
: 'Rest (5 min — keep going!)',
|
|
||||||
exIdx,
|
|
||||||
repIdx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_saveActiveSession();
|
_saveActiveSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _tapWarmup(int exIdx) {
|
void _tapWarmup(int exIdx) {
|
||||||
if (_finished || _warmupTapped[exIdx]) return;
|
if (_finished || _warmupTapped[exIdx]) return;
|
||||||
setState(() => _warmupTapped[exIdx] = true);
|
setState(() => _warmupTapped[exIdx] = true);
|
||||||
if (!_inBreak) {
|
|
||||||
_startBreak(_warmupBreakSecs, 'Warmup rest (3 min)', exIdx, -1);
|
|
||||||
}
|
|
||||||
_saveActiveSession();
|
_saveActiveSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,81 +145,36 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
_tapped[exIdx][repIdx] = false;
|
_tapped[exIdx][repIdx] = false;
|
||||||
_doneReps[exIdx][repIdx] = widget.exercises[exIdx].reps;
|
_doneReps[exIdx][repIdx] = widget.exercises[exIdx].reps;
|
||||||
});
|
});
|
||||||
if (_breakForExIdx == exIdx && _breakForRepIdx == repIdx) {
|
|
||||||
_cancelBreak();
|
|
||||||
}
|
|
||||||
_saveActiveSession();
|
_saveActiveSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Break management ───────────────────────────────────────────────────────
|
Future<void> _onThresholdChanged(
|
||||||
|
String name,
|
||||||
void _startBreak(int secs, String label, int exIdx, int repIdx) {
|
int success,
|
||||||
_breakTimer?.cancel();
|
int fail,
|
||||||
|
) async {
|
||||||
|
await StorageService.instance.setExerciseThresholds(
|
||||||
|
name,
|
||||||
|
successThreshold: success,
|
||||||
|
failThreshold: fail,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_breakDurationSecs = secs;
|
final s = _exerciseStates[name];
|
||||||
_breakRemaining = secs;
|
if (s != null) {
|
||||||
_breakLabel = label;
|
_exerciseStates[name] = ExerciseState(
|
||||||
_breakForExIdx = exIdx;
|
name: s.name,
|
||||||
_breakForRepIdx = repIdx;
|
weight: s.weight,
|
||||||
_breakStartTime = DateTime.now();
|
reps: s.reps,
|
||||||
});
|
successStreak: s.successStreak,
|
||||||
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
|
failStreak: s.failStreak,
|
||||||
|
maxWeight: s.maxWeight,
|
||||||
|
successThreshold: success,
|
||||||
|
failThreshold: fail,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _tickBreak(Timer t) {
|
|
||||||
setState(() => _breakRemaining--);
|
|
||||||
if (_breakRemaining <= 0) {
|
|
||||||
t.cancel();
|
|
||||||
_onBreakFinished();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _cancelBreak() {
|
|
||||||
_breakTimer?.cancel();
|
|
||||||
setState(() {
|
|
||||||
_breakRemaining = 0;
|
|
||||||
_breakForExIdx = -1;
|
|
||||||
_breakForRepIdx = -1;
|
|
||||||
_breakStartTime = null;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _skipBreak() {
|
|
||||||
_cancelBreak();
|
|
||||||
_saveActiveSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If the user reduces reps on the rep that triggered the current break,
|
|
||||||
/// switch from 3-min to 5-min (or vice versa).
|
|
||||||
void _recomputeBreakIfNeeded(int exIdx, int repIdx) {
|
|
||||||
if (!_inBreak) return;
|
|
||||||
if (_breakForExIdx != exIdx || _breakForRepIdx != repIdx) return;
|
|
||||||
if (_breakForRepIdx == -1) return; // warmup break, never recompute
|
|
||||||
|
|
||||||
final succeeded =
|
|
||||||
_doneReps[exIdx][repIdx] >= widget.exercises[exIdx].reps;
|
|
||||||
final newDuration = succeeded ? _successBreakSecs : _failBreakSecs;
|
|
||||||
if (newDuration == _breakDurationSecs) return;
|
|
||||||
|
|
||||||
final elapsed = DateTime.now().difference(_breakStartTime!).inSeconds;
|
|
||||||
final newRemaining = (newDuration - elapsed).clamp(0, newDuration);
|
|
||||||
|
|
||||||
_breakDurationSecs = newDuration;
|
|
||||||
_breakRemaining = newRemaining;
|
|
||||||
_breakLabel =
|
|
||||||
succeeded ? 'Rest (3 min — well done!)' : 'Rest (5 min — keep going!)';
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onBreakFinished() async {
|
|
||||||
await _audio.play(AssetSource('sounds/break_end.mp3')).catchError((_) {});
|
|
||||||
final hasVibrator = await Vibration.hasVibrator() == true;
|
|
||||||
if (hasVibrator) Vibration.vibrate(duration: 800);
|
|
||||||
setState(() {
|
|
||||||
_breakForExIdx = -1;
|
|
||||||
_breakForRepIdx = -1;
|
|
||||||
_breakStartTime = null;
|
|
||||||
});
|
|
||||||
_saveActiveSession();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Finish / Reset ─────────────────────────────────────────────────────────
|
// ── Finish / Reset ─────────────────────────────────────────────────────────
|
||||||
@ -359,7 +242,6 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
|
|
||||||
Future<void> _finishWorkout() async {
|
Future<void> _finishWorkout() async {
|
||||||
_elapsedTimer.cancel();
|
_elapsedTimer.cancel();
|
||||||
_breakTimer?.cancel();
|
|
||||||
setState(() => _finished = true);
|
setState(() => _finished = true);
|
||||||
|
|
||||||
final endTime = DateTime.now();
|
final endTime = DateTime.now();
|
||||||
@ -369,6 +251,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
final ex = widget.exercises[i];
|
final ex = widget.exercises[i];
|
||||||
results.add(ExerciseResult(
|
results.add(ExerciseResult(
|
||||||
exercise: ex,
|
exercise: ex,
|
||||||
|
warmupDone: _warmupTapped[i],
|
||||||
sets: List.generate(
|
sets: List.generate(
|
||||||
ex.sets,
|
ex.sets,
|
||||||
(s) => SetResult(
|
(s) => SetResult(
|
||||||
@ -425,7 +308,6 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopScope(
|
return PopScope(
|
||||||
// canPop: true → back navigates home, workout stays in DB
|
|
||||||
// ignore: avoid_redundant_argument_values
|
// ignore: avoid_redundant_argument_values
|
||||||
canPop: true,
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@ -460,31 +342,27 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: ListView.separated(
|
||||||
children: [
|
|
||||||
if (_inBreak)
|
|
||||||
BreakBanner(
|
|
||||||
breakRemaining: _breakRemaining,
|
|
||||||
breakLabel: _breakLabel,
|
|
||||||
onSkip: _skipBreak,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: ListView.separated(
|
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
itemCount: widget.exercises.length,
|
itemCount: widget.exercises.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
itemBuilder: (_, i) => ExerciseTile(
|
itemBuilder: (_, i) {
|
||||||
|
final exName = widget.exercises[i].name;
|
||||||
|
final state = _exerciseStates[exName];
|
||||||
|
return ExerciseTile(
|
||||||
exercise: widget.exercises[i],
|
exercise: widget.exercises[i],
|
||||||
tapped: _tapped[i],
|
tapped: _tapped[i],
|
||||||
doneReps: _doneReps[i],
|
doneReps: _doneReps[i],
|
||||||
warmupTapped: _warmupTapped[i],
|
warmupTapped: _warmupTapped[i],
|
||||||
|
successThreshold: state?.successThreshold ?? 3,
|
||||||
|
failThreshold: state?.failThreshold ?? 2,
|
||||||
onTapCircle: (s) => _tapCircle(i, s),
|
onTapCircle: (s) => _tapCircle(i, s),
|
||||||
onLongPressCircle: (s) => _resetCircle(i, s),
|
onLongPressCircle: (s) => _resetCircle(i, s),
|
||||||
onTapWarmup: () => _tapWarmup(i),
|
onTapWarmup: () => _tapWarmup(i),
|
||||||
),
|
onThresholdChanged: (success, fail) =>
|
||||||
),
|
_onThresholdChanged(exName, success, fail),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -361,4 +361,30 @@ class StorageService {
|
|||||||
[limit],
|
[limit],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<String>> getAllWorkoutDates() async {
|
||||||
|
final rows = await _db.rawQuery(
|
||||||
|
'SELECT DISTINCT date FROM workout_history ORDER BY date DESC',
|
||||||
|
);
|
||||||
|
return rows.map((r) => r['date'] as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resetExerciseToDefaults(String name) async {
|
||||||
|
final defaults = [...workoutA, ...workoutB].firstWhere(
|
||||||
|
(e) => e.name == name,
|
||||||
|
orElse: () => throw Exception('Unknown exercise: $name'),
|
||||||
|
);
|
||||||
|
await _db.update(
|
||||||
|
'exercise_state',
|
||||||
|
{
|
||||||
|
'weight': defaults.weight,
|
||||||
|
'success_threshold': 3,
|
||||||
|
'fail_threshold': 2,
|
||||||
|
'success_streak': 0,
|
||||||
|
'fail_streak': 0,
|
||||||
|
},
|
||||||
|
where: 'name = ?',
|
||||||
|
whereArgs: [name],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,138 @@
|
|||||||
|
/// Monthly calendar showing which days had workouts.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WorkoutCalendar extends StatelessWidget {
|
||||||
|
const WorkoutCalendar({
|
||||||
|
super.key,
|
||||||
|
required this.workoutDates,
|
||||||
|
required this.month,
|
||||||
|
required this.onPrevMonth,
|
||||||
|
required this.onNextMonth,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Set<String> workoutDates;
|
||||||
|
|
||||||
|
/// Only the year and month of this DateTime are used.
|
||||||
|
final DateTime month;
|
||||||
|
final VoidCallback onPrevMonth;
|
||||||
|
final VoidCallback onNextMonth;
|
||||||
|
|
||||||
|
static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||||
|
|
||||||
|
static const _monthNames = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
String _dateKey(int year, int m, int day) =>
|
||||||
|
'$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final year = month.year;
|
||||||
|
final m = month.month;
|
||||||
|
final daysInMonth = DateTime(year, m + 1, 0).day;
|
||||||
|
// weekday: 1=Mon..7=Sun → offset 0..6
|
||||||
|
final firstWeekday = DateTime(year, m, 1).weekday - 1;
|
||||||
|
final totalCells = firstWeekday + daysInMonth;
|
||||||
|
final rows = (totalCells / 7).ceil();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Month navigation header
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_left, color: Colors.white70),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: onPrevMonth,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${_monthNames[m - 1]} $year',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_right, color: Colors.white70),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: onNextMonth,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Day-of-week headers
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: _weekHeaders
|
||||||
|
.map(
|
||||||
|
(h) => SizedBox(
|
||||||
|
width: 30,
|
||||||
|
child: Text(
|
||||||
|
h,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Day grid
|
||||||
|
...List.generate(rows, (row) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: List.generate(7, (col) {
|
||||||
|
final cell = row * 7 + col;
|
||||||
|
final day = cell - firstWeekday + 1;
|
||||||
|
if (day < 1 || day > daysInMonth) {
|
||||||
|
return const SizedBox(width: 30, height: 30);
|
||||||
|
}
|
||||||
|
final key = _dateKey(year, m, day);
|
||||||
|
final worked = workoutDates.contains(key);
|
||||||
|
return Container(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
decoration: worked
|
||||||
|
? BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'$day',
|
||||||
|
style: TextStyle(
|
||||||
|
color: worked ? Colors.white : Colors.white38,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight:
|
||||||
|
worked ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,25 +12,27 @@ class ExerciseTile extends StatelessWidget {
|
|||||||
required this.tapped,
|
required this.tapped,
|
||||||
required this.doneReps,
|
required this.doneReps,
|
||||||
required this.warmupTapped,
|
required this.warmupTapped,
|
||||||
|
required this.successThreshold,
|
||||||
|
required this.failThreshold,
|
||||||
required this.onTapCircle,
|
required this.onTapCircle,
|
||||||
required this.onLongPressCircle,
|
required this.onLongPressCircle,
|
||||||
required this.onTapWarmup,
|
required this.onTapWarmup,
|
||||||
|
required this.onThresholdChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Exercise exercise;
|
final Exercise exercise;
|
||||||
|
|
||||||
/// tapped[setIdx] - whether each main set circle has been tapped.
|
|
||||||
final List<bool> tapped;
|
final List<bool> tapped;
|
||||||
|
|
||||||
/// doneReps[setIdx] - how many reps recorded for each set.
|
|
||||||
final List<int> doneReps;
|
final List<int> doneReps;
|
||||||
|
|
||||||
final bool warmupTapped;
|
final bool warmupTapped;
|
||||||
|
final int successThreshold;
|
||||||
|
final int failThreshold;
|
||||||
final void Function(int setIdx) onTapCircle;
|
final void Function(int setIdx) onTapCircle;
|
||||||
final void Function(int setIdx) onLongPressCircle;
|
final void Function(int setIdx) onLongPressCircle;
|
||||||
final VoidCallback onTapWarmup;
|
final VoidCallback onTapWarmup;
|
||||||
|
|
||||||
|
/// Called when user changes thresholds inline; args are (newSuccess, newFail).
|
||||||
|
final void Function(int success, int fail) onThresholdChanged;
|
||||||
|
|
||||||
bool get _allCompleted => tapped.every((t) => t);
|
bool get _allCompleted => tapped.every((t) => t);
|
||||||
|
|
||||||
bool get _allSucceeded =>
|
bool get _allSucceeded =>
|
||||||
@ -51,7 +53,6 @@ class ExerciseTile extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header row: name + target
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -71,14 +72,12 @@ class ExerciseTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Warmup row
|
|
||||||
_WarmupRow(
|
_WarmupRow(
|
||||||
warmupWeight: exercise.warmupWeight,
|
warmupWeight: exercise.warmupWeight,
|
||||||
tapped: warmupTapped,
|
tapped: warmupTapped,
|
||||||
onTap: onTapWarmup,
|
onTap: onTapWarmup,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
// Main set circles
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@ -93,6 +92,13 @@ class ExerciseTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(color: Colors.white12, height: 20),
|
||||||
|
_ThresholdRow(
|
||||||
|
successThreshold: successThreshold,
|
||||||
|
failThreshold: failThreshold,
|
||||||
|
onSuccessChanged: (v) => onThresholdChanged(v, failThreshold),
|
||||||
|
onFailChanged: (v) => onThresholdChanged(successThreshold, v),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -100,6 +106,108 @@ class ExerciseTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ThresholdRow extends StatelessWidget {
|
||||||
|
const _ThresholdRow({
|
||||||
|
required this.successThreshold,
|
||||||
|
required this.failThreshold,
|
||||||
|
required this.onSuccessChanged,
|
||||||
|
required this.onFailChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int successThreshold;
|
||||||
|
final int failThreshold;
|
||||||
|
final ValueChanged<int> onSuccessChanged;
|
||||||
|
final ValueChanged<int> onFailChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.trending_up, size: 13, color: Colors.greenAccent),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text(
|
||||||
|
'after',
|
||||||
|
style: TextStyle(color: Colors.white38, fontSize: 11),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_MiniStepper(
|
||||||
|
value: successThreshold,
|
||||||
|
onChanged: onSuccessChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text(
|
||||||
|
'↑',
|
||||||
|
style: TextStyle(color: Colors.white38, fontSize: 11),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(Icons.trending_down, size: 13, color: Colors.redAccent),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text(
|
||||||
|
'after',
|
||||||
|
style: TextStyle(color: Colors.white38, fontSize: 11),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_MiniStepper(
|
||||||
|
value: failThreshold,
|
||||||
|
onChanged: onFailChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text(
|
||||||
|
'↓',
|
||||||
|
style: TextStyle(color: Colors.white38, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MiniStepper extends StatelessWidget {
|
||||||
|
const _MiniStepper({required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
final int value;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
static const _min = 1;
|
||||||
|
static const _max = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_btn(Icons.remove, value > _min ? () => onChanged(value - 1) : null),
|
||||||
|
SizedBox(
|
||||||
|
width: 22,
|
||||||
|
child: Text(
|
||||||
|
'$value',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_btn(Icons.add, value < _max ? () => onChanged(value + 1) : null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _btn(IconData icon, VoidCallback? onTap) => GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 12,
|
||||||
|
color: onTap != null ? Colors.white : Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class _WarmupRow extends StatelessWidget {
|
class _WarmupRow extends StatelessWidget {
|
||||||
const _WarmupRow({
|
const _WarmupRow({
|
||||||
required this.warmupWeight,
|
required this.warmupWeight,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user