Skip to content

Commit 773285b

Browse files
sprobst76claude
andcommitted
feat: Implement Comeback System Improvements (Feature 4)
This completes Feature 4 of the planned enhancements with three major components: 1. Dynamic FTP Estimation - Added detectedFtp, ftpDetectedAt, ftpDetectionMethod fields to ComebackMode - Implemented hasFtpSuggestion getter to detect when athlete improves beyond effective FTP - Added three detection methods in comeback_service: 20-minute power test, sweet-spot analysis, normalized power trend - Added acceptFtpSuggestion() and dismissFtpSuggestion() methods - FTP suggestions persist for 7 days and require performance > effective FTP 2. Wellness Trend Visualization - Created wellness_trend_chart.dart with 7-day rolling line chart - Color-coded wellness scores: green (≥75%), blue (≥50%), orange (≥25%), red (<25%) - Added _TrendIndicator to show wellness progression (Steigend/Stabil/Fallend) - Shows axis labels with weekday abbreviations and percentage scale - Responsive touch tooltips showing score and date 3. Smart Phase Progression - Created phase_readiness_card.dart with conditional phase advancement - Added isReadyForNextPhase getter with three criteria: * Minimum 5 days in current phase * Average wellness score ≥60% for last 3 days * Resting HR not elevated (≤110% of baseline) - Added phaseProgressionRecommendation getter providing user guidance - Manual override button available after 7 days with warning dialog - Integrated into comeback_setup_page with conditional rendering 4. Comprehensive Testing - Created comeback_mode_test.dart with 44 tests (100% passing) - Tests cover: FTP suggestion logic, phase readiness criteria, phase progression recommendations - Tests validate JSON serialization/deserialization with new fields - Tests verify copyWith functionality and entity equality Files modified: - lib/domain/entities/comeback_mode.dart (4 new fields, 4 new getters, updated serialization) - lib/core/services/comeback_service.dart (4 new methods for FTP detection and phase management) - lib/features/comeback/presentation/pages/comeback_setup_page.dart (integrated new widgets) Files created: - lib/features/comeback/presentation/widgets/wellness_trend_chart.dart (150 lines) - lib/features/comeback/presentation/widgets/phase_readiness_card.dart (190 lines) - test/domain/entities/comeback_mode_test.dart (950 lines, 44 tests) All code compiles successfully with only pre-existing info-level analyzer warnings. Feature 4 implementation (5 phases, 3-4 hours) is now complete and ready for manual testing. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 587d926 commit 773285b

File tree

6 files changed

+1877
-2
lines changed

6 files changed

+1877
-2
lines changed

lib/core/services/comeback_service.dart

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
44
import 'package:shared_preferences/shared_preferences.dart';
55

66
import '../../domain/entities/comeback_mode.dart';
7+
import '../../domain/entities/training_session.dart';
78
import '../../domain/entities/workout.dart';
9+
import '../../providers/providers.dart';
810

