Skip to content

Commit 27152de

Browse files
committed
chore: merge latest main into schedule session branch
2 parents c45c0ed + 67909d6 commit 27152de

32 files changed

Lines changed: 1400 additions & 999 deletions

CONTEXT.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,12 @@ An allowlisted non-content value attached to a Product Usage Event.
8686
_Avoid_: Event payload, arbitrary metadata, raw detail
8787

8888
**Schedule**:
89-
A planned commitment the user wants to arrive at or complete on time.
90-
_Avoid_: Event, appointment, alarm target
89+
A planned commitment with a place, appointment time, travel time, optional buffer time, and preparation.
90+
_Avoid_: Event, appointment record
9191

9292
**Preparation**:
93-
The ordered set of steps a user intends to complete before a Schedule.
94-
_Avoid_: Routine, checklist, template instance
93+
An ordered set of steps and durations used to get ready for a Schedule.
94+
_Avoid_: Routine, prep checklist
9595

9696
**Preparation Step**:
9797
One named action in a Preparation with its own expected duration.
@@ -117,6 +117,18 @@ _Avoid_: Total duration, travel time, buffer time
117117
A user buffer before a Schedule's appointment time, separate from travel time and Preparation Duration.
118118
_Avoid_: Preparation time, move time
119119

120+
**Default Preparation**:
121+
The user's fallback Preparation for new Schedules.
122+
_Avoid_: Base preparation, global preparation
123+
124+
**Custom Preparation**:
125+
A Schedule-specific Preparation that differs from the user's fallback or selected template.
126+
_Avoid_: Changed preparation, edited default
127+
128+
**Preparation Mode**:
129+
The category describing whether a Schedule uses Default Preparation, a preparation template, or Custom Preparation.
130+
_Avoid_: Preparation type, preparation source
131+
120132
**Schedule Notification**:
121133
A user-facing notification that starts preparation for a scheduled commitment at the intended moment.
122134
_Avoid_: Schedule alarm, alarm, push
@@ -191,6 +203,11 @@ _Avoid_: Loaded range, stream range, cached range
191203

192204
## Relationships
193205

