Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ _Avoid_: Tracking vendor, analytics SDK
A Product Usage Event that marks completion or failure of a meaningful user workflow step.
_Avoid_: Tap event, raw navigation log, interaction trace

**Schedule**:
A planned commitment with a target time that OnTime helps the user prepare for.
_Avoid_: Event, appointment, alarm

**Preparation**:
The ordered set of steps a user completes before a Schedule.
_Avoid_: Preparation chain, task list

**Preparation Step**:
One named action with an expected duration inside a Preparation.
_Avoid_: Task, checklist item, alarm step

**Provider Authentication Completed**:
The state where the external Apple or Google account prompt has returned credentials to OnTime.
_Avoid_: Login completed, signed in, session ready
Expand Down Expand Up @@ -174,6 +186,9 @@ _Avoid_: Loaded range, stream range, cached range
- An **Analytics Event Parameter** must not contain user-authored text, direct identifiers, tokens, raw exception strings, request bodies, or response bodies.
- A **Product Usage Event** uses a stable snake_case name and includes a schema version.
- A changed **Product Usage Event** meaning requires a new event name or schema version.
- A **Schedule** may have one **Preparation** for that specific commitment.
- A **Preparation** contains zero or more **Preparation Steps** in user-defined order.
- A user's default **Preparation** may be applied to a **Schedule** and then changed for that Schedule.
- A **Schedule** has a **Preparation** whose **Preparation Duration** contributes to preparation-start timing.
- **Preparation Duration**, move time, and **Schedule Spare Time** are distinct schedule timing inputs.
- User-facing copy should call a scheduled notification a **Schedule Notification**, not an **Alarm**, unless it opens an OnTime screen without the user first tapping a notification.
Expand Down Expand Up @@ -245,4 +260,5 @@ _Avoid_: Loaded range, stream range, cached range
- "Time Sensitive" was too platform-specific for default user-facing status; resolved: fallback iOS delivery should be called notification.
- "Status label" was ambiguous across platforms; resolved: Android uses precise notification or notification status, while iOS uses alarm status only for **iOS AlarmKit Alarm**.
- "No scheduled alarm" was too capability-specific for an empty state; resolved: use **No Scheduled Notification** across platforms.
- "Preparation chain" describes storage reconstruction, not product language; resolved: the domain concept is an ordered **Preparation** made of **Preparation Steps**.
- "Total duration" was ambiguous between **Preparation Duration** and the broader preparation-start timing calculation; resolved: **Preparation Duration** is steps only, while move time and **Schedule Spare Time** are separate inputs.
63 changes: 36 additions & 27 deletions lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,42 +18,51 @@ import 'package:uuid/uuid.dart';
part 'database.g.dart';

@Singleton()
@DriftDatabase(tables: [
Places,
Schedules,
Users,
PreparationSchedules,
PreparationUsers,
], daos: [
ScheduleDao,
PlaceDao,
UserDao,
PreparationScheduleDao,
PreparationUserDao
])
@DriftDatabase(
tables: [Places, Schedules, Users, PreparationSchedules, PreparationUsers],
daos: [
ScheduleDao,
PlaceDao,
UserDao,
PreparationScheduleDao,
PreparationUserDao,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());

AppDatabase.forTesting(super.e);

@override
int get schemaVersion => 3;
int get schemaVersion => 4;

@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from == 1) {
await m.createTable(preparationSchedules);
await m.createTable(preparationUsers);
}
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 3) {
await m.createTable(preparationSchedules);
await m.createTable(preparationUsers);
}
if (from < 4) {
await _createLookupIndexes(m);
}
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);

Future<void> _createLookupIndexes(Migrator m) async {
await m.createIndex(schedulesScheduleTimeIdx);
await m.createIndex(schedulesPlaceIdIdx);
await m.createIndex(preparationSchedulesScheduleIdIdx);
await m.createIndex(preparationSchedulesNextPreparationIdIdx);
await m.createIndex(preparationUsersUserIdIdx);
await m.createIndex(preparationUsersNextPreparationIdIdx);
}

static QueryExecutor _openConnection() {
return driftDatabase(
Expand Down
35 changes: 11 additions & 24 deletions lib/data/daos/preparation_schedule_dao.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart';
import 'package:on_time_front/domain/entities/preparation_step_entity.dart';
Expand Down Expand Up @@ -59,29 +58,17 @@ class PreparationScheduleDao extends DatabaseAccessor<AppDatabase>
return PreparationEntity(preparationStepList: []);
}

final firstStep = allSteps.firstWhere(
(step) => allSteps.every((other) => other.nextPreparationId != step.id),
orElse: () => allSteps.first,
);

final List<PreparationStepEntity> orderedSteps = [];
PreparationSchedule? currentStep = firstStep;

while (currentStep != null) {
orderedSteps.add(
PreparationStepEntity(
id: currentStep.id,
preparationName: currentStep.preparationName,
preparationTime: Duration(minutes: currentStep.preparationTime),
nextPreparationId: currentStep.nextPreparationId,
),
);
currentStep = allSteps.firstWhereOrNull(
(step) => step.id == currentStep!.nextPreparationId,
);
}

return PreparationEntity(preparationStepList: orderedSteps);
return PreparationEntity(
preparationStepList: [
for (final step in allSteps)
PreparationStepEntity(
id: step.id,
preparationName: step.preparationName,
preparationTime: Duration(minutes: step.preparationTime),
nextPreparationId: step.nextPreparationId,
),
],
).ordered;
}

