screen-locker/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart
Krzysztof kuhy Rudnicki 23d2173d9f Add comprehensive test suite, backup service, and linting to workout app
- Add sqflite_common_ffi + very_good_analysis; tighten analysis_options
- Add BackupService for JSON export/import of exercise state
- Add full test coverage: models, screens, services, widgets
- Add scripts/check_flutter_coverage.sh to enforce 100% line coverage
- Add docstrings to ExerciseState fields and storage service
- Minor fixes across screens, widgets, and sync/HTTP services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuiPt6GPWkxpLbJFrnfy8U
2026-06-28 08:11:43 +02:00

289 lines
8.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Card widget for a single exercise showing warmup and main-set rep circles.
library;
import 'package:flutter/material.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/widgets/rep_circle.dart';
/// Card widget displaying warmup and working-set rep circles for one exercise.
class ExerciseTile extends StatelessWidget {
/// Creates an [ExerciseTile].
const ExerciseTile({
required this.exercise,
required this.tapped,
required this.doneReps,
required this.warmupTapped,
required this.successThreshold,
required this.failThreshold,
required this.onTapCircle,
required this.onLongPressCircle,
required this.onTapWarmup,
required this.onThresholdChanged,
super.key,
});
/// The exercise definition to display.
final Exercise exercise;
/// Per-set tap state; true when a set circle has been tapped.
final List<bool> tapped;
/// Per-set rep count; may be less than target after repeated taps.
final List<int> doneReps;
/// Whether the warmup circle has been tapped.
final bool warmupTapped;
/// Success streak threshold shown in the inline settings row.
final int successThreshold;
/// Fail streak threshold shown in the inline settings row.
final int failThreshold;
/// Called when a working-set circle is tapped.
final void Function(int setIdx) onTapCircle;
/// Called when a working-set circle is long-pressed (resets to neutral).
final void Function(int setIdx) onLongPressCircle;
/// Called when the warmup circle is tapped.
final VoidCallback onTapWarmup;
/// Called when the user changes thresholds inline (newSuccess, newFail).
final void Function(int success, int fail) onThresholdChanged;
bool get _allCompleted => tapped.every((t) => t);
bool get _allSucceeded =>
_allCompleted && doneReps.every((r) => r >= exercise.reps);
@override
Widget build(BuildContext context) {
var headerColor = Colors.grey.shade800;
if (_allCompleted) {
headerColor = _allSucceeded ? Colors.green.shade800 : Colors.red.shade900;
}
return Card(
color: headerColor,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
exercise.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
Text(
'${exercise.sets}×${exercise.reps}×${exercise.weight}kg',
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
const SizedBox(height: 8),
if (exercise.hasWarmup) ...[
_WarmupRow(
warmupWeight: exercise.warmupWeight,
tapped: warmupTapped,
onTap: onTapWarmup,
),
const SizedBox(height: 10),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(
exercise.sets,
(s) => RepCircle(
targetReps: exercise.reps,
doneReps: doneReps[s],
tapped: tapped[s],
onTap: () => onTapCircle(s),
onLongPress: () => onLongPressCircle(s),
),
),
),
const Divider(color: Colors.white12, height: 20),
_ThresholdRow(
successThreshold: successThreshold,
failThreshold: failThreshold,
onSuccessChanged: (v) => onThresholdChanged(v, failThreshold),
onFailChanged: (v) => onThresholdChanged(successThreshold, v),
),
],
),
),
);
}
}
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 {
const _WarmupRow({
required this.warmupWeight,
required this.tapped,
required this.onTap,
});
final double warmupWeight;
final bool tapped;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Row(
children: [
const Text(
'Warmup 1×5×',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
Text(
'${warmupWeight}kg',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(width: 10),
GestureDetector(
onTap: tapped ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: tapped ? Colors.teal : Colors.transparent,
border: Border.all(
color: tapped ? Colors.teal : Colors.white38,
width: 2,
),
),
alignment: Alignment.center,
child: Icon(
tapped ? Icons.check : Icons.fitness_center,
color: tapped ? Colors.white : Colors.white38,
size: 16,
),
),
),
const SizedBox(width: 6),
Text(
tapped ? 'done' : 'optional',
style: TextStyle(
color: tapped ? Colors.tealAccent : Colors.white30,
fontSize: 11,
),
),
],
);
}
}