diff --git a/lib/core/network/auth_notifier.g.dart b/lib/core/network/auth_notifier.g.dart index 86ce6eeff..7d29a5ac1 100644 --- a/lib/core/network/auth_notifier.g.dart +++ b/lib/core/network/auth_notifier.g.dart @@ -32,7 +32,7 @@ final class AuthNotifierProvider extends $AsyncNotifierProvider AuthNotifier(); } -String _$authNotifierHash() => r'19bf6776a00c5a7374ddc918f55709ee3a193b3f'; +String _$authNotifierHash() => r'2e848f93a2dec9ca9c98ce85e860864c58fa0a2b'; abstract class _$AuthNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/core/widgets/dashboard/calendar.dart b/lib/core/widgets/dashboard/calendar.dart index 719a2a4ae..5d2c16394 100644 --- a/lib/core/widgets/dashboard/calendar.dart +++ b/lib/core/widgets/dashboard/calendar.dart @@ -107,11 +107,12 @@ class _DashboardCalendarWidgetState extends riverpod.ConsumerState []); var time = ''; - if (session.timeStart != null && session.timeEnd != null) { - time = '(${timeToString(session.timeStart)} - ${timeToString(session.timeEnd)})'; + if (session.datetimeEnd != null) { + time = + '(${timeToString(TimeOfDay.fromDateTime(session.datetimeStart))} - ${timeToString(TimeOfDay.fromDateTime(session.datetimeEnd!))})'; } events[date]!.add( Event( diff --git a/lib/database/converters/date_only_text_converter.dart b/lib/database/converters/date_only_text_converter.dart index 7507c216b..7735d8652 100644 --- a/lib/database/converters/date_only_text_converter.dart +++ b/lib/database/converters/date_only_text_converter.dart @@ -42,3 +42,18 @@ class DateOnlyTextConverter extends TypeConverter { '${value.month.toString().padLeft(2, '0')}-' '${value.day.toString().padLeft(2, '0')}'; } + +/// Stores a moment as UTC ISO8601, the same wire format the server speaks. +/// +/// Keeping every row in UTC with an identical layout means a plain string +/// comparison on the column is also a comparison in time, which the session +/// lookup in the log repository relies on. +class DateTimeTextConverter extends TypeConverter { + const DateTimeTextConverter(); + + @override + DateTime fromSql(String fromDb) => DateTime.parse(fromDb).toLocal(); + + @override + String toSql(DateTime value) => value.toUtc().toIso8601String(); +} diff --git a/lib/database/powersync/database.g.dart b/lib/database/powersync/database.g.dart index 18dbe7b23..552202159 100644 --- a/lib/database/powersync/database.g.dart +++ b/lib/database/powersync/database.g.dart @@ -6273,14 +6273,6 @@ class $WorkoutSessionTableTable extends WorkoutSessionTable type: DriftSqlType.int, requiredDuringInsert: false, ); - @override - late final GeneratedColumnWithTypeConverter date = GeneratedColumn( - 'date', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ).withConverter($WorkoutSessionTableTable.$converterdate); static const VerificationMeta _notesMeta = const VerificationMeta('notes'); @override late final GeneratedColumn notes = GeneratedColumn( @@ -6302,6 +6294,36 @@ class $WorkoutSessionTableTable extends WorkoutSessionTable $WorkoutSessionTableTable.$converterimpression, ); @override + late final GeneratedColumnWithTypeConverter datetimeStart = + GeneratedColumn( + 'datetime_start', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $WorkoutSessionTableTable.$converterdatetimeStartn, + ); + @override + late final GeneratedColumnWithTypeConverter datetimeEnd = + GeneratedColumn( + 'datetime_end', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $WorkoutSessionTableTable.$converterdatetimeEndn, + ); + @override + late final GeneratedColumnWithTypeConverter date = GeneratedColumn( + 'date', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter($WorkoutSessionTableTable.$converterdaten); + @override late final GeneratedColumnWithTypeConverter timeStart = GeneratedColumn( 'time_start', @@ -6325,9 +6347,11 @@ class $WorkoutSessionTableTable extends WorkoutSessionTable id, routineId, dayId, - date, notes, impression, + datetimeStart, + datetimeEnd, + date, timeStart, timeEnd, ]; @@ -6372,34 +6396,46 @@ class $WorkoutSessionTableTable extends WorkoutSessionTable @override WorkoutSession map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return WorkoutSession( + return WorkoutSession.fromDb( id: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}id'], )!, + routineId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}routine_id'], + ), dayId: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}day_id'], ), - routineId: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}routine_id'], + notes: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}notes'], ), - date: $WorkoutSessionTableTable.$converterdate.fromSql( + impression: $WorkoutSessionTableTable.$converterimpression.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}date'], + data['${effectivePrefix}impression'], )!, ), - impression: $WorkoutSessionTableTable.$converterimpression.fromSql( + datetimeStart: $WorkoutSessionTableTable.$converterdatetimeStartn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}impression'], - )!, + data['${effectivePrefix}datetime_start'], + ), ), - notes: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}notes'], + datetimeEnd: $WorkoutSessionTableTable.$converterdatetimeEndn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}datetime_end'], + ), + ), + date: $WorkoutSessionTableTable.$converterdaten.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}date'], + ), ), timeStart: $WorkoutSessionTableTable.$convertertimeStartn.fromSql( attachedDatabase.typeMapping.read( @@ -6421,9 +6457,20 @@ class $WorkoutSessionTableTable extends WorkoutSessionTable return $WorkoutSessionTableTable(attachedDatabase, alias); } - static TypeConverter $converterdate = const DateOnlyTextConverter(); static TypeConverter $converterimpression = const WorkoutImpressionConverter(); + static TypeConverter $converterdatetimeStart = const DateTimeTextConverter(); + static TypeConverter $converterdatetimeStartn = NullAwareTypeConverter.wrap( + $converterdatetimeStart, + ); + static TypeConverter $converterdatetimeEnd = const DateTimeTextConverter(); + static TypeConverter $converterdatetimeEndn = NullAwareTypeConverter.wrap( + $converterdatetimeEnd, + ); + static TypeConverter $converterdate = const DateOnlyTextConverter(); + static TypeConverter $converterdaten = NullAwareTypeConverter.wrap( + $converterdate, + ); static TypeConverter $convertertimeStart = const TimeOfDayConverter(); static TypeConverter $convertertimeStartn = NullAwareTypeConverter.wrap( $convertertimeStart, @@ -6438,9 +6485,11 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { final Value id; final Value routineId; final Value dayId; - final Value date; final Value notes; final Value impression; + final Value datetimeStart; + final Value datetimeEnd; + final Value date; final Value timeStart; final Value timeEnd; final Value rowid; @@ -6448,9 +6497,11 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { this.id = const Value.absent(), this.routineId = const Value.absent(), this.dayId = const Value.absent(), - this.date = const Value.absent(), this.notes = const Value.absent(), this.impression = const Value.absent(), + this.datetimeStart = const Value.absent(), + this.datetimeEnd = const Value.absent(), + this.date = const Value.absent(), this.timeStart = const Value.absent(), this.timeEnd = const Value.absent(), this.rowid = const Value.absent(), @@ -6459,21 +6510,24 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { this.id = const Value.absent(), this.routineId = const Value.absent(), this.dayId = const Value.absent(), - required DateTime date, this.notes = const Value.absent(), required WorkoutImpression impression, + this.datetimeStart = const Value.absent(), + this.datetimeEnd = const Value.absent(), + this.date = const Value.absent(), this.timeStart = const Value.absent(), this.timeEnd = const Value.absent(), this.rowid = const Value.absent(), - }) : date = Value(date), - impression = Value(impression); + }) : impression = Value(impression); static Insertable custom({ Expression? id, Expression? routineId, Expression? dayId, - Expression? date, Expression? notes, Expression? impression, + Expression? datetimeStart, + Expression? datetimeEnd, + Expression? date, Expression? timeStart, Expression? timeEnd, Expression? rowid, @@ -6482,9 +6536,11 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { if (id != null) 'id': id, if (routineId != null) 'routine_id': routineId, if (dayId != null) 'day_id': dayId, - if (date != null) 'date': date, if (notes != null) 'notes': notes, if (impression != null) 'impression': impression, + if (datetimeStart != null) 'datetime_start': datetimeStart, + if (datetimeEnd != null) 'datetime_end': datetimeEnd, + if (date != null) 'date': date, if (timeStart != null) 'time_start': timeStart, if (timeEnd != null) 'time_end': timeEnd, if (rowid != null) 'rowid': rowid, @@ -6495,9 +6551,11 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { Value? id, Value? routineId, Value? dayId, - Value? date, Value? notes, Value? impression, + Value? datetimeStart, + Value? datetimeEnd, + Value? date, Value? timeStart, Value? timeEnd, Value? rowid, @@ -6506,9 +6564,11 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { id: id ?? this.id, routineId: routineId ?? this.routineId, dayId: dayId ?? this.dayId, - date: date ?? this.date, notes: notes ?? this.notes, impression: impression ?? this.impression, + datetimeStart: datetimeStart ?? this.datetimeStart, + datetimeEnd: datetimeEnd ?? this.datetimeEnd, + date: date ?? this.date, timeStart: timeStart ?? this.timeStart, timeEnd: timeEnd ?? this.timeEnd, rowid: rowid ?? this.rowid, @@ -6527,11 +6587,6 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { if (dayId.present) { map['day_id'] = Variable(dayId.value); } - if (date.present) { - map['date'] = Variable( - $WorkoutSessionTableTable.$converterdate.toSql(date.value), - ); - } if (notes.present) { map['notes'] = Variable(notes.value); } @@ -6540,6 +6595,25 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { $WorkoutSessionTableTable.$converterimpression.toSql(impression.value), ); } + if (datetimeStart.present) { + map['datetime_start'] = Variable( + $WorkoutSessionTableTable.$converterdatetimeStartn.toSql( + datetimeStart.value, + ), + ); + } + if (datetimeEnd.present) { + map['datetime_end'] = Variable( + $WorkoutSessionTableTable.$converterdatetimeEndn.toSql( + datetimeEnd.value, + ), + ); + } + if (date.present) { + map['date'] = Variable( + $WorkoutSessionTableTable.$converterdaten.toSql(date.value), + ); + } if (timeStart.present) { map['time_start'] = Variable( $WorkoutSessionTableTable.$convertertimeStartn.toSql(timeStart.value), @@ -6562,9 +6636,11 @@ class WorkoutSessionTableCompanion extends UpdateCompanion { ..write('id: $id, ') ..write('routineId: $routineId, ') ..write('dayId: $dayId, ') - ..write('date: $date, ') ..write('notes: $notes, ') ..write('impression: $impression, ') + ..write('datetimeStart: $datetimeStart, ') + ..write('datetimeEnd: $datetimeEnd, ') + ..write('date: $date, ') ..write('timeStart: $timeStart, ') ..write('timeEnd: $timeEnd, ') ..write('rowid: $rowid') @@ -17659,9 +17735,11 @@ typedef $$WorkoutSessionTableTableCreateCompanionBuilder = Value id, Value routineId, Value dayId, - required DateTime date, Value notes, required WorkoutImpression impression, + Value datetimeStart, + Value datetimeEnd, + Value date, Value timeStart, Value timeEnd, Value rowid, @@ -17671,9 +17749,11 @@ typedef $$WorkoutSessionTableTableUpdateCompanionBuilder = Value id, Value routineId, Value dayId, - Value date, Value notes, Value impression, + Value datetimeStart, + Value datetimeEnd, + Value date, Value timeStart, Value timeEnd, Value rowid, @@ -17703,11 +17783,6 @@ class $$WorkoutSessionTableTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters get date => $composableBuilder( - column: $table.date, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); - ColumnFilters get notes => $composableBuilder( column: $table.notes, builder: (column) => ColumnFilters(column), @@ -17719,6 +17794,22 @@ class $$WorkoutSessionTableTableFilterComposer builder: (column) => ColumnWithTypeConverterFilters(column), ); + ColumnWithTypeConverterFilters get datetimeStart => + $composableBuilder( + column: $table.datetimeStart, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get datetimeEnd => $composableBuilder( + column: $table.datetimeEnd, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get date => $composableBuilder( + column: $table.date, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + ColumnWithTypeConverterFilters get timeStart => $composableBuilder( column: $table.timeStart, builder: (column) => ColumnWithTypeConverterFilters(column), @@ -17754,11 +17845,6 @@ class $$WorkoutSessionTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get date => $composableBuilder( - column: $table.date, - builder: (column) => ColumnOrderings(column), - ); - ColumnOrderings get notes => $composableBuilder( column: $table.notes, builder: (column) => ColumnOrderings(column), @@ -17769,6 +17855,21 @@ class $$WorkoutSessionTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get datetimeStart => $composableBuilder( + column: $table.datetimeStart, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get datetimeEnd => $composableBuilder( + column: $table.datetimeEnd, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get date => $composableBuilder( + column: $table.date, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get timeStart => $composableBuilder( column: $table.timeStart, builder: (column) => ColumnOrderings(column), @@ -17798,9 +17899,6 @@ class $$WorkoutSessionTableTableAnnotationComposer GeneratedColumn get dayId => $composableBuilder(column: $table.dayId, builder: (column) => column); - GeneratedColumnWithTypeConverter get date => - $composableBuilder(column: $table.date, builder: (column) => column); - GeneratedColumn get notes => $composableBuilder(column: $table.notes, builder: (column) => column); @@ -17809,6 +17907,19 @@ class $$WorkoutSessionTableTableAnnotationComposer builder: (column) => column, ); + GeneratedColumnWithTypeConverter get datetimeStart => $composableBuilder( + column: $table.datetimeStart, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get datetimeEnd => $composableBuilder( + column: $table.datetimeEnd, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get date => + $composableBuilder(column: $table.date, builder: (column) => column); + GeneratedColumnWithTypeConverter get timeStart => $composableBuilder(column: $table.timeStart, builder: (column) => column); @@ -17856,9 +17967,11 @@ class $$WorkoutSessionTableTableTableManager Value id = const Value.absent(), Value routineId = const Value.absent(), Value dayId = const Value.absent(), - Value date = const Value.absent(), Value notes = const Value.absent(), Value impression = const Value.absent(), + Value datetimeStart = const Value.absent(), + Value datetimeEnd = const Value.absent(), + Value date = const Value.absent(), Value timeStart = const Value.absent(), Value timeEnd = const Value.absent(), Value rowid = const Value.absent(), @@ -17866,9 +17979,11 @@ class $$WorkoutSessionTableTableTableManager id: id, routineId: routineId, dayId: dayId, - date: date, notes: notes, impression: impression, + datetimeStart: datetimeStart, + datetimeEnd: datetimeEnd, + date: date, timeStart: timeStart, timeEnd: timeEnd, rowid: rowid, @@ -17878,9 +17993,11 @@ class $$WorkoutSessionTableTableTableManager Value id = const Value.absent(), Value routineId = const Value.absent(), Value dayId = const Value.absent(), - required DateTime date, Value notes = const Value.absent(), required WorkoutImpression impression, + Value datetimeStart = const Value.absent(), + Value datetimeEnd = const Value.absent(), + Value date = const Value.absent(), Value timeStart = const Value.absent(), Value timeEnd = const Value.absent(), Value rowid = const Value.absent(), @@ -17888,9 +18005,11 @@ class $$WorkoutSessionTableTableTableManager id: id, routineId: routineId, dayId: dayId, - date: date, notes: notes, impression: impression, + datetimeStart: datetimeStart, + datetimeEnd: datetimeEnd, + date: date, timeStart: timeStart, timeEnd: timeEnd, rowid: rowid, diff --git a/lib/database/powersync/tables/routines.dart b/lib/database/powersync/tables/routines.dart index 56d962530..307382b87 100644 --- a/lib/database/powersync/tables/routines.dart +++ b/lib/database/powersync/tables/routines.dart @@ -112,7 +112,7 @@ const PowersyncWorkoutLogTable = ps.Table( ], ); -@UseRowClass(WorkoutSession) +@UseRowClass(WorkoutSession, constructor: 'fromDb') class WorkoutSessionTable extends Table { @override String get tableName => 'manager_workoutsession'; @@ -120,9 +120,20 @@ class WorkoutSessionTable extends Table { TextColumn get id => text().clientDefault(() => ps.uuid.v7())(); IntColumn get routineId => integer().named('routine_id').nullable()(); IntColumn get dayId => integer().named('day_id').nullable()(); - TextColumn get date => text().map(const DateOnlyTextConverter())(); TextColumn get notes => text().nullable()(); TextColumn get impression => text().map(const WorkoutImpressionConverter())(); + + // Nullable, and permanently so: rows that were replicated before 2.7 have no + // such key in the stored JSON and read as NULL for as long as that local + // database lives. WorkoutSession.fromDb rebuilds them from the columns below. + TextColumn get datetimeStart => + text().named('datetime_start').nullable().map(const DateTimeTextConverter())(); + TextColumn get datetimeEnd => + text().named('datetime_end').nullable().map(const DateTimeTextConverter())(); + + // Pre-2.7 shape, only ever read. Remove once MIN_APP_VERSION has passed the + // versions that wrote them. + TextColumn get date => text().nullable().map(const DateOnlyTextConverter())(); TextColumn get timeStart => text().named('time_start').nullable().map(const TimeOfDayConverter())(); TextColumn get timeEnd => text().named('time_end').nullable().map(const TimeOfDayConverter())(); @@ -133,9 +144,11 @@ const PowersyncWorkoutSessionTable = ps.Table( [ ps.Column.integer('routine_id'), ps.Column.integer('day_id'), - ps.Column.text('date'), ps.Column.text('notes'), ps.Column.text('impression'), + ps.Column.text('datetime_start'), + ps.Column.text('datetime_end'), + ps.Column.text('date'), ps.Column.text('time_start'), ps.Column.text('time_end'), ], diff --git a/lib/features/nutrition/providers/nutrition_notifier.g.dart b/lib/features/nutrition/providers/nutrition_notifier.g.dart index 7d78e5995..15815203f 100644 --- a/lib/features/nutrition/providers/nutrition_notifier.g.dart +++ b/lib/features/nutrition/providers/nutrition_notifier.g.dart @@ -33,7 +33,7 @@ final class NutritionNotifierProvider NutritionNotifier create() => NutritionNotifier(); } -String _$nutritionNotifierHash() => r'ecc463d68d5eae2df4c5e73e43e5cf214f6e1c8a'; +String _$nutritionNotifierHash() => r'd0db2f8f3853bd38ae28913cbb3256753e783f6c'; abstract class _$NutritionNotifier extends $StreamNotifier { Stream build(); diff --git a/lib/features/routines/models/session.dart b/lib/features/routines/models/session.dart index cb65f26b2..5ff0ccbba 100644 --- a/lib/features/routines/models/session.dart +++ b/lib/features/routines/models/session.dart @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import 'package:clock/clock.dart'; import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -56,6 +57,9 @@ extension WorkoutImpressionL10n on WorkoutImpression { } } +/// How long after its start an ongoing session still picks up new logs +const sessionMaxDuration = Duration(hours: 5); + @freezed class WorkoutSession with _$WorkoutSession { /// Inclusive upper bound for [notes] @@ -69,15 +73,13 @@ class WorkoutSession with _$WorkoutSession { @override final int? dayId; @override - final DateTime date; - @override final WorkoutImpression impression; @override final String? notes; @override - final TimeOfDay? timeStart; + final DateTime datetimeStart; @override - final TimeOfDay? timeEnd; + final DateTime? datetimeEnd; @override final List logs; @@ -85,42 +87,86 @@ class WorkoutSession with _$WorkoutSession { this.id, this.dayId, required this.routineId, - required this.date, + required this.datetimeStart, + this.datetimeEnd, this.impression = WorkoutImpression.neutral, this.notes = '', - this.timeStart, - this.timeEnd, this.logs = const [], }); + /// Builds the model from a database row. + /// + /// Rows that were replicated before 2.7 have no `datetime_start` in their + /// stored JSON and read as NULL, for as long as that local database lives. + /// They are rebuilt from the pre-2.7 date and time columns. Application code + /// uses the default constructor. + factory WorkoutSession.fromDb({ + String? id, + int? routineId, + int? dayId, + String? notes, + WorkoutImpression impression = WorkoutImpression.neutral, + DateTime? datetimeStart, + DateTime? datetimeEnd, + DateTime? date, + TimeOfDay? timeStart, + TimeOfDay? timeEnd, + }) { + DateTime on(DateTime day, TimeOfDay? time) => + DateTime(day.year, day.month, day.day, time?.hour ?? 0, time?.minute ?? 0); + + final start = datetimeStart ?? (date == null ? clock.now() : on(date, timeStart)); + + var end = datetimeEnd; + if (end == null && date != null && timeEnd != null) { + end = on(date, timeEnd); + // An end before the start means the session ran past midnight + if (end.isBefore(start)) { + end = end.add(const Duration(days: 1)); + } + } + + return WorkoutSession( + id: id, + routineId: routineId, + dayId: dayId, + notes: notes, + impression: impression, + datetimeStart: start, + datetimeEnd: end, + ); + } + WorkoutSessionTableCompanion toCompanion() { return WorkoutSessionTableCompanion( id: id != null ? drift.Value(id!) : const drift.Value.absent(), routineId: drift.Value(routineId), dayId: drift.Value(dayId), - // 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 - // 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)), notes: drift.Value(notes), impression: drift.Value(impression), - // Explicit NULL, not absent: clearing a time has to clear the column too - timeStart: drift.Value(timeStart), - timeEnd: drift.Value(timeEnd), + // Explicit NULL, not absent: clearing the end has to clear the column too. + // The pre-2.7 columns are never written again, only read by fromDb. + datetimeStart: drift.Value(datetimeStart), + datetimeEnd: drift.Value(datetimeEnd), ); } + /// The calendar day this session counts for, e.g. for the dashboard calendar + /// + /// A session that runs over midnight counts for the day it started on. + DateTime get localDay => DateTime(datetimeStart.year, datetimeStart.month, datetimeStart.day); + + /// Duration between start and end, null while the session is still open Duration? get duration { - if (timeStart == null || timeEnd == null) { + final end = datetimeEnd; + if (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(datetimeStart); } + /// Returns a localized string representation of the duration (e.g., "2h 30m"). String durationTxt(BuildContext context) { final duration = this.duration; if (duration == null) { @@ -131,14 +177,16 @@ class WorkoutSession with _$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 end = datetimeEnd; + if (end == 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(datetimeStart)); + final endTime = localizations.formatTimeOfDay(TimeOfDay.fromDateTime(end)); return '${durationTxt(context)} ($startTime - $endTime)'; } diff --git a/lib/features/routines/models/session.freezed.dart b/lib/features/routines/models/session.freezed.dart index 2fe4e547b..f1b1be07c 100644 --- a/lib/features/routines/models/session.freezed.dart +++ b/lib/features/routines/models/session.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; mixin _$WorkoutSession { /// Client-generated UUID, is `null` only before the first persist - String? get id; int? get routineId; int? get dayId; DateTime get date; WorkoutImpression get impression; String? get notes; TimeOfDay? get timeStart; TimeOfDay? get timeEnd; List get logs; + String? get id; int? get routineId; int? get dayId; WorkoutImpression get impression; String? get notes; DateTime get datetimeStart; DateTime? get datetimeEnd; List get logs; /// Create a copy of WorkoutSession /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -26,16 +26,16 @@ $WorkoutSessionCopyWith get copyWith => _$WorkoutSessionCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is WorkoutSession&&(identical(other.id, id) || other.id == id)&&(identical(other.routineId, routineId) || other.routineId == routineId)&&(identical(other.dayId, dayId) || other.dayId == dayId)&&(identical(other.date, date) || other.date == date)&&(identical(other.impression, impression) || other.impression == impression)&&(identical(other.notes, notes) || other.notes == notes)&&(identical(other.timeStart, timeStart) || other.timeStart == timeStart)&&(identical(other.timeEnd, timeEnd) || other.timeEnd == timeEnd)&&const DeepCollectionEquality().equals(other.logs, logs)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is WorkoutSession&&(identical(other.id, id) || other.id == id)&&(identical(other.routineId, routineId) || other.routineId == routineId)&&(identical(other.dayId, dayId) || other.dayId == dayId)&&(identical(other.impression, impression) || other.impression == impression)&&(identical(other.notes, notes) || other.notes == notes)&&(identical(other.datetimeStart, datetimeStart) || other.datetimeStart == datetimeStart)&&(identical(other.datetimeEnd, datetimeEnd) || other.datetimeEnd == datetimeEnd)&&const DeepCollectionEquality().equals(other.logs, logs)); } @override -int get hashCode => Object.hash(runtimeType,id,routineId,dayId,date,impression,notes,timeStart,timeEnd,const DeepCollectionEquality().hash(logs)); +int get hashCode => Object.hash(runtimeType,id,routineId,dayId,impression,notes,datetimeStart,datetimeEnd,const DeepCollectionEquality().hash(logs)); @override String toString() { - return 'WorkoutSession(id: $id, routineId: $routineId, dayId: $dayId, date: $date, impression: $impression, notes: $notes, timeStart: $timeStart, timeEnd: $timeEnd, logs: $logs)'; + return 'WorkoutSession(id: $id, routineId: $routineId, dayId: $dayId, impression: $impression, notes: $notes, datetimeStart: $datetimeStart, datetimeEnd: $datetimeEnd, logs: $logs)'; } @@ -46,7 +46,7 @@ abstract mixin class $WorkoutSessionCopyWith<$Res> { factory $WorkoutSessionCopyWith(WorkoutSession value, $Res Function(WorkoutSession) _then) = _$WorkoutSessionCopyWithImpl; @useResult $Res call({ - String? id, int? dayId, int? routineId, DateTime date, WorkoutImpression impression, String? notes, TimeOfDay? timeStart, TimeOfDay? timeEnd, List logs + String? id, int? dayId, int? routineId, DateTime datetimeStart, DateTime? datetimeEnd, WorkoutImpression impression, String? notes, List logs }); @@ -63,17 +63,16 @@ class _$WorkoutSessionCopyWithImpl<$Res> /// Create a copy of WorkoutSession /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? dayId = freezed,Object? routineId = freezed,Object? date = null,Object? impression = null,Object? notes = freezed,Object? timeStart = freezed,Object? timeEnd = freezed,Object? logs = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? dayId = freezed,Object? routineId = freezed,Object? datetimeStart = null,Object? datetimeEnd = freezed,Object? impression = null,Object? notes = freezed,Object? logs = null,}) { return _then(WorkoutSession( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String?,dayId: freezed == dayId ? _self.dayId : dayId // ignore: cast_nullable_to_non_nullable as int?,routineId: freezed == routineId ? _self.routineId : routineId // ignore: cast_nullable_to_non_nullable -as int?,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable -as DateTime,impression: null == impression ? _self.impression : impression // ignore: cast_nullable_to_non_nullable +as int?,datetimeStart: null == datetimeStart ? _self.datetimeStart : datetimeStart // ignore: cast_nullable_to_non_nullable +as DateTime,datetimeEnd: freezed == datetimeEnd ? _self.datetimeEnd : datetimeEnd // ignore: cast_nullable_to_non_nullable +as DateTime?,impression: null == impression ? _self.impression : impression // ignore: cast_nullable_to_non_nullable as WorkoutImpression,notes: freezed == notes ? _self.notes : notes // ignore: cast_nullable_to_non_nullable -as String?,timeStart: freezed == timeStart ? _self.timeStart : timeStart // ignore: cast_nullable_to_non_nullable -as TimeOfDay?,timeEnd: freezed == timeEnd ? _self.timeEnd : timeEnd // ignore: cast_nullable_to_non_nullable -as TimeOfDay?,logs: null == logs ? _self.logs : logs // ignore: cast_nullable_to_non_nullable +as String?,logs: null == logs ? _self.logs : logs // ignore: cast_nullable_to_non_nullable as List, )); } diff --git a/lib/features/routines/providers/gym_log_notifier.g.dart b/lib/features/routines/providers/gym_log_notifier.g.dart index aa12a3505..0b99b311d 100644 --- a/lib/features/routines/providers/gym_log_notifier.g.dart +++ b/lib/features/routines/providers/gym_log_notifier.g.dart @@ -40,7 +40,7 @@ final class GymLogNotifierProvider extends $NotifierProvider r'2a9eb1f27bcc5d72a893843ddfaa077a32f8ed26'; +String _$gymLogNotifierHash() => r'f19f65118fc2746149178debd2f5fcb1cdfcab3c'; abstract class _$GymLogNotifier extends $Notifier { Log? build(); diff --git a/lib/features/routines/providers/gym_state_notifier.g.dart b/lib/features/routines/providers/gym_state_notifier.g.dart index 87afe3caf..691ca7d7f 100644 --- a/lib/features/routines/providers/gym_state_notifier.g.dart +++ b/lib/features/routines/providers/gym_state_notifier.g.dart @@ -40,7 +40,7 @@ final class GymStateNotifierProvider extends $NotifierProvider r'018852d495cd23fd0f765591a02b9369764c2161'; +String _$gymStateNotifierHash() => r'be5943c4201793dc053ef44a4a243226e6d38526'; abstract class _$GymStateNotifier extends $Notifier { GymModeState build(); diff --git a/lib/features/routines/providers/workout_logs_repository.dart b/lib/features/routines/providers/workout_logs_repository.dart index d8012cf0d..e6daa9f8c 100644 --- a/lib/features/routines/providers/workout_logs_repository.dart +++ b/lib/features/routines/providers/workout_logs_repository.dart @@ -23,6 +23,7 @@ import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:wger/core/json.dart'; import 'package:wger/database/powersync/database.dart'; import 'package:wger/features/routines/models/log.dart'; import 'package:wger/features/routines/models/session.dart'; @@ -119,36 +120,79 @@ class WorkoutLogRepository { _logger.finer('Adding local workout log entry ${log.date}'); await _db.transaction(() async { - if (log.sessionId == null) { - final dayMidnightUtc = DateTime.utc(log.date.year, log.date.month, log.date.day); - - final existing = - await (_db.select(_db.workoutSessionTable) - ..where( - (t) => - t.routineId.equalsNullable(log.routineId) & - t.date.equalsValue(dayMidnightUtc), - ) - ..limit(1)) - .getSingleOrNull(); - - if (existing != null) { - log.sessionId = existing.id; - } else { - 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'); - } - } + log.sessionId ??= await _sessionIdFor(log); final inserted = await _db.into(_db.workoutLogTable).insertReturning(log.toCompanion()); log.id = inserted.id; }); } + + /// The session a log without one belongs to, creating it if nothing fits. + /// + /// In order: a session the log falls into, then the most recent session that + /// is still open and started no more than [sessionMaxDuration] ago, then one + /// on the same day that carries no time at all, otherwise a new one. The first + /// three mirror what the server does for logs uploaded without a session. + Future _sessionIdFor(Log log) async { + final at = dateToUtcIso8601(log.date); + + final covering = + await (_db.select(_db.workoutSessionTable) + ..where( + (t) => + t.routineId.equalsNullable(log.routineId) & + t.datetimeStart.isSmallerOrEqualValue(at) & + t.datetimeEnd.isBiggerOrEqualValue(at), + ) + ..limit(1)) + .getSingleOrNull(); + if (covering != null) { + return covering.id; + } + + final windowStart = dateToUtcIso8601(log.date.subtract(sessionMaxDuration)); + final open = + await (_db.select(_db.workoutSessionTable) + ..where( + (t) => + t.routineId.equalsNullable(log.routineId) & + t.datetimeEnd.isNull() & + t.datetimeStart.isBiggerOrEqualValue(windowStart) & + t.datetimeStart.isSmallerOrEqualValue(at), + ) + ..orderBy([(t) => OrderingTerm.desc(t.datetimeStart)]) + ..limit(1)) + .getSingleOrNull(); + if (open != null) { + return open.id; + } + + // Sessions that came from the server without a time can only be matched by + // their day. Dropping this would create a duplicate next to every one of them. + final dayMidnightUtc = DateTime.utc(log.date.year, log.date.month, log.date.day); + final sameDay = + await (_db.select(_db.workoutSessionTable) + ..where( + (t) => + t.routineId.equalsNullable(log.routineId) & + t.datetimeStart.isNull() & + t.date.equalsValue(dayMidnightUtc), + ) + ..limit(1)) + .getSingleOrNull(); + if (sameDay != null) { + return sameDay.id; + } + + // The start has to be set, otherwise the lookups above can never find this + // session again and every further log would create one of its own. + final created = await _db + .into(_db.workoutSessionTable) + .insertReturning( + WorkoutSession(routineId: log.routineId, datetimeStart: log.date).toCompanion(), + ); + _logger.finer('Created lazy session ${created.id} for log'); + + return created.id; + } } diff --git a/lib/features/routines/providers/workout_session_repository.dart b/lib/features/routines/providers/workout_session_repository.dart index bf4e52d47..79eb9523f 100644 --- a/lib/features/routines/providers/workout_session_repository.dart +++ b/lib/features/routines/providers/workout_session_repository.dart @@ -42,20 +42,23 @@ class WorkoutSessionRepository { Stream> watchAllDrift() { _logger.finer('Watching all local workout session entries'); - final query = _db.select(_db.workoutSessionTable).join([ - leftOuterJoin( - _db.workoutLogTable, - _db.workoutLogTable.sessionId.equalsExp(_db.workoutSessionTable.id), - ), - leftOuterJoin( - _db.routineRepetitionUnitTable, - _db.routineRepetitionUnitTable.id.equalsExp(_db.workoutLogTable.repetitionsUnitId), - ), - leftOuterJoin( - _db.routineWeightUnitTable, - _db.routineWeightUnitTable.id.equalsExp(_db.workoutLogTable.weightUnitId), - ), - ])..orderBy([OrderingTerm(expression: _db.workoutSessionTable.date, mode: OrderingMode.desc)]); + final query = + _db.select(_db.workoutSessionTable).join([ + leftOuterJoin( + _db.workoutLogTable, + _db.workoutLogTable.sessionId.equalsExp(_db.workoutSessionTable.id), + ), + leftOuterJoin( + _db.routineRepetitionUnitTable, + _db.routineRepetitionUnitTable.id.equalsExp(_db.workoutLogTable.repetitionsUnitId), + ), + leftOuterJoin( + _db.routineWeightUnitTable, + _db.routineWeightUnitTable.id.equalsExp(_db.workoutLogTable.weightUnitId), + ), + ])..orderBy([ + OrderingTerm(expression: _db.workoutSessionTable.datetimeStart, mode: OrderingMode.desc), + ]); return query.watch().map((rows) { final sessions = {}; @@ -103,7 +106,7 @@ class WorkoutSessionRepository { /// `session.id` is null, Drift mints one, so the returned session carries /// the id the caller needs to reference it. Future addLocalDrift(WorkoutSession session) async { - _logger.finer('Adding local workout session entry ${session.date}'); + _logger.finer('Adding local workout session entry ${session.datetimeStart}'); return _db.into(_db.workoutSessionTable).insertReturning(session.toCompanion()); } } diff --git a/lib/features/routines/validators.dart b/lib/features/routines/validators.dart index 5e79191f2..4c1fa29fc 100644 --- a/lib/features/routines/validators.dart +++ b/lib/features/routines/validators.dart @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import 'package:flutter/material.dart'; +import 'package:wger/features/routines/models/session.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; /// Cross-field validation for a workout log entry, mirroring the @@ -33,21 +33,19 @@ String? validateWorkoutLogCrossField({ return null; } -/// Cross-field validation for a workout session, mirroring the backend -/// `WorkoutSession.clean()` rules: +/// Cross-field validation for a workout session, mirroring the backend rule. /// -/// - [timeStart] and [timeEnd] must both be set or both be empty, -/// - if both are set, [timeStart] must not be after [timeEnd]. +/// An end before the start is not an error, the form reads it as a session that +/// ran past midnight. What the server does reject is a session longer than +/// [sessionMaxDuration]. String? validateWorkoutSessionTimes({ - required TimeOfDay? timeStart, - required TimeOfDay? timeEnd, + required DateTime datetimeStart, + required DateTime? datetimeEnd, required AppLocalizations i18n, }) { - if ((timeStart == null) != (timeEnd == null)) { - return i18n.timeStartEndBothOrNeither; - } - if (timeStart != null && timeEnd != null && timeStart.isAfter(timeEnd)) { - return i18n.timeStartAhead; + if (datetimeEnd != null && datetimeEnd.difference(datetimeStart) > sessionMaxDuration) { + return i18n.sessionTooLong(sessionMaxDuration.inHours); } + return null; } diff --git a/lib/features/routines/widgets/forms/session.dart b/lib/features/routines/widgets/forms/session.dart index b0e09ae49..69a9bc689 100644 --- a/lib/features/routines/widgets/forms/session.dart +++ b/lib/features/routines/widgets/forms/session.dart @@ -60,7 +60,11 @@ class _SessionFormState extends ConsumerState { super.initState(); _draft = widget._session ?? - WorkoutSession(routineId: widget._routineId, dayId: widget._dayId, date: clock.now()); + WorkoutSession( + routineId: widget._routineId, + dayId: widget._dayId, + datetimeStart: clock.now(), + ); notesController.text = _draft.notes ?? ''; } @@ -82,6 +86,20 @@ class _SessionFormState extends ConsumerState { super.dispose(); } + /// Anchors a picked time on the day the session belongs to + DateTime _onSessionDay(TimeOfDay time) { + final day = _draft.localDay; + + return DateTime(day.year, day.month, day.day, time.hour, time.minute); + } + + /// Same, but an end before the start means the session ran past midnight + DateTime _endOnSessionDay(TimeOfDay time) { + final end = _onSessionDay(time); + + return end.isBefore(_draft.datetimeStart) ? end.add(const Duration(days: 1)) : end; + } + @override Widget build(BuildContext context) { final sessionProvider = ref.read(workoutSessionProvider.notifier); @@ -133,19 +151,21 @@ class _SessionFormState extends ConsumerState { Flexible( child: TimeInputWidget( key: const ValueKey('time-start'), - value: _draft.timeStart, + value: TimeOfDay.fromDateTime(_draft.datetimeStart), labelText: AppLocalizations.of(context).timeStart, - onCleared: () => _draft = _draft.copyWith(timeStart: null), - onChanged: (time) => _draft = _draft.copyWith(timeStart: time), + onChanged: (time) => _draft = _draft.copyWith(datetimeStart: _onSessionDay(time)), ), ), Flexible( child: TimeInputWidget( key: const ValueKey('time-end'), - value: _draft.timeEnd, + value: _draft.datetimeEnd != null + ? TimeOfDay.fromDateTime(_draft.datetimeEnd!) + : null, labelText: AppLocalizations.of(context).timeEnd, - onCleared: () => _draft = _draft.copyWith(timeEnd: null), - onChanged: (time) => _draft = _draft.copyWith(timeEnd: time), + onCleared: () => _draft = _draft.copyWith(datetimeEnd: null), + onChanged: (time) => + _draft = _draft.copyWith(datetimeEnd: _endOnSessionDay(time)), ), ), ], @@ -162,8 +182,8 @@ class _SessionFormState extends ConsumerState { final i18n = AppLocalizations.of(context); final error = validateWorkoutSessionTimes( - timeStart: _draft.timeStart, - timeEnd: _draft.timeEnd, + datetimeStart: _draft.datetimeStart, + datetimeEnd: _draft.datetimeEnd, i18n: i18n, ); if (error != null) { diff --git a/lib/features/routines/widgets/gym_mode/session_page.dart b/lib/features/routines/widgets/gym_mode/session_page.dart index 326fc4216..a8e92ca7b 100644 --- a/lib/features/routines/widgets/gym_mode/session_page.dart +++ b/lib/features/routines/widgets/gym_mode/session_page.dart @@ -17,6 +17,7 @@ */ import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/core/consts.dart'; @@ -52,22 +53,31 @@ class _SessionPageState extends ConsumerState { value: ref.watch(workoutSessionProvider), loggerName: 'SessionPage', data: (sessions) { - final found = sessions.firstWhere( - (s) => s.date.isSameDayAs(clock.now()) && s.routineId == gymState.routine.id, - orElse: () => WorkoutSession( - dayId: gymState.dayId, - date: clock.now(), - routineId: gymState.routine.id, - ), - ); + final now = clock.now(); + final ours = sessions.where((s) => s.routineId == gymState.routine.id); + + // A session that is still running wins over one that merely shares + // a calendar day, because a workout can cross midnight. The day is + // still the fallback, so an already finished one stays editable. + final found = + ours.firstWhereOrNull( + (s) => + s.datetimeEnd == null && + now.difference(s.datetimeStart) <= sessionMaxDuration, + ) ?? + ours.firstWhereOrNull((s) => s.datetimeStart.isSameDayAs(now)) ?? + WorkoutSession( + dayId: gymState.dayId, + datetimeStart: gymState.workoutStart, + routineId: gymState.routine.id, + ); // Prefill missing times. A session may have been created lazily // while logging sets (without times), so fall back to the gym // session's start and the current time. - final session = found.copyWith( - timeStart: found.timeStart ?? gymState.startTime, - timeEnd: found.timeEnd ?? TimeOfDay.fromDateTime(clock.now()), - ); + // A session created lazily while logging has no end yet; prefill + // the current time so the form opens on a complete interval. + final session = found.copyWith(datetimeEnd: found.datetimeEnd ?? now); return Column( children: [ diff --git a/lib/features/routines/widgets/gym_mode/summary.dart b/lib/features/routines/widgets/gym_mode/summary.dart index cdd83cc6a..493472876 100644 --- a/lib/features/routines/widgets/gym_mode/summary.dart +++ b/lib/features/routines/widgets/gym_mode/summary.dart @@ -101,7 +101,7 @@ class _WorkoutSummaryState extends ConsumerState { } final session = routine.sessions.firstWhereOrNull( - (s) => s.date.isSameDayAs(clock.now()), + (s) => s.localDay.isSameDayAs(clock.now()), ); final userTrophies = trophyState.prTrophies .where((t) => t.contextData?.sessionId == session?.id) diff --git a/lib/features/routines/widgets/logs/day_logs_container.dart b/lib/features/routines/widgets/logs/day_logs_container.dart index 8b1cfe11e..74cb4651f 100644 --- a/lib/features/routines/widgets/logs/day_logs_container.dart +++ b/lib/features/routines/widgets/logs/day_logs_container.dart @@ -43,7 +43,7 @@ class DayLogWidget extends ConsumerWidget { final trophyState = ref.read(trophyStateProvider); final session = _routine.sessions.firstWhere( - (s) => s.date.isSameDayAs(_date), + (s) => s.localDay.isSameDayAs(_date), ); final exercises = session.exercises; diff --git a/lib/features/routines/widgets/logs/log_overview_routine.dart b/lib/features/routines/widgets/logs/log_overview_routine.dart index 4418bc071..8fa545252 100644 --- a/lib/features/routines/widgets/logs/log_overview_routine.dart +++ b/lib/features/routines/widgets/logs/log_overview_routine.dart @@ -94,8 +94,8 @@ class _WorkoutLogCalendarState extends State { void loadEvents() { for (final session in widget._routine.sessions) { - _events[DateFormatLists.format(session.date)] = [ - session.date, + _events[DateFormatLists.format(session.localDay)] = [ + session.localDay, ]; } diff --git a/lib/features/routines/widgets/logs/session_info.dart b/lib/features/routines/widgets/logs/session_info.dart index 35cdc68a4..96d765651 100644 --- a/lib/features/routines/widgets/logs/session_info.dart +++ b/lib/features/routines/widgets/logs/session_info.dart @@ -47,7 +47,7 @@ class _SessionInfoState extends State { style: Theme.of(context).textTheme.headlineSmall, ), subtitle: Text( - localizedDate(context).format(widget._session.date), + localizedDate(context).format(widget._session.localDay), ), onTap: () => setState(() => editMode = !editMode), trailing: Icon(editMode ? Icons.edit_off : Icons.edit), diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 588ee634f..54a6839f3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1168,5 +1168,6 @@ "certsNotVerifiedTitle": "Zertifikatsvalidierung ist deaktiviert", "certsNotVerifiedDetail": "Zertifikate für {host} werden nicht geprüft. Zum Ändern melde dich ab und passe die Option im Anmeldebildschirm an.", "settingsVerboseLogging": "Ausführliches Protokoll", - "settingsVerboseLoggingDescription": "Hilfreich, wenn du anhaltende Probleme meldest." + "settingsVerboseLoggingDescription": "Hilfreich, wenn du anhaltende Probleme meldest.", + "sessionTooLong": "Eine Trainingseinheit darf nicht länger als {hours} Stunden dauern" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1aff62662..98e6d7b56 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1496,5 +1496,14 @@ "timeStartEndBothOrNeither": "Either set both start and end time or leave both empty", "@timeStartEndBothOrNeither": { "description": "Validation error when only one of start/end time is set on a workout session" + }, + "sessionTooLong": "A session cannot be longer than {hours} hours", + "@sessionTooLong": { + "description": "Validation error when a workout session exceeds the maximum length", + "placeholders": { + "hours": { + "type": "int" + } + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f6b4b305a..eca01c4e8 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1207,5 +1207,6 @@ "certsNotVerifiedTitle": "La validación de certificados está desactivada", "certsNotVerifiedDetail": "Los certificados de {host} no se comprueban. Para cambiarlo, cierra la sesión y ajusta la opción en la pantalla de inicio de sesión.", "settingsVerboseLogging": "Registro detallado", - "settingsVerboseLoggingDescription": "Útil cuando informas de problemas persistentes." + "settingsVerboseLoggingDescription": "Útil cuando informas de problemas persistentes.", + "sessionTooLong": "Una sesión de entrenamiento no puede durar más de {hours} horas" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a72f4e0ce..a8c0ba6c6 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1178,5 +1178,6 @@ "certsNotVerifiedTitle": "La validation des certificats est désactivée", "certsNotVerifiedDetail": "Les certificats de {host} ne sont pas vérifiés. Pour changer cela, déconnectez-vous et modifiez l'option sur l'écran de connexion.", "settingsVerboseLogging": "Journalisation détaillée", - "settingsVerboseLoggingDescription": "Utile lorsque vous signalez des problèmes persistants." + "settingsVerboseLoggingDescription": "Utile lorsque vous signalez des problèmes persistants.", + "sessionTooLong": "Une session d’entraînement ne peut pas durer plus de {hours} heures" } diff --git a/lib/powersync/connector.dart b/lib/powersync/connector.dart index 1058f458e..300b633c8 100644 --- a/lib/powersync/connector.dart +++ b/lib/powersync/connector.dart @@ -208,7 +208,6 @@ class DjangoConnector extends PowerSyncBackendConnector { /// are read-only on the serializer and therefore safe to leave out. static const Map> _dateOnlyFields = { 'manager_routine': {'start', 'end'}, - 'manager_workoutsession': {'date'}, 'nutrition_nutritionplan': {'start', 'end'}, 'gallery_image': {'date'}, }; diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index 2df3fa82d..d7cd961db 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -503,6 +503,39 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get allowSelfSignedCertsTitle => + (super.noSuchMethod( + Invocation.getter(#allowSelfSignedCertsTitle), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#allowSelfSignedCertsTitle), + ), + ) + as String); + + @override + String get allowSelfSignedCertsDetail => + (super.noSuchMethod( + Invocation.getter(#allowSelfSignedCertsDetail), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#allowSelfSignedCertsDetail), + ), + ) + as String); + + @override + String get certsNotVerifiedTitle => + (super.noSuchMethod( + Invocation.getter(#certsNotVerifiedTitle), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#certsNotVerifiedTitle), + ), + ) + as String); + @override String get authOptionPasswordTitle => (super.noSuchMethod( @@ -1662,6 +1695,14 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get meal => + (super.noSuchMethod( + Invocation.getter(#meal), + returnValue: _i3.dummyValue(this, Invocation.getter(#meal)), + ) + as String); + @override String get mealLogged => (super.noSuchMethod( @@ -3628,6 +3669,28 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get settingsVerboseLogging => + (super.noSuchMethod( + Invocation.getter(#settingsVerboseLogging), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#settingsVerboseLogging), + ), + ) + as String); + + @override + String get settingsVerboseLoggingDescription => + (super.noSuchMethod( + Invocation.getter(#settingsVerboseLoggingDescription), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#settingsVerboseLoggingDescription), + ), + ) + as String); + @override String get aboutPageTitle => (super.noSuchMethod( @@ -4250,6 +4313,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get useDynamicColor => + (super.noSuchMethod( + Invocation.getter(#useDynamicColor), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#useDynamicColor), + ), + ) + as String); + @override String get youAreOffline => (super.noSuchMethod( @@ -4492,6 +4566,39 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get syncStatusStalledHint => + (super.noSuchMethod( + Invocation.getter(#syncStatusStalledHint), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#syncStatusStalledHint), + ), + ) + as String); + + @override + String get syncStatusReconnect => + (super.noSuchMethod( + Invocation.getter(#syncStatusReconnect), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#syncStatusReconnect), + ), + ) + as String); + + @override + String get syncStatusNeverSynced => + (super.noSuchMethod( + Invocation.getter(#syncStatusNeverSynced), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#syncStatusNeverSynced), + ), + ) + as String); + @override String get filterNutriscore => (super.noSuchMethod( @@ -4602,6 +4709,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String certsNotVerifiedDetail(String? host) => + (super.noSuchMethod( + Invocation.method(#certsNotVerifiedDetail, [host]), + returnValue: _i3.dummyValue( + this, + Invocation.method(#certsNotVerifiedDetail, [host]), + ), + ) + as String); + @override String exerciseNr(String? nr) => (super.noSuchMethod( @@ -4954,6 +5072,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String syncStatusPendingUploads(int? count) => + (super.noSuchMethod( + Invocation.method(#syncStatusPendingUploads, [count]), + returnValue: _i3.dummyValue( + this, + Invocation.method(#syncStatusPendingUploads, [count]), + ), + ) + as String); + @override String filterNutriscoreOrBetter(String? grade) => (super.noSuchMethod( diff --git a/test/features/routines/models/session_test.dart b/test/features/routines/models/session_test.dart index 965272748..4467843c6 100644 --- a/test/features/routines/models/session_test.dart +++ b/test/features/routines/models/session_test.dart @@ -16,12 +16,57 @@ * along with this program. If not, see . */ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:wger/core/consts.dart'; import 'package:wger/features/routines/models/log.dart'; import 'package:wger/features/routines/models/session.dart'; void main() { + group('WorkoutSession.fromDb', () { + test('takes the new columns when they are set', () { + final session = WorkoutSession.fromDb( + routineId: 1, + datetimeStart: DateTime(2026, 4, 15, 18, 30), + datetimeEnd: DateTime(2026, 4, 15, 19, 45), + ); + + expect(session.datetimeStart, DateTime(2026, 4, 15, 18, 30)); + expect(session.datetimeEnd, DateTime(2026, 4, 15, 19, 45)); + }); + + test('rebuilds a row that predates 2.7 from its date and times', () { + final session = WorkoutSession.fromDb( + routineId: 1, + date: DateTime(2026, 4, 15), + timeStart: const TimeOfDay(hour: 18, minute: 30), + timeEnd: const TimeOfDay(hour: 19, minute: 45), + ); + + expect(session.datetimeStart, DateTime(2026, 4, 15, 18, 30)); + expect(session.datetimeEnd, DateTime(2026, 4, 15, 19, 45)); + }); + + test('reads an end before the start as the next day', () { + final session = WorkoutSession.fromDb( + routineId: 1, + date: DateTime(2026, 4, 15), + timeStart: const TimeOfDay(hour: 23, minute: 0), + timeEnd: const TimeOfDay(hour: 1, minute: 30), + ); + + expect(session.datetimeStart, DateTime(2026, 4, 15, 23, 0)); + expect(session.datetimeEnd, DateTime(2026, 4, 16, 1, 30)); + }); + + test('an old row without times starts at midnight and stays open', () { + final session = WorkoutSession.fromDb(routineId: 1, date: DateTime(2026, 4, 15)); + + expect(session.datetimeStart, DateTime(2026, 4, 15)); + expect(session.datetimeEnd, isNull); + }); + }); + group('WorkoutSession.volume', () { test('sums metric volumes correctly', () { final a = Log( @@ -46,7 +91,7 @@ void main() { repetitionsUnitId: REP_UNIT_REPETITIONS_ID, ); - final session = WorkoutSession(routineId: 1, date: DateTime(2021), logs: [a, b]); + final session = WorkoutSession(routineId: 1, datetimeStart: DateTime(2021), logs: [a, b]); final vol = session.volume; expect(vol['metric'], equals(100 * 3 + 50 * 2)); @@ -76,7 +121,7 @@ void main() { repetitionsUnitId: REP_UNIT_REPETITIONS_ID, ); - final session = WorkoutSession(routineId: 1, date: DateTime(2021), logs: [a, b]); + final session = WorkoutSession(routineId: 1, datetimeStart: DateTime(2021), logs: [a, b]); final vol = session.volume; expect(vol['imperial'], equals(220 * 4 + 150 * 1)); @@ -117,7 +162,7 @@ void main() { repetitionsUnitId: 999, // some other repetition unit -> should be ignored ); - final session = WorkoutSession(routineId: 1, date: DateTime(2021), logs: [a, b, c]); + final session = WorkoutSession(routineId: 1, datetimeStart: DateTime(2021), logs: [a, b, c]); final vol = session.volume; // only 'a' should count for metric, only 'b' for imperial @@ -126,7 +171,7 @@ void main() { }); test('returns zero for empty logs', () { - final session = WorkoutSession(routineId: 1, date: DateTime(2021), logs: []); + final session = WorkoutSession(routineId: 1, datetimeStart: DateTime(2021), logs: []); final vol = session.volume; expect(vol['metric'], equals(0)); @@ -156,7 +201,7 @@ void main() { repetitionsUnitId: REP_UNIT_REPETITIONS_ID, ); - final session = WorkoutSession(routineId: 1, date: DateTime(2021), logs: [a, b]); + final session = WorkoutSession(routineId: 1, datetimeStart: DateTime(2021), logs: [a, b]); final vol = session.volume; expect(vol['metric'], closeTo(10.5 * 3 + 5.25 * 2.5, 1e-9)); diff --git a/test/features/routines/providers/routines_notifier_test.dart b/test/features/routines/providers/routines_notifier_test.dart index b5e9e116d..2cf877240 100644 --- a/test/features/routines/providers/routines_notifier_test.dart +++ b/test/features/routines/providers/routines_notifier_test.dart @@ -387,7 +387,7 @@ void main() { final session = WorkoutSession( id: 'session-1', routineId: 101, - date: DateTime(2025, 1, 1), + datetimeStart: DateTime(2025, 1, 1), logs: [log], ); when( diff --git a/test/features/routines/providers/routines_repository_test.mocks.dart b/test/features/routines/providers/routines_repository_test.mocks.dart index 65a35a37c..641d3fc16 100644 --- a/test/features/routines/providers/routines_repository_test.mocks.dart +++ b/test/features/routines/providers/routines_repository_test.mocks.dart @@ -1429,6 +1429,29 @@ class MockDriftPowersyncDatabase extends _i1.Mock implements _i4.DriftPowersyncD ) as _i3.GenerationContext); + @override + _i3.GenerationContext $writeUpdateInsertable( + _i3.TableInfo<_i3.Table, dynamic>? table, + _i3.Insertable? insertable, { + int? startIndex, + }) => + (super.noSuchMethod( + Invocation.method( + #$writeUpdateInsertable, + [table, insertable], + {#startIndex: startIndex}, + ), + returnValue: _FakeGenerationContext_52( + this, + Invocation.method( + #$writeUpdateInsertable, + [table, insertable], + {#startIndex: startIndex}, + ), + ), + ) + as _i3.GenerationContext); + @override String $expandVar(int? start, int? amount) => (super.noSuchMethod( diff --git a/test/features/routines/providers/workout_logs_repository_test.dart b/test/features/routines/providers/workout_logs_repository_test.dart index cb46f529f..eec7efcfb 100644 --- a/test/features/routines/providers/workout_logs_repository_test.dart +++ b/test/features/routines/providers/workout_logs_repository_test.dart @@ -81,11 +81,11 @@ void main() { }); group('addLocalDrift, log without sessionId', () { - test('reuses an existing session for the same routine and day', () async { + test('reuses an open session within the window', () async { final existingSession = WorkoutSession( id: 'existing-session-1', routineId: 100, - date: DateTime.utc(2026, 4, 15), + datetimeStart: DateTime.utc(2026, 4, 15, 16), ); await db.into(db.workoutSessionTable).insert(existingSession.toCompanion()); @@ -99,11 +99,9 @@ void main() { }); test('reuses a server-synced session whose date has no time component', () async { - // After a round-trip the backend stores the date as a bare 'YYYY-MM-DD' - // (Django DateField), not the local 'T00:00:00.000Z' format. The day - // lookup must still match it, otherwise a duplicate session is created - // and the server rejects it on the unique (date, routine, user) - // constraint, taking every log on it down with it. + // A row replicated before 2.7 carries only the bare 'YYYY-MM-DD' date and + // no datetime_start. The day lookup has to keep matching it, otherwise a + // duplicate session appears next to every one of them. await db.customStatement( "INSERT INTO manager_workoutsession (id, routine_id, date, impression) VALUES ('server-session', 100, '2026-04-15', '2')", ); @@ -117,22 +115,39 @@ void main() { expect(logs.single.sessionId, 'server-session'); }); - test('creates a new session at midnight UTC of the log date', () async { + test('creates a new session starting at the moment of the log', () async { + // Without a start the lookups above could never find it again and every + // further log would create one of its own. final log = makeLog(date: DateTime.utc(2026, 4, 15, 18, 30)); await repo.addLocalDrift(log); final sessions = await readSessions(); expect(sessions, hasLength(1)); - expect(sessions.single.date, DateTime.utc(2026, 4, 15)); + expect(sessions.single.datetimeStart, DateTime.utc(2026, 4, 15, 18, 30).toLocal()); expect(log.sessionId, sessions.single.id); }); + test('starts a new session when the open one is older than the window', () async { + await db + .into(db.workoutSessionTable) + .insert( + WorkoutSession( + routineId: 100, + datetimeStart: DateTime.utc(2026, 4, 15, 8), + ).toCompanion(), + ); + + await repo.addLocalDrift(makeLog(routineId: 100, date: DateTime.utc(2026, 4, 15, 18))); + + expect(await readSessions(), hasLength(2)); + }); + test('does not reuse a session from a different day', () async { await db .into(db.workoutSessionTable) .insert( - WorkoutSession(routineId: 100, date: DateTime.utc(2026, 4, 14)).toCompanion(), + WorkoutSession(routineId: 100, datetimeStart: DateTime.utc(2026, 4, 14)).toCompanion(), ); await repo.addLocalDrift(makeLog(date: DateTime.utc(2026, 4, 15))); @@ -144,7 +159,7 @@ void main() { await db .into(db.workoutSessionTable) .insert( - WorkoutSession(routineId: 999, date: DateTime.utc(2026, 4, 15)).toCompanion(), + WorkoutSession(routineId: 999, datetimeStart: DateTime.utc(2026, 4, 15)).toCompanion(), ); await repo.addLocalDrift(makeLog(routineId: 100, date: DateTime.utc(2026, 4, 15))); @@ -167,7 +182,7 @@ void main() { final existingSession = WorkoutSession( id: 'free-session', routineId: null, - date: DateTime.utc(2026, 4, 15), + datetimeStart: DateTime.utc(2026, 4, 15, 16), ); await db.into(db.workoutSessionTable).insert(existingSession.toCompanion()); @@ -182,7 +197,7 @@ void main() { await db .into(db.workoutSessionTable) .insert( - WorkoutSession(routineId: 100, date: DateTime.utc(2026, 4, 15)).toCompanion(), + WorkoutSession(routineId: 100, datetimeStart: DateTime.utc(2026, 4, 15)).toCompanion(), ); await repo.addLocalDrift(makeLog(routineId: null, date: DateTime.utc(2026, 4, 15))); diff --git a/test/features/routines/providers/workout_session_repository_test.dart b/test/features/routines/providers/workout_session_repository_test.dart index 819f06dc5..5799e1548 100644 --- a/test/features/routines/providers/workout_session_repository_test.dart +++ b/test/features/routines/providers/workout_session_repository_test.dart @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:wger/database/powersync/database.dart'; import 'package:wger/features/routines/models/log.dart'; @@ -55,7 +54,7 @@ void main() { return WorkoutSession( id: id, routineId: routineId, - date: date ?? DateTime.utc(2026, 4, 15), + datetimeStart: date ?? DateTime.utc(2026, 4, 15), ); } @@ -96,20 +95,20 @@ void main() { expect(rows.single.notes, 'updated'); }); - test('editLocalDrift clears times that were nulled', () async { + test('editLocalDrift clears an end that was nulled', () async { final inserted = await repo.addLocalDrift( makeSession().copyWith( - timeStart: const TimeOfDay(hour: 8, minute: 0), - timeEnd: const TimeOfDay(hour: 9, minute: 30), + datetimeStart: DateTime(2021, 5, 1, 8, 0), + datetimeEnd: DateTime(2021, 5, 1, 9, 30), ), ); - // The user clears both times via the form's clear buttons - await repo.editLocalDrift(inserted.copyWith(timeStart: null, timeEnd: null)); + // The user clears the end via the form's clear button + await repo.editLocalDrift(inserted.copyWith(datetimeEnd: null)); final row = await db.select(db.workoutSessionTable).getSingle(); - expect(row.timeStart, isNull); - expect(row.timeEnd, isNull); + expect(row.datetimeStart, DateTime(2021, 5, 1, 8, 0)); + expect(row.datetimeEnd, isNull); }); test('deleteLocalDrift removes the row with matching id', () async { @@ -134,7 +133,7 @@ void main() { expect(await repo.watchAllDrift().first, isEmpty); }); - test('emits sessions sorted by date desc', () async { + test('emits sessions sorted by start desc', () async { await repo.addLocalDrift(makeSession(routineId: 1, date: DateTime.utc(2026, 4, 14))); await repo.addLocalDrift(makeSession(routineId: 2, date: DateTime.utc(2026, 4, 16))); await repo.addLocalDrift(makeSession(routineId: 3, date: DateTime.utc(2026, 4, 15))); diff --git a/test/features/routines/screens/routine_logs_screen_test.dart b/test/features/routines/screens/routine_logs_screen_test.dart index 3d5da8547..c1d844faf 100644 --- a/test/features/routines/screens/routine_logs_screen_test.dart +++ b/test/features/routines/screens/routine_logs_screen_test.dart @@ -56,7 +56,7 @@ void main() { setUp(() { routine = getTestRoutine(); - routine.sessions[0] = routine.sessions[0].copyWith(date: DateTime(2025, 3, 29)); + routine.sessions[0] = routine.sessions[0].copyWith(datetimeStart: DateTime(2025, 3, 29)); // Pin every log to a known session id so we can verify the edit // dialog round-trips the value through the model. for (final log in routine.sessions[0].logs) { diff --git a/test/features/routines/widgets/forms/session_form_test.dart b/test/features/routines/widgets/forms/session_form_test.dart index 0b450c93b..409771ce6 100644 --- a/test/features/routines/widgets/forms/session_form_test.dart +++ b/test/features/routines/widgets/forms/session_form_test.dart @@ -55,9 +55,8 @@ void main() { routineId: 1, notes: 'Existing notes', impression: WorkoutImpression.bad, - date: DateTime.now(), - timeStart: const TimeOfDay(hour: 10, minute: 0), - timeEnd: const TimeOfDay(hour: 11, minute: 0), + datetimeStart: DateTime.now().copyWith(hour: 10, minute: 0), + datetimeEnd: DateTime.now().copyWith(hour: 11, minute: 0), ); //Act @@ -97,7 +96,7 @@ void main() { routineId: 1, notes: 'Old notes', impression: WorkoutImpression.neutral, - date: DateTime.now(), + datetimeStart: DateTime.now(), ); when(mockRepository.editLocalDrift(any as dynamic)).thenAnswer( diff --git a/test/features/routines/widgets/gym_mode/session_page_test.dart b/test/features/routines/widgets/gym_mode/session_page_test.dart index 199d9a7d5..0076162d5 100644 --- a/test/features/routines/widgets/gym_mode/session_page_test.dart +++ b/test/features/routines/widgets/gym_mode/session_page_test.dart @@ -105,13 +105,12 @@ void main() { }); }); - testWidgets('Existing session with null times falls back to defaults', ( + testWidgets('An open session keeps its start and gets the current time as end', ( WidgetTester tester, ) async { - // A session created lazily while logging has no times; the page should - // prefill the gym session's start and the current time instead of leaving - // both fields blank. - testRoutine.sessions[0] = testRoutine.sessions[0].copyWith(timeStart: null, timeEnd: null); + // A session created lazily while logging has a start but no end yet; the + // page prefills the current time so the form opens on a full interval. + testRoutine.sessions[0] = testRoutine.sessions[0].copyWith(datetimeEnd: null); notifier.state = notifier.state.copyWith( routine: testRoutine, @@ -123,7 +122,7 @@ void main() { await tester.pumpWidget(renderSessionPage()); await tester.pumpAndSettle(); - expect(find.text('1:35 PM'), findsOneWidget); + expect(find.text('10:00 AM'), findsOneWidget); expect(find.text('3:23 PM'), findsOneWidget); }); }); @@ -159,8 +158,8 @@ void main() { expect(captured.id, '1'); expect(captured.impression, WorkoutImpression.good); expect(captured.notes, equals('This is a note')); - expect(captured.timeStart, equals(const TimeOfDay(hour: 10, minute: 0))); - expect(captured.timeEnd, equals(const TimeOfDay(hour: 12, minute: 34))); + expect(captured.datetimeStart, equals(DateTime(2021, 5, 1, 10, 0))); + expect(captured.datetimeEnd, equals(DateTime(2021, 5, 1, 12, 34))); }); }); } diff --git a/test/powersync/connector_test.dart b/test/powersync/connector_test.dart index 2a2ec3bb4..c80220f08 100644 --- a/test/powersync/connector_test.dart +++ b/test/powersync/connector_test.dart @@ -103,17 +103,19 @@ void main() { expect(out['created'], '2024-10-30T10:15:00.000Z'); }); - test('strips the time component on `manager_workoutsession.date`', () { + test('leaves the session timestamps untouched', () { + // The session no longer has a date-only column, both timestamps go to + // the server as the full ISO8601 values they are. final out = connector.genericTransform( 'manager_workoutsession', { - 'date': '2024-11-01T00:00:00.000Z', + 'datetime_start': '2024-11-01T18:30:00.000Z', 'notes': 'felt great', 'impression': '1', }, '12', ); - expect(out['date'], '2024-11-01'); + expect(out['datetime_start'], '2024-11-01T18:30:00.000Z'); expect(out['notes'], 'felt great'); }); diff --git a/test/screenshots/screenshots_03_gym_mode.dart b/test/screenshots/screenshots_03_gym_mode.dart index c7546f5e7..27cb77c33 100644 --- a/test/screenshots/screenshots_03_gym_mode.dart +++ b/test/screenshots/screenshots_03_gym_mode.dart @@ -100,7 +100,7 @@ Widget createGymModeResultsScreen({Locale? locale}) { final key = GlobalKey(); final routine = getTestRoutine(exercises: getScreenshotExercises()); - routine.sessions[0] = routine.sessions.first.copyWith(date: clock.now()); + routine.sessions[0] = routine.sessions.first.copyWith(datetimeStart: clock.now()); return riverpod.UncontrolledProviderScope( container: riverpod.ProviderContainer.test( diff --git a/test_data/routines.dart b/test_data/routines.dart index a3142e235..b7890d380 100644 --- a/test_data/routines.dart +++ b/test_data/routines.dart @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import 'package:flutter/material.dart'; import 'package:wger/features/exercises/models/exercise.dart'; import 'package:wger/features/routines/models/base_config.dart'; import 'package:wger/features/routines/models/day.dart'; @@ -92,22 +91,20 @@ Routine getTestRoutine({List? exercises}) { final session1 = WorkoutSession( id: '1', routineId: 1, - date: DateTime(2021, 5, 1), impression: WorkoutImpression.good, notes: 'This is a note', - timeStart: const TimeOfDay(hour: 10, minute: 0), - timeEnd: const TimeOfDay(hour: 12, minute: 34), + datetimeStart: DateTime(2021, 5, 1, 10, 0), + datetimeEnd: DateTime(2021, 5, 1, 12, 34), logs: [log1, log2], ); final session2 = WorkoutSession( id: '2', routineId: 1, - date: DateTime(2021, 5, 2), impression: WorkoutImpression.bad, notes: 'This is a note', - timeStart: const TimeOfDay(hour: 6, minute: 12), - timeEnd: const TimeOfDay(hour: 8, minute: 1), + datetimeStart: DateTime(2021, 5, 2, 6, 12), + datetimeEnd: DateTime(2021, 5, 2, 8, 1), logs: [log3], );