206+
- A **Product Usage Event** may describe a **Schedule**, **Preparation**, notification, alarm, onboarding, or account action without storing the user's raw schedule names, notes, place names, credentials, tokens, or free text.
207+
- A **Schedule** has one effective **Preparation** for calculating preparation timing.
208+
- A **Default Preparation** may seed a new **Schedule** before the user chooses a different preparation.
209+
- A **Custom Preparation** belongs to one **Schedule**.
210+
- **Preparation Mode** may appear as an **Analytics Event Parameter** without exposing preparation step names.
194211
- A **Monthly Calendar** displays **Schedules** grouped by calendar day.
195212
- A **Calendar Month Range** starts at the first day of its first month and ends before the first day of the month after its last month.
196213
- A **Monthly Calendar** may extend a **Calendar Month Range** when the user moves to an adjacent month.
@@ -200,7 +217,6 @@ _Avoid_: Loaded range, stream range, cached range
200217
- A **Preparation** contains zero or more **Preparation Steps** in user-defined order.
201218
- A **Preparation Step** belongs to exactly one **Preparation**.
202219
- A **Schedule Notification** uses **Schedule** and **Preparation** timing, but is not itself a **Schedule** or **Preparation**.
203-
- A **Product Usage Event** may describe a schedule, preparation, notification, alarm, onboarding, or account action without storing the user's raw schedule names, notes, place names, credentials, tokens, or free text.
204220
- First-release **Product Usage Events** are **Workflow Milestone Events**, not every tap or raw navigation step.
205221
- First-release **Workflow Milestone Events** cover analytics preference, onboarding, authentication, schedule, notification permission, alarm, and schedule-finish outcomes.
206222
- **Provider Authentication Completed** precedes **OnTime Session Established** during Apple or Google sign-in.
@@ -286,6 +302,7 @@ _Avoid_: Loaded range, stream range, cached range
286302
- "Event payload" was too open-ended; resolved: events use allowlisted **Analytics Event Parameters** only.
287303
- "Schedule preparation session" was implicit in code but not in the glossary; resolved: the canonical term is **Schedule Preparation Session**, with **Early Start Session** for sessions started before the **Preparation Start Moment**.
288304
- "Login completed" was ambiguous for Apple and Google sign-in; resolved: external account prompt completion is **Provider Authentication Completed**, while usable OnTime sign-in is **OnTime Session Established**.
305+
- "Preparation" was ambiguous between the user's fallback steps and a schedule-specific edited set; resolved: use **Default Preparation** for the fallback and **Custom Preparation** for the schedule-specific version.
289306
- "Alarm permission" was ambiguous between **Exact Timing Permission** and notification permission; resolved: notification permission may enable a **Fallback Notification**, but does not mean **Exact Timing Permission** is granted.
290307
- "Pending" was ambiguous for notification status; resolved: the canonical state is **No Scheduled Notification** when notifications are enabled but no upcoming Schedule Notification is armed.
291308
- "Allowed" was ambiguous for permission requests; resolved: a request action is not the same as granted **Exact Timing Permission**.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:google_sign_in/google_sign_in.dart';
3+
import 'package:injectable/injectable.dart';
4+
import 'package:on_time_front/core/logging/app_logger.dart';
5+
import 'package:on_time_front/domain/entities/google_auth_credential.dart';
6+
7+
abstract interface class GoogleAuthenticationService {
8+
Stream<GoogleAuthCredential> get authenticationCredentials;
9+
10+
bool get supportsAuthenticate;
11+
12+
Future<void> initialize();
13+
14+
Future<GoogleAuthCredential> authenticate();
15+
16+
Future<void> disconnect();
17+
}
18+
19+
class GoogleAuthenticationCanceledException implements Exception {
20+
const GoogleAuthenticationCanceledException();
21+
}
22+
23+
@Singleton(as: GoogleAuthenticationService)
24+
class GoogleSignInAuthenticationService implements GoogleAuthenticationService {
25+
GoogleSignInAuthenticationService({@ignoreParam GoogleSignIn? googleSignIn})
26+
: _googleSignIn = googleSignIn ?? GoogleSignIn.instance;
27+
28+
static const _googleIosClientId =
29+
'456571312261-r35ah9qi0qaq7al007e2db0e0jmjcmb4.apps.googleusercontent.com';
30+
static const _googleServerClientId =
31+
'456571312261-5kuf2r6i5i7lqjr7qealv06sdgkn3hcp.apps.googleusercontent.com';
32+
static const _googleScopes = ['email', 'profile'];
33+
34+
final GoogleSignIn _googleSignIn;
35+
Future<void>? _initialization;
36+
37+
@override
38+
Stream<GoogleAuthCredential> get authenticationCredentials => _googleSignIn
39+
.authenticationEvents
40+
.where((event) => event is GoogleSignInAuthenticationEventSignIn)
41+
.cast<GoogleSignInAuthenticationEventSignIn>()
42+
.map((event) => _credentialFromAccount(event.user));
43+
44+
@override
45+
bool get supportsAuthenticate => _googleSignIn.supportsAuthenticate();
46+
47+
@override
48+
Future<void> initialize() {
49+
return _initialization ??= _initialize();
50+
}
51+
52+
Future<void> _initialize() async {
53+
await _googleSignIn.initialize(
54+
clientId: _googleClientId,
55+
serverClientId: _googleServerClientId,
56+
);
57+
}
58+
59+
@override
60+
Future<GoogleAuthCredential> authenticate() async {
61+
try {
62+
await initialize();
63+
final account = await _googleSignIn.authenticate(
64+
scopeHint: _googleScopes,
65+
);
66+
return _credentialFromAccount(account);
67+
} on GoogleSignInException catch (error) {
68+
if (error.code == GoogleSignInExceptionCode.canceled) {
69+
throw const GoogleAuthenticationCanceledException();
70+
}
71+
rethrow;
72+
}
73+
}
74+
75+
@override
76+
Future<void> disconnect() async {
77+
try {
78+
await _googleSignIn.disconnect();
79+
AppLogger.debug('Google Sign-In disconnected');
80+
} catch (error) {
81+
AppLogger.debug(
82+
'Google Sign-In disconnect failed errorType=${error.runtimeType}',
83+
);
84+
}
85+
}
86+
87+
GoogleAuthCredential _credentialFromAccount(GoogleSignInAccount account) {
88+
final idToken = account.authentication.idToken;
89+
if (idToken == null) {
90+
throw Exception('Google ID Token is null');
91+
}
92+
return GoogleAuthCredential(idToken: idToken);
93+
}
94+
95+
String? get _googleClientId {
96+
if (kIsWeb) return null;
97+
return defaultTargetPlatform == TargetPlatform.iOS
98+
? _googleIosClientId
99+
: null;
100+
}
101+
}

lib/data/repositories/user_repository_impl.dart

Lines changed: 9 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,34 @@
11
import 'dart:async';
22

33
import 'package:dio/dio.dart';
4-
import 'package:flutter/foundation.dart';
5-
import 'package:google_sign_in/google_sign_in.dart';
64
import 'package:injectable/injectable.dart';
75
import 'package:on_time_front/core/logging/app_logger.dart';
6+
import 'package:on_time_front/core/services/google_authentication_service.dart';
87
import 'package:on_time_front/core/validation/backend_constraints.dart';
98
import 'package:on_time_front/data/data_sources/authentication_remote_data_source.dart';
109
import 'package:on_time_front/data/data_sources/token_local_data_source.dart';
1110
import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart';
1211
import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart';
12+
import 'package:on_time_front/domain/entities/google_auth_credential.dart';
1313
import 'package:on_time_front/domain/entities/user_entity.dart';
1414
import 'package:on_time_front/domain/repositories/user_repository.dart';
1515
import 'package:rxdart/subjects.dart';
1616

1717
@Singleton(as: UserRepository)
1818
class UserRepositoryImpl implements UserRepository {
19-
static const _googleIosClientId =
20-
'456571312261-r35ah9qi0qaq7al007e2db0e0jmjcmb4.apps.googleusercontent.com';
21-
static const _googleServerClientId =
22-
'456571312261-5kuf2r6i5i7lqjr7qealv06sdgkn3hcp.apps.googleusercontent.com';
23-
static const _googleScopes = ['email', 'profile'];
24-
2519
final AuthenticationRemoteDataSource _authenticationRemoteDataSource;
2620
final TokenLocalDataSource _tokenLocalDataSource;
27-
final GoogleSignIn _googleSignIn = GoogleSignIn.instance;
28-
Future<void>? _googleSignInInitialization;
21+
final GoogleAuthenticationService _googleAuthenticationService;
2922
late final _userStreamController = BehaviorSubject<UserEntity>.seeded(
3023
const UserEntity.empty(),
3124
);
3225

33-
@override
34-
Stream<GoogleSignInAuthenticationEvent> get googleAuthenticationEvents =>
35-
_googleSignIn.authenticationEvents;
36-
37-
@override
38-
bool get supportsGoogleAuthenticate => _googleSignIn.supportsAuthenticate();
39-
4026
UserRepositoryImpl(
4127
this._authenticationRemoteDataSource,
4228
this._tokenLocalDataSource,
29+
this._googleAuthenticationService,
4330
);
4431

45-
@override
46-
Future<void> initializeGoogleSignIn() {
47-
return _googleSignInInitialization ??= _initializeGoogleSignIn();
48-
}
49-
50-
Future<void> _initializeGoogleSignIn() async {
51-
await _googleSignIn.initialize(
52-
clientId: _googleClientId,
53-
serverClientId: _googleServerClientId,
54-
);
55-
}
56-
57-
@override
58-
Future<GoogleSignInAccount> authenticateWithGoogle() async {
59-
await initializeGoogleSignIn();
60-
return _googleSignIn.authenticate(scopeHint: _googleScopes);
61-
}
62-
6332
@override
6433
Future<UserEntity> getUser() async {
6534
try {
@@ -122,16 +91,14 @@ class UserRepositoryImpl implements UserRepository {
12291
}
12392

12493
@override
125-
Future<void> signInWithGoogle(GoogleSignInAccount googleUser) async {
94+
Future<void> signInWithGoogle(GoogleAuthCredential credential) async {
12695
try {
127-
final GoogleSignInAuthentication googleAuth = googleUser.authentication;
128-
final String? idToken = googleAuth.idToken;
129-
if (idToken == null) {
96+
if (credential.idToken.isEmpty) {
13097
throw Exception('Google ID Token is null');
13198
}
13299
final signInWithGoogleRequestModel = SignInWithGoogleRequestModel(
133-
idToken: idToken,
134-
refreshToken: '',
100+
idToken: credential.idToken,
101+
refreshToken: credential.refreshToken,
135102
);
136103
await _tokenLocalDataSource.deleteToken();
137104
final result = await _authenticationRemoteDataSource.signInWithGoogle(
@@ -225,8 +192,7 @@ class UserRepositoryImpl implements UserRepository {
225192
@override
226193
Future<void> disconnectGoogleSignIn() async {
227194
try {
228-
await _googleSignIn.disconnect();
229-
AppLogger.debug('Google Sign-In disconnected');
195+
await _googleAuthenticationService.disconnect();
230196
} catch (error) {
231197
AppLogger.debug(
232198
'Google Sign-In disconnect failed errorType=${error.runtimeType}',
@@ -237,11 +203,4 @@ class UserRepositoryImpl implements UserRepository {
237203
@override
238204
Stream<UserEntity> get userStream =>
239205
_userStreamController.asBroadcastStream();
240-
241-
String? get _googleClientId {
242-
if (kIsWeb) return null;
243-
return defaultTargetPlatform == TargetPlatform.iOS
244-
? _googleIosClientId
245-
: null;
246-
}
247206
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class GoogleAuthCredential {
2+
const GoogleAuthCredential({required this.idToken, this.refreshToken = ''});
3+
4+
final String idToken;
5+
final String refreshToken;
6+
}

lib/domain/repositories/user_repository.dart

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
1-
import 'package:google_sign_in/google_sign_in.dart';
1+
import 'package:on_time_front/domain/entities/google_auth_credential.dart';
22
import 'package:on_time_front/domain/entities/user_entity.dart';
33

44
abstract interface class UserRepository {
55
Stream<UserEntity> get userStream;
66

7-
Stream<GoogleSignInAuthenticationEvent> get googleAuthenticationEvents;
8-
9-
Future<void> initializeGoogleSignIn();
10-
11-
bool get supportsGoogleAuthenticate;
12-
13-
Future<GoogleSignInAccount> authenticateWithGoogle();
14-
157
Future<void> signUp({
168
required String email,
179
required String password,
@@ -22,7 +14,7 @@ abstract interface class UserRepository {
2214

2315
Future<void> signOut();
2416

25-
Future<void> signInWithGoogle(GoogleSignInAccount account);
17+
Future<void> signInWithGoogle(GoogleAuthCredential credential);
2618

2719
Future<void> signInWithApple({
2820
required String idToken,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import 'package:injectable/injectable.dart';
2+
import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart';
3+
import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart';
4+
import 'package:on_time_front/domain/use-cases/schedule_analytics_tracker.dart';
5+
import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart';
6+
7+
@Injectable()
8+
class CreateScheduleFormSubmissionUseCase {
9+
final CreateScheduleWithPlaceUseCase _createScheduleWithPlaceUseCase;
10+
final CreateCustomPreparationUseCase _createCustomPreparationUseCase;
11+
final ScheduleAnalyticsTracker _scheduleAnalyticsTracker;
12+
13+
CreateScheduleFormSubmissionUseCase(
14+
this._createScheduleWithPlaceUseCase,
15+
this._createCustomPreparationUseCase,
16+
this._scheduleAnalyticsTracker,
17+
);
18+
19+
Future<void> call(ScheduleFormSubmission submission) async {
20+
await _createScheduleWithPlaceUseCase(submission.schedule);
21+
if (submission.preparationChanged) {
22+
await _createCustomPreparationUseCase(
23+
submission.preparation,
24+
submission.schedule.id,
25+
);
26+
}
27+
await _scheduleAnalyticsTracker.trackScheduleCreated(
28+
schedule: submission.schedule,
29+
preparation: submission.preparation,
30+
);
31+
}
32+
}

0 commit comments

Comments
 (0)