Future<PreparationStepEntity> getPreparationStepById(
Expand Down
35 changes: 11 additions & 24 deletions lib/data/daos/preparation_user_dao.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:on_time_front/data/mappers/domain_persistence_mappers.dart';
import 'package:on_time_front/domain/entities/preparation_step_entity.dart';
Expand Down Expand Up @@ -49,29 +48,17 @@ class PreparationUserDao extends DatabaseAccessor<AppDatabase>
return PreparationEntity(preparationStepList: []);
}

final firstStep = allSteps.firstWhere(
(step) => allSteps.every((other) => other.nextPreparationId != step.id),
orElse: () => allSteps.first,
);

final List<PreparationStepEntity> orderedSteps = [];
PreparationUser? currentStep = firstStep;

while (currentStep != null) {
orderedSteps.add(
PreparationStepEntity(
id: currentStep.id,
preparationName: currentStep.preparationName,
preparationTime: Duration(minutes: currentStep.preparationTime),
nextPreparationId: currentStep.nextPreparationId,
),
);
currentStep = allSteps.firstWhereOrNull(
(step) => step.id == currentStep!.nextPreparationId,
);
}

return PreparationEntity(preparationStepList: orderedSteps);
return PreparationEntity(
preparationStepList: [
for (final step in allSteps)
PreparationStepEntity(
id: step.id,
preparationName: step.preparationName,
preparationTime: Duration(minutes: step.preparationTime),
nextPreparationId: step.nextPreparationId,
),
],
).ordered;
}

Future<PreparationStepEntity> getPreparationStepById(
Expand Down
8 changes: 8 additions & 0 deletions lib/data/tables/preparation_schedule_table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import 'package:drift/drift.dart';
import 'package:on_time_front/data/tables/schedules_table.dart';
import 'package:uuid/uuid.dart';

@TableIndex(
name: 'preparation_schedules_schedule_id_idx',
columns: {#scheduleId},
)
@TableIndex(
name: 'preparation_schedules_next_preparation_id_idx',
columns: {#nextPreparationId},
)
class PreparationSchedules extends Table {
TextColumn get id => text().clientDefault(() => Uuid().v7())();
TextColumn get scheduleId => text().references(Schedules, #id)();
Expand Down
5 changes: 5 additions & 0 deletions lib/data/tables/preparation_user_table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import 'package:drift/drift.dart';
import 'package:on_time_front/data/tables/user_table.dart';
import 'package:uuid/uuid.dart';

@TableIndex(name: 'preparation_users_user_id_idx', columns: {#userId})
@TableIndex(
name: 'preparation_users_next_preparation_id_idx',
columns: {#nextPreparationId},
)
class PreparationUsers extends Table {
TextColumn get id => text().clientDefault(() => Uuid().v7())();
TextColumn get userId => text().references(Users, #id)();
Expand Down
2 changes: 2 additions & 0 deletions lib/data/tables/schedules_table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'package:on_time_front/core/utils/json_converters/duration_json_converter
import 'package:on_time_front/data/tables/places_table.dart';
import 'package:uuid/uuid.dart';

@TableIndex(name: 'schedules_schedule_time_idx', columns: {#scheduleTime})
@TableIndex(name: 'schedules_place_id_idx', columns: {#placeId})
class Schedules extends Table {
TextColumn get id => text().clientDefault(() => Uuid().v7())();
TextColumn get placeId => text().references(Places, #id)();
Expand Down
91 changes: 91 additions & 0 deletions test/core/database/database_index_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:on_time_front/core/database/database.dart';

void main() {
late AppDatabase database;

setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
});

tearDown(() async {
await database.close();
});

test(
'creates lookup indexes for schedule and preparation DAO predicates',
() async {
for (final entry in _expectedLookupIndexesByTable.entries) {
final indexNames = await _indexNames(database, entry.key);

expect(
indexNames,
containsAll(entry.value),
reason: '${entry.key} should declare indexes for DAO lookup paths',
);
}
},
);

test('adds lookup indexes when upgrading a schema 3 database', () async {
expect(database.schemaVersion, 4);

await _dropExpectedLookupIndexes(database);

for (final entry in _expectedLookupIndexesByTable.entries) {
expect(
await _indexNames(database, entry.key),
isNot(containsAll(entry.value)),
);
}

await database.migration.onUpgrade(database.createMigrator(), 3, 4);

for (final entry in _expectedLookupIndexesByTable.entries) {
final indexNames = await _indexNames(database, entry.key);

expect(
indexNames,
containsAll(entry.value),
reason: '${entry.key} indexes should be added by the 3 -> 4 migration',
);
}
});
}

const _expectedLookupIndexesByTable = {
'preparation_schedules': {
'preparation_schedules_schedule_id_idx',
'preparation_schedules_next_preparation_id_idx',
},
'preparation_users': {
'preparation_users_user_id_idx',
'preparation_users_next_preparation_id_idx',
},
'schedules': {'schedules_schedule_time_idx', 'schedules_place_id_idx'},
};

Future<Set<String>> _indexNames(AppDatabase database, String tableName) async {
final rows = await database
.customSelect(
'''
SELECT name
FROM sqlite_master
WHERE type = 'index' AND tbl_name = ?
''',
variables: [drift.Variable.withString(tableName)],
)
.get();

return {for (final row in rows) row.read<String>('name')};
}

Future<void> _dropExpectedLookupIndexes(AppDatabase database) async {
for (final indexNames in _expectedLookupIndexesByTable.values) {
for (final indexName in indexNames) {
await database.customStatement('DROP INDEX IF EXISTS $indexName');
}
}
}
Loading
Loading