• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wger-project / flutter / 30854254088

03 Aug 2026 09:21PM UTC coverage: 40.895% (-15.0%) from 55.93%
30854254088

Pull #1260

github

web-flow
Merge d93794de5 into e3affdd4f
Pull Request #1260: WIP: health sync

9980 of 24404 relevant lines covered (40.89%)

1.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.9
/lib/features/routines/models/routine.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c) 2020 - 2026 wger Team
4
 *
5
 * wger Workout Manager is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU Affero General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Affero General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Affero General Public License
16
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
 */
18

19
import 'package:drift/drift.dart' as drift;
20
import 'package:json_annotation/json_annotation.dart';
21
import 'package:wger/core/date.dart';
22
import 'package:wger/core/json.dart';
23
import 'package:wger/database/powersync/database.dart';
24
import 'package:wger/features/exercises/models/exercise.dart';
25
import 'package:wger/features/routines/models/day.dart';
26
import 'package:wger/features/routines/models/day_data.dart';
27
import 'package:wger/features/routines/models/log.dart';
28
import 'package:wger/features/routines/models/session.dart';
29

30
part 'routine.g.dart';
31

32
@JsonSerializable()
33
class Routine {
34
  static const MIN_LENGTH_DESCRIPTION = 0;
35
  static const MAX_LENGTH_DESCRIPTION = 1000;
36

37
  static const MIN_LENGTH_NAME = 3;
38
  static const MAX_LENGTH_NAME = 25;
39

40
  /// In weeks
41
  static const MIN_DURATION = 2;
42
  static const MAX_DURATION = 17;
43
  static const DEFAULT_DURATION = 12;
44

45
  @JsonKey(required: true, includeToJson: false)
46
  int? id;
47

48
  @JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
49
  late DateTime created;
50

51
  @JsonKey(required: true, name: 'name')
52
  late String name;
53

54
  @JsonKey(required: true, name: 'description')
55
  late String description;
56

57
  @JsonKey(required: true, name: 'fit_in_week')
58
  late bool fitInWeek;
59

60
  // The two template flags are server-managed and the Flutter app does not
61
  // expose UI to toggle them, but PowerSync syncs them so the in-memory
62
  // state stays consistent with the backend (and round-trips correctly
63
  // on PATCH writes via [toCompanion]).
64
  @JsonKey(name: 'is_template', defaultValue: false)
65
  late bool isTemplate;
66

67
  @JsonKey(name: 'is_public', defaultValue: false)
68
  late bool isPublic;
69

70
  @JsonKey(required: true, toJson: dateToYYYYMMDD)
71
  late DateTime start;
72

73
  @JsonKey(required: true, toJson: dateToYYYYMMDD)
74
  late DateTime end;
75

76
  @JsonKey(includeFromJson: true, required: false, includeToJson: false)
77
  List<Day> days = [];
78

79
  @JsonKey(includeFromJson: false, includeToJson: false)
80
  List<DayData> dayData = [];
81

82
  @JsonKey(includeFromJson: false, includeToJson: false)
83
  List<DayData> dayDataGym = [];
84

85
  /// Whether the full structure (days, slots, configs and the server-computed
86
  /// dayData) has been loaded from the REST api. This can't be a simple
87
  /// len(days) since that would match new routines.
88
  @JsonKey(includeFromJson: false, includeToJson: false)
89
  bool isHydrated = false;
90

91
  @JsonKey(required: false, includeToJson: false, includeFromJson: false)
92
  List<WorkoutSession> sessions = [];
93

94
  Routine({
17✔
95
    this.id,
96
    DateTime? created,
97
    required this.name,
98
    DateTime? start,
99
    DateTime? end,
100
    this.fitInWeek = false,
101
    this.isTemplate = false,
102
    this.isPublic = false,
103
    String? description,
104
    this.days = const [],
105
    this.dayData = const [],
106
    this.dayDataGym = const [],
107
    this.isHydrated = false,
108
    this.sessions = const [],
109
  }) {
110
    this.created = created ?? DateTime.now();
19✔
111
    this.start = start ?? DateTime.now();
19✔
112
    this.end = end ?? DateTime.now().add(const Duration(days: DEFAULT_DURATION * 7));
21✔
113
    this.description = description ?? '';
17✔
114
  }
115

116
  Routine.empty() {
1✔
117
    name = '';
1✔
118
    description = '';
1✔
119
    created = DateTime.now();
2✔
120
    start = DateTime.now();
2✔
121
    end = DateTime.now().add(const Duration(days: DEFAULT_DURATION * 7));
3✔
122
    fitInWeek = true;
1✔
123
    isTemplate = false;
1✔
124
    isPublic = false;
1✔
125
  }
126

127
  // Boilerplate
128
  factory Routine.fromJson(Map<String, dynamic> json) => _$RoutineFromJson(json);
2✔
129

130
  Map<String, dynamic> toJson() => _$RoutineToJson(this);
2✔
131

132
  Routine copyWith({
1✔
133
    int? id,
134
    DateTime? created,
135
    String? name,
136
    String? description,
137
    bool? fitInWeek,
138
    bool? isTemplate,
139
    bool? isPublic,
140
    DateTime? start,
141
    DateTime? end,
142
    List<Day>? days,
143
    List<DayData>? dayData,
144
    List<DayData>? dayDataGym,
145
    bool? isHydrated,
146
    List<WorkoutSession>? sessions,
147
  }) {
148
    return Routine(
1✔
149
      id: id ?? this.id,
1✔
150
      created: created ?? this.created,
1✔
151
      name: name ?? this.name,
1✔
152
      description: description ?? this.description,
1✔
153
      fitInWeek: fitInWeek ?? this.fitInWeek,
1✔
154
      isTemplate: isTemplate ?? this.isTemplate,
1✔
155
      isPublic: isPublic ?? this.isPublic,
1✔
156
      start: start ?? this.start,
1✔
157
      end: end ?? this.end,
1✔
158
      days: days ?? this.days,
1✔
159
      dayData: dayData ?? this.dayData,
1✔
160
      dayDataGym: dayDataGym ?? this.dayDataGym,
1✔
161
      isHydrated: isHydrated ?? this.isHydrated,
×
162
      sessions: sessions ?? this.sessions,
1✔
163
    );
164
  }
165

166
  RoutineTableCompanion toCompanion() {
×
167
    final routineId = id;
×
168
    if (routineId == null) {
169
      throw StateError('Cannot persist routine without id (creation goes via REST)');
×
170
    }
171
    return RoutineTableCompanion(
×
172
      id: drift.Value(routineId),
×
173
      name: drift.Value(name),
×
174
      description: drift.Value(description),
×
175
      created: drift.Value(created),
×
176
      // `start`/`end` are `DateField` server-side
177
      start: drift.Value(DateTime.utc(start.year, start.month, start.day)),
×
178
      end: drift.Value(DateTime.utc(end.year, end.month, end.day)),
×
179
      isTemplate: drift.Value(isTemplate),
×
180
      isPublic: drift.Value(isPublic),
×
181
      fitInWeek: drift.Value(fitInWeek),
×
182
    );
183
  }
184

185
  List<Log> get logs {
4✔
186
    final out = <Log>[];
4✔
187
    for (final session in sessions) {
8✔
188
      out.addAll(session.logs);
8✔
189
    }
190
    return out;
191
  }
192

193
  int? getIteration({DateTime? date}) {
2✔
194
    date ??= DateTime.now();
×
195

196
    for (final data in dayData) {
4✔
197
      if (data.date.isSameDayAs(date)) {
4✔
198
        return data.iteration;
×
199
      }
200
    }
201
    return null;
202
  }
203

204
  List<DayData> get dayDataCurrentIteration {
2✔
205
    final iteration = getIteration(date: DateTime.now()) ?? 1;
4✔
206
    return dayData.where((data) => data.iteration == iteration).toList();
12✔
207
  }
208

209
  /// Filter out dayData entries with null days as well as duplicated days from
210
  /// the "fixed weekly schedule" toggle.
211
  List<DayData> get dayDataCurrentIterationFiltered {
1✔
212
    final sorted = List<DayData>.from(
1✔
213
      dayDataCurrentIteration.where((dd) => dd.day != null),
4✔
214
    )..sort((a, b) => a.day!.order.compareTo(b.day!.order));
7✔
215

216
    // Filter out entries where the day is the same as the previous one. This
217
    // is necessary because if the user has the "Fixed weekly schedule" option
218
    // enabled, there would be multiple entries for the same day.
219
    final unique = <DayData>[];
1✔
220
    for (final dd in sorted) {
2✔
221
      if (unique.isEmpty || unique.last.day!.id != dd.day!.id) {
7✔
222
        unique.add(dd);
1✔
223
      } else {
224
        // If the day id is the same as the previous, replace the previous
225
        // entry with the current one so the last occurrence is kept.
226
        unique[unique.length - 1] = dd;
×
227
      }
228
    }
229

230
    return unique;
231
  }
232

233
  List<DayData> get dayDataCurrentIterationGym {
1✔
234
    final iteration = getIteration(date: DateTime.now()) ?? 1;
2✔
235
    return dayDataGym.where((data) => data.iteration == iteration).toList();
6✔
236
  }
237

238
  /// Filters the workout logs by exercise and sorts them by date
239
  ///
240
  /// Optionally, filters list so that only unique logs are returned. "Unique"
241
  /// means here that the values are the same, i.e. logs with the same weight,
242
  /// reps, etc. are considered equal. Workout ID, Log ID and date are not
243
  /// considered.
244
  List<Log> filterLogsByExercise(int exerciseId, {bool unique = false}) {
3✔
245
    var out = logs.where((log) => log.exerciseId == exerciseId).toList();
18✔
246

247
    if (unique) {
248
      out = out.toSet().toList();
×
249
    }
250

251
    out.sort((a, b) => b.date.compareTo(a.date));
15✔
252
    return out;
253
  }
254

255
  /// Groups logs by repetition
256
  Map<num, List<Log>> groupLogsByRepetition({
2✔
257
    List<Log>? logs,
258
    filterNullWeights = false,
259
    filterNullReps = false,
260
  }) {
261
    final workoutLogs = logs ?? this.logs;
1✔
262
    final Map<num, List<Log>> groupedLogs = {};
2✔
263

264
    for (final log in workoutLogs) {
4✔
265
      if (log.repetitions == null ||
2✔
266
          (filterNullWeights && log.weight == null) ||
1✔
267
          (filterNullReps && log.repetitions == null)) {
1✔
268
        continue;
269
      }
270

271
      if (!groupedLogs.containsKey(log.repetitions)) {
4✔
272
        groupedLogs[log.repetitions!] = [];
6✔
273
      }
274

275
      groupedLogs[log.repetitions]!.add(log);
6✔
276
    }
277

278
    return groupedLogs;
279
  }
280

281
  Routine replaceExercise(int oldExerciseId, Exercise newExercise) {
×
282
    final updatedRoutine = copyWith(
×
283
      sessions: List<WorkoutSession>.from(sessions),
×
284
      dayData: List<DayData>.from(dayData),
×
285
      dayDataGym: List<DayData>.from(dayDataGym),
×
286
    );
287

288
    for (final session in updatedRoutine.sessions) {
×
289
      for (final log in session.logs) {
×
290
        if (log.exerciseId == oldExerciseId) {
×
291
          log.exerciseId = newExercise.id;
×
292
          log.exercise = newExercise;
×
293
        }
294
      }
295
    }
296

297
    for (final day in updatedRoutine.dayData) {
×
298
      for (final slot in day.slots) {
×
299
        for (final config in slot.setConfigs) {
×
300
          if (config.exerciseId == oldExerciseId) {
×
301
            config.exerciseId = newExercise.id;
×
302
            config.exercise = newExercise;
×
303
          }
304
        }
305
      }
306
    }
307

308
    for (final day in updatedRoutine.dayDataGym) {
×
309
      for (final slot in day.slots) {
×
310
        for (final config in slot.setConfigs) {
×
311
          if (config.exerciseId == oldExerciseId) {
×
312
            config.exerciseId = newExercise.id;
×
313
            config.exercise = newExercise;
×
314
          }
315
        }
316
      }
317
    }
318
    return updatedRoutine;
319
  }
320
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc