Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/database/converters/date_only_text_converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,22 @@ class DateOnlyTextConverter extends TypeConverter<DateTime, String> {
'${value.month.toString().padLeft(2, '0')}-'
'${value.day.toString().padLeft(2, '0')}';
}

class DateTimeTextConverter extends TypeConverter<DateTime, String> {
const DateTimeTextConverter();

@override
DateTime fromSql(String fromDb) {
final day = DateTime.parse(fromDb.substring(0, 19));
return DateTime.utc(day.year, day.month, day.day, day.hour, day.minute, day.second);
}

@override
String toSql(DateTime value) =>
'${value.year.toString().padLeft(4, '0')}-'
'${value.month.toString().padLeft(2, '0')}-'
'${value.day.toString().padLeft(2, '0')}T'
'${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}';
}
5 changes: 3 additions & 2 deletions lib/database/powersync/tables/routines.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,9 @@ class WorkoutSessionTable extends Table {
TextColumn get notes => text().nullable()();
TextColumn get impression => text().map(const WorkoutImpressionConverter())();
TextColumn get timeStart =>
text().named('time_start').nullable().map(const TimeOfDayConverter())();
TextColumn get timeEnd => text().named('time_end').nullable().map(const TimeOfDayConverter())();
text().named('time_start').nullable().map(const DateTimeTextConverter())();
TextColumn get timeEnd =>
text().named('time_end').nullable().map(const DateTimeTextConverter())();
}

const PowersyncWorkoutSessionTable = ps.Table(
Expand Down
4 changes: 2 additions & 2 deletions lib/helpers/routines/validators.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ String? validateWorkoutLogCrossField({
/// - [timeStart] and [timeEnd] must both be set or both be empty,
/// - if both are set, [timeStart] must not be after [timeEnd].
String? validateWorkoutSessionTimes({
required TimeOfDay? timeStart,
required TimeOfDay? timeEnd,
required DateTime? timeStart,
required DateTime? timeEnd,
required AppLocalizations i18n,
}) {
if ((timeStart == null) != (timeEnd == null)) {
Expand Down
35 changes: 21 additions & 14 deletions lib/models/workouts/session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ extension WorkoutImpressionL10n on WorkoutImpression {
}
}

const sessionMaxDuration = Duration(hours: 5);

class WorkoutSession {
/// Inclusive upper bound for [notes]
static const maxNotesChars = 1000;
Expand All @@ -65,10 +67,10 @@ class WorkoutSession {
DateTime date;
WorkoutImpression impression;
late String? notes;
late TimeOfDay? timeStart;
late TimeOfDay? timeEnd;
DateTime? timeStart;
DateTime? timeEnd;

List<Log> logs = [];
List<Log> logs;

WorkoutSession({
this.id,
Expand All @@ -87,8 +89,7 @@ class WorkoutSession {
id: id != null ? drift.Value(id!) : const drift.Value.absent(),
routineId: routineId != null ? drift.Value(routineId) : const drift.Value.absent(),
dayId: dayId != null ? drift.Value(dayId) : const drift.Value.absent(),
// Server-side `date` is a `DateField` (no time, no TZ). We send here the
// calendar day the user picked, packaged as midnight-UTC so it round-trips
// Server-side `date` is a `DateTimeField`. It round-trips
// through PowerSync's ISO8601 wire format and lands on the right day on
// the server.
date: drift.Value(DateTime.utc(date.year, date.month, date.day)),
Expand All @@ -99,16 +100,19 @@ class WorkoutSession {
);
}

/// Calculates the duration between [timeStart] and [timeEnd].
/// Returns null if either is missing.
Duration? get duration {
if (timeStart == null || timeEnd == null) {
final start = timeStart;
final end = timeEnd;
if (start == null || end == null) {
return null;
}
final now = DateTime.now();
final startDate = DateTime(now.year, now.month, now.day, timeStart!.hour, timeStart!.minute);
final endDate = DateTime(now.year, now.month, now.day, timeEnd!.hour, timeEnd!.minute);
return endDate.difference(startDate);

return end.difference(start);
}

/// Returns a localized string representation of the duration (e.g., "2h 30m").
String durationTxt(BuildContext context) {
final duration = this.duration;
if (duration == null) {
Expand All @@ -119,14 +123,17 @@ class WorkoutSession {
).durationHoursMinutes(duration.inHours, duration.inMinutes.remainder(60));
}

/// Returns a formatted string: "2h 30m (09:00 AM - 11:30 AM)".
String durationTxtWithStartEnd(BuildContext context) {
final duration = this.duration;
if (duration == null) {
final start = timeStart;
final end = timeEnd;
if (end == null || start == null) {
return '-/-';
}

final startTime = MaterialLocalizations.of(context).formatTimeOfDay(timeStart!);
final endTime = MaterialLocalizations.of(context).formatTimeOfDay(timeEnd!);
final localizations = MaterialLocalizations.of(context);
final startTime = localizations.formatTimeOfDay(TimeOfDay.fromDateTime(start));
final endTime = localizations.formatTimeOfDay(TimeOfDay.fromDateTime(end));

return '${durationTxt(context)} ($startTime - $endTime)';
}
Expand Down
8 changes: 4 additions & 4 deletions lib/providers/gym_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class GymModeState {
final List<PageEntry> pages;
final int currentPage;

final TimeOfDay startTime;
final DateTime startTime;
final DateTime validUntil;

// User settings
Expand Down Expand Up @@ -199,9 +199,9 @@ class GymModeState {
Routine? routine,

DateTime? validUntil,
TimeOfDay? startTime,
DateTime? startTime,
}) : validUntil = validUntil ?? clock.now().add(DEFAULT_DURATION),
startTime = startTime ?? TimeOfDay.fromDateTime(clock.now()) {
startTime = startTime ?? clock.now() {
if (dayId != null) {
this.dayId = dayId;
}
Expand All @@ -225,7 +225,7 @@ class GymModeState {
int? dayId,
int? iteration,
DateTime? validUntil,
TimeOfDay? startTime,
DateTime? startTime,
Routine? routine,

// User settings
Expand Down
27 changes: 23 additions & 4 deletions lib/providers/workout_logs_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/models/workouts/session.dart';

Expand Down Expand Up @@ -110,18 +111,22 @@ class WorkoutLogRepository {
await _db.transaction(() async {
if (log.sessionId == null) {
final dayMidnightUtc = DateTime.utc(log.date.year, log.date.month, log.date.day);
final windowStart = log.date.subtract(sessionMaxDuration);

final existing =
await (_db.select(_db.workoutSessionTable)
..where(
(t) => t.routineId.equals(log.routineId) & t.date.equalsValue(dayMidnightUtc),
(t) =>
t.routineId.equals(log.routineId) &
t.timeStart.isBiggerOrEqualValue(dateToUtcIso8601(windowStart)) &
t.timeStart.isSmallerOrEqualValue(dateToUtcIso8601(log.date)),
)
..orderBy([(t) => OrderingTerm.desc(t.timeStart)])
..limit(1))
.getSingleOrNull();

if (existing != null) {
log.sessionId = existing.id;
} else {
if (existing == null) {
// No session at all -> create new session
final newSession = WorkoutSession(
routineId: log.routineId,
date: dayMidnightUtc,
Expand All @@ -131,6 +136,20 @@ class WorkoutLogRepository {
.insertReturning(newSession.toCompanion());
log.sessionId = inserted.id;
_logger.finer('Created lazy session ${inserted.id} for log');
} else if (existing.timeEnd != null && log.date.isAfter(existing.timeEnd!)) {
// Session found but already closed -> create new session
final newSession = WorkoutSession(
routineId: log.routineId,
date: dayMidnightUtc,
);
final inserted = await _db
.into(_db.workoutSessionTable)
.insertReturning(newSession.toCompanion());
log.sessionId = inserted.id;
_logger.finer('Created lazy session ${inserted.id} for log');
} else {
// Session is open and within window -> reuse it
log.sessionId = existing.id;
}
}

Expand Down
3 changes: 2 additions & 1 deletion lib/widgets/dashboard/calendar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ class _DashboardCalendarWidgetState extends riverpod.ConsumerState<DashboardCale
events.putIfAbsent(date, () => []);
var time = '';
if (session.timeStart != null && session.timeEnd != null) {
time = '(${timeToString(session.timeStart)} - ${timeToString(session.timeEnd)})';
time =
'(${timeToString(TimeOfDay.fromDateTime(session.timeStart!))} - ${timeToString(TimeOfDay.fromDateTime(session.timeEnd!))})';
}
events[date]!.add(
Event(
Expand Down
33 changes: 29 additions & 4 deletions lib/widgets/routines/forms/session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,44 @@ class _SessionFormState extends ConsumerState<SessionForm> {
Flexible(
child: TimeInputWidget(
key: const ValueKey('time-start'),
value: widget._session.timeStart,
value: widget._session.timeStart != null
? TimeOfDay.fromDateTime(widget._session.timeStart!)
: null,
labelText: AppLocalizations.of(context).timeStart,
onCleared: () => widget._session.timeStart = null,
onChanged: (time) => widget._session.timeStart = time,

onChanged: (time) {
final now = clock.now();
widget._session.timeStart = (widget._session.timeStart ?? now).copyWith(
hour: time.hour,
minute: time.minute,
);
},
),
),
Flexible(
child: TimeInputWidget(
key: const ValueKey('time-end'),
value: widget._session.timeEnd,
value: widget._session.timeEnd != null
? TimeOfDay.fromDateTime(widget._session.timeEnd!)
: null,
labelText: AppLocalizations.of(context).timeEnd,
onCleared: () => widget._session.timeEnd = null,
onChanged: (time) => widget._session.timeEnd = time,

onChanged: (time) {
final now = clock.now();
final start = widget._session.timeStart ?? now;
final end = (widget._session.timeEnd ?? now).copyWith(
hour: time.hour,
minute: time.minute,
);

if (end.isBefore(start)) {
widget._session.timeEnd = end.add(const Duration(days: 1));
} else {
widget._session.timeEnd = end;
}
},
),
),
],
Expand Down
9 changes: 7 additions & 2 deletions lib/widgets/routines/gym_mode/session_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,19 @@ class _SessionPageState extends ConsumerState<SessionPage> {
value: ref.watch(workoutSessionProvider),
loggerName: 'SessionPage',
data: (sessions) {
final nowDt = clock.now();
final start = gymState.startTime;
final session = sessions.firstWhere(
(s) => s.date.isSameDayAs(clock.now()) && s.routineId == gymState.routine.id,
(s) =>
s.timeEnd == null &&
s.routineId == gymState.routine.id &&
nowDt.difference(s.timeStart!) <= sessionMaxDuration,
orElse: () => WorkoutSession(
dayId: gymState.dayId,
date: clock.now(),
routineId: gymState.routine.id,
timeStart: gymState.startTime,
timeEnd: TimeOfDay.fromDateTime(clock.now()),
timeEnd: nowDt,
),
);

Expand Down