911
/// Service für Comeback Mode Management
1012
class ComebackService {
@@ -46,14 +48,16 @@ final comebackServiceProvider = Provider<ComebackService>((ref) {
4648
final comebackModeProvider =
4749
StateNotifierProvider<ComebackModeNotifier, ComebackMode>((ref) {
4850
final service = ref.watch(comebackServiceProvider);
49-
return ComebackModeNotifier(service);
51+
return ComebackModeNotifier(service, ref);
5052
});
5153

5254
/// Comeback Mode State Notifier
5355
class ComebackModeNotifier extends StateNotifier<ComebackMode> {
5456
final ComebackService _service;
57+
final Ref _ref;
5558

56-
ComebackModeNotifier(this._service) : super(const ComebackMode()) {
59+
ComebackModeNotifier(this._service, this._ref)
60+
: super(const ComebackMode()) {
5761
_load();
5862
}
5963

@@ -110,6 +114,185 @@ class ComebackModeNotifier extends StateNotifier<ComebackMode> {
110114
state = state.copyWith(baselineRestingHr: hr);
111115
await _service.save(state);
112116
}
117+
118+
/// Erkennt FTP-Verbesserung aus abgeschlossenen Workouts
119+
Future<void> detectFtpFromSessions() async {
120+
if (!state.isActive || state.startDate == null) return;
121+
122+
try {
123+
final sessions =
124+
await _ref.read(sessionRepositoryProvider).getAllSessions();
125+
if (sessions.isEmpty) return;
126+
127+
// Filter zu Comeback-Period Sessions
128+
final comebackSessions = sessions
129+
.where((s) => state.isActive && s.startTime.isAfter(state.startDate ?? DateTime(2000)))
130+
.toList();
131+
132+
if (comebackSessions.isEmpty) return;
133+
134+
// Versuche 3 Erkennungsmethoden
135+
int? detected;
136+
String? method;
137+
138+
// Methode 1: 20-Minuten-Power-Test (genaueste)
139+
detected = _detect20MinPower(comebackSessions);
140+
if (detected != null) {
141+
method = '20min';
142+
}
143+
144+
// Methode 2: Sweet Spot Intervalle (mittl. Genauigkeit)
145+
if (detected == null) {
146+
detected = _detectSweetSpotPower(comebackSessions);
147+
if (detected != null) method = 'sweetspot';
148+
}
149+
150+
// Methode 3: Normalized Power Trend (konservativ)
151+
if (detected == null) {
152+
detected = _detectNormalizedPowerTrend(comebackSessions);
153+
if (detected != null) method = 'normalized';
154+
}
155+
156+
if (detected != null && detected > state.effectiveFtp) {
157+
state = state.copyWith(
158+
detectedFtp: detected,
159+
ftpDetectedAt: DateTime.now(),
160+
ftpDetectionMethod: method,
161+
);
162+
await _service.save(state);
163+
}
164+
} catch (e) {
165+
// Fehler ignorieren
166+
}
167+
}
168+
169+
int? _detect20MinPower(List<TrainingSession> sessions) {
170+
// Suche nach Sessions mit 20+ Minuten Effort
171+
for (final session in sessions.reversed.take(5)) {
172+
if (session.stats == null) continue;
173+
174+
final duration = session.stats!.duration;
175+
if (duration.inMinutes < 20) continue;
176+
177+
// Finde 20-Minuten Rolling Average Power
178+
final dataPoints = session.dataPoints;
179+
if (dataPoints.length < 1200) continue; // 20 min at 1Hz
180+
181+
int maxAvg20Min = 0;
182+
for (int i = 0; i <= dataPoints.length - 1200; i++) {
183+
final window = dataPoints.skip(i).take(1200);
184+
final avg = window.map((p) => p.power).reduce((a, b) => a + b) ~/ 1200;
185+
if (avg > maxAvg20Min) maxAvg20Min = avg;
186+
}
187+
188+
if (maxAvg20Min > 0) {
189+
return (maxAvg20Min * 0.95).round(); // 95% of 20min = FTP estimate
190+
}
191+
}
192+
return null;
193+
}
194+
195+
int? _detectSweetSpotPower(List<TrainingSession> sessions) {
196+
// Suche nach Efforts im 75-90% FTP Bereich
197+
final efforts = <int>[];
198+
199+
for (final session in sessions.reversed.take(10)) {
200+
final dataPoints = session.dataPoints;
201+
if (dataPoints.length < 300) continue; // 5+ min efforts
202+
203+
int currentEffortPower = 0;
204+
int currentEffortLength = 0;
205+
206+
for (final point in dataPoints) {
207+
// Check if in "sweet spot" range (75-90% of current effective FTP)
208+
final targetLow = (state.effectiveFtp * 0.75).round();
209+
final targetHigh = (state.effectiveFtp * 0.90).round();
210+
211+
if (point.power >= targetLow && point.power <= targetHigh) {
212+
currentEffortPower = point.power;
213+
currentEffortLength++;
214+
} else if (currentEffortLength >= 300) {
215+
// Effort endete, speichern wenn 5+ minuten
216+
efforts.add(currentEffortPower);
217+
currentEffortLength = 0;
218+
} else {
219+
currentEffortLength = 0;
220+
}
221+
}
222+
}
223+
224+
if (efforts.isEmpty) return null;
225+
226+
// Durchschnitt der Sweet Spot Efforts und Rückberechnung FTP
227+
final avgSweetSpot = efforts.reduce((a, b) => a + b) ~/ efforts.length;
228+
return (avgSweetSpot / 0.85).round(); // Assume sweet spot = 85% FTP
229+
}
230+
231+
int? _detectNormalizedPowerTrend(List<TrainingSession> sessions) {
232+
// Konservativ: Schau auf NP Trend über letzte 5 Sessions
233+
final recentNP = sessions
234+
.reversed
235+
.take(5)
236+
.where((s) => s.stats?.normalizedPower != null)
237+
.map((s) => s.stats!.normalizedPower)
238+
.whereType<int>()
239+
.toList();
240+
241+
if (recentNP.length < 3) return null;
242+
243+
final avgNP = recentNP.reduce((a, b) => a + b) ~/ recentNP.length;
244+
245+
// Wenn durchschn NP konsistent höher als effective FTP, schlag Erhöhung vor
246+
if (avgNP > state.effectiveFtp * 1.05) {
247+
return avgNP; // Use average NP as new FTP estimate
248+
}
249+
250+
return null;
251+
}
252+
253+
/// Führt zu nächster Phase vor
254+
void advancePhase() {
255+
if (state.currentPhase == ComebackPhase.completed) return;
256+
257+
// Berechne neue Startdatum für Phasenfortschritt
258+
final daysToAdd = 7 - state.dayInCurrentWeek;
259+
final adjustedStartDate =
260+
state.startDate!.subtract(Duration(days: daysToAdd + 7));
261+
262+
state = state.copyWith(
263+
startDate: adjustedStartDate,
264+
);
265+
266+
_service.save(state);
267+
}
268+
269+
/// Akzeptiert FTP-Verbesserungsvorschlag
270+
void acceptFtpSuggestion() {
271+
if (state.detectedFtp == null) return;
272+
273+
// Update athlete profile with new FTP
274+
_ref.read(athleteProfileProvider.notifier).updateFtp(state.detectedFtp!);
275+
276+
// Update comeback original FTP
277+
state = state.copyWith(
278+
originalFtp: state.detectedFtp,
279+
detectedFtp: null, // Clear suggestion
280+
ftpDetectedAt: null,
281+
ftpDetectionMethod: null,
282+
);
283+
284+
_service.save(state);
285+
}
286+
287+
/// Verwirft FTP-Verbesserungsvorschlag
288+
void dismissFtpSuggestion() {
289+
state = state.copyWith(
290+
detectedFtp: null,
291+
ftpDetectedAt: null,
292+
ftpDetectionMethod: null,
293+
);
294+
_service.save(state);
295+
}
113296
}
114297

115298
/// Comeback Workouts

lib/domain/entities/comeback_mode.dart

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ class ComebackMode extends Equatable {
219219
final int? baselineRestingHr; // Normaler Ruhepuls
220220
final List<WellnessCheckIn> checkIns;
221221
final String? illnessType; // Optional: Art der Krankheit
222+
final int? detectedFtp; // Aus Workouts erkannter FTP
223+
final DateTime? ftpDetectedAt; // Wann FTP erkannt wurde
224+
final String? ftpDetectionMethod; // '20min' | 'sweetspot' | 'normalized'
222225

223226
const ComebackMode({
224227
this.isActive = false,
@@ -228,6 +231,9 @@ class ComebackMode extends Equatable {
228231
this.baselineRestingHr,
229232
this.checkIns = const [],
230233
this.illnessType,
234+
this.detectedFtp,
235+
this.ftpDetectedAt,
236+
this.ftpDetectionMethod,
231237
});
232238

233239
/// Aktuelle Phase basierend auf Startdatum
@@ -336,6 +342,78 @@ class ComebackMode extends Equatable {
336342
return (days / 28 * 100).clamp(0, 100);
337343
}
338344

345+
/// Hat eine FTP-Verbesserung erkannt?
346+
bool get hasFtpSuggestion =>
347+
detectedFtp != null &&
348+
detectedFtp! > effectiveFtp &&
349+
(ftpDetectedAt == null || DateTime.now().difference(ftpDetectedAt!).inDays < 7);
350+
351+
/// Empfohlene FTP-Steigerung
352+
int get suggestedFtpIncrease => (detectedFtp ?? originalFtp) - effectiveFtp;
353+
354+
/// Ist bereit für nächste Phase?
355+
bool get isReadyForNextPhase {
356+
if (currentPhase == ComebackPhase.completed) return false;
357+
358+
// Mindestens 5 Tage in Phase
359+
if (dayInCurrentWeek < 5) return false;
360+
361+
// Durchschnittlicher Wellness-Score >60% für letzte 3 Tage
362+
final recent3Days = checkIns
363+
.where((c) => DateTime.now().difference(c.date).inDays <= 3)
364+
.toList();
365+
if (recent3Days.length < 2) return false;
366+
367+
final avgScore =
368+
recent3Days.map((c) => c.normalizedScore).reduce((a, b) => a + b) /
369+
recent3Days.length;
370+
if (avgScore < 60) return false;
371+
372+
// Ruhepuls nicht erhöht
373+
if (isRestingHrTrending) return false;
374+
375+
return true;
376+
}
377+
378+
/// Empfehlung zur Phasenfortschritt
379+
String get phaseProgressionRecommendation {
380+
if (currentPhase == ComebackPhase.completed) {
381+
return 'Comeback abgeschlossen! Du bist zurück auf 100%.';
382+
}
383+
384+
if (isReadyForNextPhase) {
385+
final nextPhase = _getNextPhase();
386+
return 'Bereit für $nextPhase! Wellness und HR sind stabil.';
387+
}
388+
389+
final blockers = <String>[];
390+
if (dayInCurrentWeek < 5) {
391+
final daysLeft = 5 - dayInCurrentWeek;
392+
blockers.add('$daysLeft Tag${daysLeft > 1 ? 'e' : ''} mehr empfohlen');
393+
}
394+
395+
if (averageWellnessScore7d < 60) {
396+
blockers
397+
.add('Wellness-Score verbessern (aktuell: ${averageWellnessScore7d.round()}%)');
398+
}
399+
400+
if (isRestingHrTrending) {
401+
blockers.add('Ruhepuls erhöht - Erholung abwarten');
402+
}
403+
404+
return blockers.join(', ');
405+
}
406+
407+
String _getNextPhase() {
408+
return switch (currentPhase) {
409+
ComebackPhase.week1 => 'Woche 2 (70% Intensität)',
410+
ComebackPhase.week2 => 'Woche 3 (85% Intensität)',
411+
ComebackPhase.week3 => 'Woche 4 (100% Intensität)',
412+
ComebackPhase.week4 => 'Normales Training',
413+
ComebackPhase.completed => 'Bereits abgeschlossen',
414+
};
415+
}
416+
339417
ComebackMode copyWith({
340418
bool? isActive,
341419
DateTime? startDate,
@@ -344,6 +422,9 @@ class ComebackMode extends Equatable {
344422
int? baselineRestingHr,
345423
List<WellnessCheckIn>? checkIns,
346424
String? illnessType,
425+
int? detectedFtp,
426+
DateTime? ftpDetectedAt,
427+
String? ftpDetectionMethod,
347428
}) {
348429
return ComebackMode(
349430
isActive: isActive ?? this.isActive,
@@ -353,6 +434,9 @@ class ComebackMode extends Equatable {
353434
baselineRestingHr: baselineRestingHr ?? this.baselineRestingHr,
354435
checkIns: checkIns ?? this.checkIns,
355436
illnessType: illnessType ?? this.illnessType,
437+
detectedFtp: detectedFtp ?? this.detectedFtp,
438+
ftpDetectedAt: ftpDetectedAt ?? this.ftpDetectedAt,
439+
ftpDetectionMethod: ftpDetectionMethod ?? this.ftpDetectionMethod,
356440
);
357441
}
358442

@@ -372,6 +456,11 @@ class ComebackMode extends Equatable {
372456
.toList() ??
373457
[],
374458
illnessType: json['illnessType'] as String?,
459+
detectedFtp: json['detectedFtp'] as int?,
460+
ftpDetectedAt: json['ftpDetectedAt'] != null
461+
? DateTime.parse(json['ftpDetectedAt'] as String)
462+
: null,
463+
ftpDetectionMethod: json['ftpDetectionMethod'] as String?,
375464
);
376465
}
377466

@@ -383,6 +472,9 @@ class ComebackMode extends Equatable {
383472
'baselineRestingHr': baselineRestingHr,
384473
'checkIns': checkIns.map((c) => c.toJson()).toList(),
385474
'illnessType': illnessType,
475+
'detectedFtp': detectedFtp,
476+
'ftpDetectedAt': ftpDetectedAt?.toIso8601String(),
477+
'ftpDetectionMethod': ftpDetectionMethod,
386478
};
387479

388480
@override
@@ -394,5 +486,8 @@ class ComebackMode extends Equatable {
394486
baselineRestingHr,
395487
checkIns,
396488
illnessType,
489+
detectedFtp,
490+
ftpDetectedAt,
491+
ftpDetectionMethod,
397492
];
398493
}

0 commit comments

Comments
 (0)