diff --git a/CONTEXT.md b/CONTEXT.md index 4d156c37..39095a5d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,6 +41,14 @@ _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 +**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 + +**OnTime Session Established**: +The state where OnTime has accepted provider credentials, created an app session, and can route the user into the signed-in app experience. +_Avoid_: Provider login completed, credential received + **Analytics Event Parameter**: An allowlisted non-content value attached to a Product Usage Event. _Avoid_: Event payload, arbitrary metadata, raw detail @@ -102,6 +110,9 @@ _Avoid_: Notification, native alarm - 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. - First-release **Product Usage Events** are **Workflow Milestone Events**, not every tap or raw navigation step. - First-release **Workflow Milestone Events** cover analytics preference, onboarding, authentication, schedule, notification permission, alarm, and schedule-finish outcomes. +- **Provider Authentication Completed** precedes **OnTime Session Established** during Apple or Google sign-in. +- **Provider Authentication Completed** does not mean the user is signed in to OnTime. +- The signed-in app experience begins only after **OnTime Session Established**. - A **Product Usage Event** may include **Analytics Event Parameters** such as workflow, result, stable error category, coarse count, coarse duration, platform, or app version. - 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. @@ -153,6 +164,7 @@ _Avoid_: Notification, native alarm - "Third party" was ambiguous for analytics; resolved: the canonical term is **Analytics Provider**. - "Event taxonomy" was broad; resolved: first-release analytics tracks **Workflow Milestone Events** only. - "Event payload" was too open-ended; resolved: events use allowlisted **Analytics Event Parameters** only. +- "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**. - "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. - "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. - "Allowed" was ambiguous for permission requests; resolved: a request action is not the same as granted **Exact Timing Permission**. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 752af6aa..e6b4e476 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -123,6 +123,14 @@ "@signInSlogan": { "description": "Slogan on the sign-in screen" }, + "signInFailedTitle": "Sign-in failed", + "@signInFailedTitle": { + "description": "Dialog title shown when social sign-in fails after provider authentication" + }, + "signInFailedDescription": "Please try again in a moment.", + "@signInFailedDescription": { + "description": "Dialog description shown when social sign-in fails after provider authentication" + }, "welcome": "Welcome!", "@welcome": { "description": "Title on the onboarding start screen" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 33caae0a..bd808785 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -30,6 +30,8 @@ "finishPreparation": "준비 종료", "preparationReadyToGo": "출발 준비 완료", "signInSlogan": "당신의 잃어버린 여유를 찾아드립니다.", + "signInFailedTitle": "로그인에 실패했어요", + "signInFailedDescription": "잠시 후 다시 시도해 주세요.", "welcome": "반가워요!", "onboardingStartSubtitle": "Ontime과 함께 준비하기 위해서\n평소 본인의 준비 과정을 알려주세요", "start": "시작하기", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d15b4c15..19fa9ca4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -284,6 +284,18 @@ abstract class AppLocalizations { /// **'We\'ll find your lost leisure.'** String get signInSlogan; + /// Dialog title shown when social sign-in fails after provider authentication + /// + /// In en, this message translates to: + /// **'Sign-in failed'** + String get signInFailedTitle; + + /// Dialog description shown when social sign-in fails after provider authentication + /// + /// In en, this message translates to: + /// **'Please try again in a moment.'** + String get signInFailedDescription; + /// Title on the onboarding start screen /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c232c860..f21680be 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -107,6 +107,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get signInSlogan => 'We\'ll find your lost leisure.'; + @override + String get signInFailedTitle => 'Sign-in failed'; + + @override + String get signInFailedDescription => 'Please try again in a moment.'; + @override String get welcome => 'Welcome!'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a7115de6..e646908c 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -105,6 +105,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get signInSlogan => '당신의 잃어버린 여유를 찾아드립니다.'; + @override + String get signInFailedTitle => '로그인에 실패했어요'; + + @override + String get signInFailedDescription => '잠시 후 다시 시도해 주세요.'; + @override String get welcome => '반가워요!'; diff --git a/lib/presentation/login/components/google_sign_in_button/apple_sign_in_button_mobile.dart b/lib/presentation/login/components/google_sign_in_button/apple_sign_in_button_mobile.dart index f19051d6..5237ee65 100644 --- a/lib/presentation/login/components/google_sign_in_button/apple_sign_in_button_mobile.dart +++ b/lib/presentation/login/components/google_sign_in_button/apple_sign_in_button_mobile.dart @@ -1,67 +1,19 @@ import 'package:flutter/material.dart' hide IconAlignment; -import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/core/dio/api_error_message.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:sign_in_with_apple/sign_in_with_apple.dart'; class AppleSignInButton extends StatelessWidget { - const AppleSignInButton({super.key}); + const AppleSignInButton({super.key, this.onPressed}); + + final VoidCallback? onPressed; @override Widget build(BuildContext context) { - final UserRepository userRepository = getIt.get(); - return SizedBox( width: 358, height: 54, child: Material( color: Colors.transparent, child: InkWell( - onTap: () async { - try { - final credential = await SignInWithApple.getAppleIDCredential( - scopes: [ - AppleIDAuthorizationScopes.email, - AppleIDAuthorizationScopes.fullName, - ], - ); - - final fullNameRaw = - '${credential.givenName ?? ''} ${credential.familyName ?? ''}' - .trim(); - final fullName = fullNameRaw.isNotEmpty - ? fullNameRaw - : 'Apple User'; - - final identityToken = credential.identityToken; - final authorizationCode = credential.authorizationCode; - if (identityToken == null) { - throw Exception('Apple Sign In Failed: Missing credentials'); - } - - await userRepository.signInWithApple( - idToken: identityToken, - authCode: authorizationCode, - fullName: fullName, - email: credential.email, - ); - } catch (error, stackTrace) { - AppLogger.debug( - 'Apple Sign-In button failed errorType=${error.runtimeType} ' - 'message=$error stackTrace=$stackTrace', - ); - if (!context.mounted) { - return; - } - final message = - ApiErrorMessage.fromException(error) ?? - 'Apple 로그인에 실패했습니다. 잠시 후 다시 시도해 주세요.'; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } - }, + onTap: onPressed, borderRadius: BorderRadius.circular(14), child: Image.asset( 'appleid_button.png', diff --git a/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_mobile.dart b/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_mobile.dart index b6b3b814..0629e513 100644 --- a/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_mobile.dart +++ b/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_mobile.dart @@ -1,61 +1,26 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/core/logging/app_logger.dart'; -import 'package:on_time_front/domain/repositories/user_repository.dart'; -import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; -class GoogleSignInButton extends StatefulWidget { - const GoogleSignInButton({super.key}); +class GoogleSignInButton extends StatelessWidget { + const GoogleSignInButton({super.key, this.onPressed}); - @override - State createState() => _GoogleSignInButtonState(); -} - -class _GoogleSignInButtonState extends State { - bool _isSigningIn = false; + final VoidCallback? onPressed; @override Widget build(BuildContext context) { - final UserRepository authenticationRepository = getIt.get(); - final canSignIn = context.select( - (bloc) => bloc.state.status == AuthStatus.unauthenticated, - ); - return SizedBox( width: 358, height: 54, child: ElevatedButton( - onPressed: !canSignIn || _isSigningIn - ? null - : () async { - setState(() { - _isSigningIn = true; - }); - try { - final googleAccount = await authenticationRepository - .authenticateWithGoogle(); - await authenticationRepository.signInWithGoogle( - googleAccount, - ); - } catch (error) { - AppLogger.debug( - 'Google Sign-In button failed errorType=${error.runtimeType}', - ); - } finally { - if (mounted) { - setState(() { - _isSigningIn = false; - }); - } - } - }, + onPressed: onPressed, style: ElevatedButton.styleFrom( backgroundColor: Colors.white, + disabledBackgroundColor: Colors.white, foregroundColor: Colors.black, + disabledForegroundColor: Colors.black, elevation: 1, shadowColor: Colors.black26, + disabledMouseCursor: SystemMouseCursors.basic, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), side: BorderSide(color: Color(0xFFDADCE0), width: 1), @@ -74,15 +39,19 @@ class _GoogleSignInButtonState extends State { height: 20, ), SizedBox(width: 10), - Text( - 'Sign in with Google', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w600, - fontSize: 21, - height: 1.4, - letterSpacing: 0, - color: Colors.black87, + Flexible( + child: Text( + 'Sign in with Google', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 21, + height: 1.4, + letterSpacing: 0, + color: Colors.black87, + ), ), ), ], diff --git a/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_web.dart b/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_web.dart index 2f15bafa..f40fdb67 100644 --- a/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_web.dart +++ b/lib/presentation/login/components/google_sign_in_button/google_sign_in_button_web.dart @@ -7,7 +7,9 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/domain/repositories/user_repository.dart'; class GoogleSignInButton extends StatefulWidget { - const GoogleSignInButton({super.key}); + const GoogleSignInButton({super.key, this.onPressed}); + + final VoidCallback? onPressed; @override State createState() => _GoogleSignInButtonState(); diff --git a/lib/presentation/login/components/google_sign_in_button/unsupported.dart b/lib/presentation/login/components/google_sign_in_button/unsupported.dart index 9c014e55..83086202 100644 --- a/lib/presentation/login/components/google_sign_in_button/unsupported.dart +++ b/lib/presentation/login/components/google_sign_in_button/unsupported.dart @@ -1,7 +1,9 @@ import 'package:flutter/widgets.dart'; class GoogleSignInButton extends StatelessWidget { - const GoogleSignInButton({super.key}); + const GoogleSignInButton({super.key, this.onPressed}); + + final VoidCallback? onPressed; @override Widget build(BuildContext context) { diff --git a/lib/presentation/login/screens/sign_in_main_screen.dart b/lib/presentation/login/screens/sign_in_main_screen.dart index 52d3be5e..367e8a40 100644 --- a/lib/presentation/login/screens/sign_in_main_screen.dart +++ b/lib/presentation/login/screens/sign_in_main_screen.dart @@ -2,13 +2,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/foundation.dart'; import 'dart:io' show Platform; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/core/logging/app_logger.dart'; +import 'package:on_time_front/domain/repositories/user_repository.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; +import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; +import 'package:sign_in_with_apple/sign_in_with_apple.dart'; import '../components/google_sign_in_button/shared.dart'; import '../components/google_sign_in_button/apple_sign_in_button_mobile.dart'; -class SignInMainScreen extends StatelessWidget { - const SignInMainScreen({super.key}); +typedef SocialSignInAction = Future Function(); + +class SignInMainScreen extends StatefulWidget { + const SignInMainScreen({super.key, this.onAppleSignIn, this.onGoogleSignIn}); + + final SocialSignInAction? onAppleSignIn; + final SocialSignInAction? onGoogleSignIn; + + @override + State createState() => _SignInMainScreenState(); +} + +class _SignInMainScreenState extends State { + bool _isSigningIn = false; @override Widget build(BuildContext context) { @@ -22,15 +41,123 @@ class SignInMainScreen extends StatelessWidget { _CharacterImage(), SizedBox(height: 41), if (!kIsWeb && Platform.isIOS) ...[ - AppleSignInButton(), + AppleSignInButton( + onPressed: _isSigningIn + ? null + : () => _startSignIn( + widget.onAppleSignIn ?? _defaultAppleSignIn, + ), + ), SizedBox(height: 16), ], - GoogleSignInButton(), + GoogleSignInButton( + onPressed: _isSigningIn + ? null + : () => _startSignIn( + widget.onGoogleSignIn ?? _defaultGoogleSignIn, + ), + ), ], ), ), ); } + + Future _startSignIn(SocialSignInAction signIn) async { + if (_isSigningIn) { + return; + } + + setState(() { + _isSigningIn = true; + }); + + try { + await signIn(); + } catch (error, stackTrace) { + if (_isUserCancellation(error)) { + AppLogger.debug( + 'Social sign-in canceled errorType=${error.runtimeType}', + ); + return; + } + + AppLogger.debug( + 'Social sign-in failed errorType=${error.runtimeType} ' + 'stackTrace=$stackTrace', + ); + if (mounted) { + _restoreSignInButtons(); + await _showSignInFailureDialog(); + } + } finally { + if (mounted && _isSigningIn) { + _restoreSignInButtons(); + } + } + } + + void _restoreSignInButtons() { + setState(() { + _isSigningIn = false; + }); + } + + Future _defaultGoogleSignIn() async { + final userRepository = getIt.get(); + final googleAccount = await userRepository.authenticateWithGoogle(); + await userRepository.signInWithGoogle(googleAccount); + } + + Future _defaultAppleSignIn() async { + final userRepository = getIt.get(); + final credential = await SignInWithApple.getAppleIDCredential( + scopes: [ + AppleIDAuthorizationScopes.email, + AppleIDAuthorizationScopes.fullName, + ], + ); + + final fullNameRaw = + '${credential.givenName ?? ''} ${credential.familyName ?? ''}'.trim(); + final fullName = fullNameRaw.isNotEmpty ? fullNameRaw : 'Apple User'; + + final identityToken = credential.identityToken; + final authorizationCode = credential.authorizationCode; + if (identityToken == null) { + throw Exception('Apple Sign In Failed: Missing credentials'); + } + + await userRepository.signInWithApple( + idToken: identityToken, + authCode: authorizationCode, + fullName: fullName, + email: credential.email, + ); + } + + bool _isUserCancellation(Object error) { + return error is GoogleSignInException && + error.code == GoogleSignInExceptionCode.canceled || + error is SignInWithAppleAuthorizationException && + error.code == AuthorizationErrorCode.canceled; + } + + Future _showSignInFailureDialog() { + final l10n = AppLocalizations.of(context)!; + + return showTwoActionDialog( + context, + config: TwoActionDialogConfig( + title: l10n.signInFailedTitle, + description: l10n.signInFailedDescription, + primaryAction: DialogActionConfig( + label: l10n.ok, + variant: ModalWideButtonVariant.destructive, + ), + ), + ); + } } class _Title extends StatelessWidget { @@ -41,16 +168,11 @@ class _Title extends StatelessWidget { return Column( spacing: 28, children: [ - Image.asset( - 'logo.png', - package: 'assets', - width: 167, + Image.asset('logo.png', package: 'assets', width: 167), + Text( + AppLocalizations.of(context)!.signInSlogan, + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w400), ), - Text(AppLocalizations.of(context)!.signInSlogan, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w400, - )), ], ); } @@ -62,11 +184,9 @@ class _CharacterImage extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - height: 241, - child: SvgPicture.asset( - 'characters/character.svg', - package: 'assets', - )); + height: 241, + child: SvgPicture.asset('characters/character.svg', package: 'assets'), + ); } } diff --git a/lib/presentation/shared/router/go_router.dart b/lib/presentation/shared/router/go_router.dart index a654cc86..2f93a0c9 100644 --- a/lib/presentation/shared/router/go_router.dart +++ b/lib/presentation/shared/router/go_router.dart @@ -47,42 +47,12 @@ GoRouter goRouterConfig( ]), navigatorKey: getIt.get().navigatorKey, redirect: (BuildContext context, GoRouterState state) { - final authStatus = authBloc.state.status; - final notificationGateStatus = notificationGateCubit.state.status; - final alarmGateStatus = alarmGateCubit.state.status; - final path = state.uri.path; - final isStartupRoute = path == '/startup'; - final isPublicRoute = isStartupRoute || path == '/signIn'; - final isOnboardingRoute = - path == '/onboarding' || path == '/onboarding/start'; - final isNotificationRoute = path == '/allowNotification'; - final isAlarmRoute = path == '/allowAlarm'; - final isTransientRoute = - isPublicRoute || - isOnboardingRoute || - isNotificationRoute || - isAlarmRoute; - - switch (authStatus) { - case AuthStatus.loading: - return isStartupRoute ? null : '/startup'; - case AuthStatus.unauthenticated: - return path == '/signIn' ? null : '/signIn'; - case AuthStatus.authenticated: - if (!notificationGateCubit.state.isResolved || - !alarmGateCubit.state.isResolved) { - return isStartupRoute ? null : '/startup'; - } - if (notificationGateStatus == NotificationGateStatus.required) { - return isNotificationRoute ? null : '/allowNotification'; - } - if (alarmGateStatus == AlarmGateStatus.required) { - return isAlarmRoute ? null : '/allowAlarm'; - } - return isTransientRoute ? '/home' : null; - case AuthStatus.onboardingNotCompleted: - return isOnboardingRoute ? null : '/onboarding/start'; - } + return appRedirectLocation( + authStatus: authBloc.state.status, + notificationGateState: notificationGateCubit.state, + alarmGateState: alarmGateCubit.state, + path: state.uri.path, + ); }, initialLocation: '/startup', routes: [ @@ -250,6 +220,43 @@ GoRouter goRouterConfig( ); } +@visibleForTesting +String? appRedirectLocation({ + required AuthStatus authStatus, + required NotificationGateState notificationGateState, + required AlarmGateState alarmGateState, + required String path, +}) { + final isStartupRoute = path == '/startup'; + final isPublicRoute = isStartupRoute || path == '/signIn'; + final isOnboardingRoute = + path == '/onboarding' || path == '/onboarding/start'; + final isNotificationRoute = path == '/allowNotification'; + final isAlarmRoute = path == '/allowAlarm'; + final isTransientRoute = + isPublicRoute || isOnboardingRoute || isNotificationRoute || isAlarmRoute; + + switch (authStatus) { + case AuthStatus.loading: + return isStartupRoute ? null : '/startup'; + case AuthStatus.unauthenticated: + return path == '/signIn' ? null : '/signIn'; + case AuthStatus.authenticated: + if (notificationGateState.status == NotificationGateStatus.required) { + return isNotificationRoute ? null : '/allowNotification'; + } + if (!notificationGateState.isResolved || !alarmGateState.isResolved) { + return isStartupRoute ? null : '/startup'; + } + if (alarmGateState.status == AlarmGateStatus.required) { + return isAlarmRoute ? null : '/allowAlarm'; + } + return isTransientRoute ? '/home' : null; + case AuthStatus.onboardingNotCompleted: + return isOnboardingRoute ? null : '/onboarding/start'; + } +} + CustomTransitionPage _buildAppRoutePage({ required GoRouterState state, AppRouteTransition transition = AppRouteTransition.standard, diff --git a/test/presentation/login/screens/sign_in_main_screen_test.dart b/test/presentation/login/screens/sign_in_main_screen_test.dart new file mode 100644 index 00000000..f66fca0f --- /dev/null +++ b/test/presentation/login/screens/sign_in_main_screen_test.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/login/screens/sign_in_main_screen.dart'; +import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets( + 'social sign-in buttons stay visible and ignore taps while session is pending', + (tester) async { + final signInCompleter = Completer(); + var signInAttempts = 0; + + await _pumpSubject( + tester, + onGoogleSignIn: () { + signInAttempts += 1; + return signInCompleter.future; + }, + ); + + await tester.tap(find.text('Sign in with Google')); + await tester.pump(); + await tester.tap(find.text('Sign in with Google')); + await tester.pump(); + + expect(signInAttempts, 1); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Sign in with Google'), findsOneWidget); + final googleButton = tester.widget( + find.byType(ElevatedButton), + ); + expect( + googleButton.style?.backgroundColor?.resolve({WidgetState.disabled}), + Colors.white, + ); + expect( + googleButton.style?.foregroundColor?.resolve({WidgetState.disabled}), + Colors.black, + ); + }, + ); + + testWidgets('failed social sign-in restores buttons and shows error dialog', ( + tester, + ) async { + await _pumpSubject( + tester, + onGoogleSignIn: () async => throw Exception('backend failed'), + ); + + await tester.tap(find.text('Sign in with Google')); + await tester.pumpAndSettle(); + + expect(find.text('로그인에 실패했어요'), findsOneWidget); + expect(find.text('잠시 후 다시 시도해 주세요.'), findsOneWidget); + expect(find.text('Sign in with Google'), findsOneWidget); + expect( + tester.widget(find.byType(ModalWideButton)).variant, + ModalWideButtonVariant.destructive, + ); + }); + + testWidgets( + 'canceled social sign-in restores buttons without showing error dialog', + (tester) async { + await _pumpSubject( + tester, + onGoogleSignIn: () async => throw const GoogleSignInException( + code: GoogleSignInExceptionCode.canceled, + ), + ); + + await tester.tap(find.text('Sign in with Google')); + await tester.pumpAndSettle(); + + expect(find.text('로그인에 실패했어요'), findsNothing); + expect(find.text('Sign in with Google'), findsOneWidget); + }, + ); +} + +Future _pumpSubject( + WidgetTester tester, { + Future Function()? onGoogleSignIn, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: const Locale('ko'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SignInMainScreen(onGoogleSignIn: onGoogleSignIn), + ), + ); +} diff --git a/test/presentation/shared/router/go_router_redirect_test.dart b/test/presentation/shared/router/go_router_redirect_test.dart new file mode 100644 index 00000000..fa1e0862 --- /dev/null +++ b/test/presentation/shared/router/go_router_redirect_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/app/cubit/alarm_gate_cubit.dart'; +import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; +import 'package:on_time_front/presentation/shared/router/go_router.dart'; + +void main() { + test( + 'authenticated user goes directly to notification prompt while alarm gate is unresolved', + () { + final redirect = appRedirectLocation( + authStatus: AuthStatus.authenticated, + notificationGateState: const NotificationGateState.required(), + alarmGateState: const AlarmGateState.initial(), + path: '/signIn', + ); + + expect(redirect, '/allowNotification'); + }, + ); + + test( + 'authenticated user waits on startup while gates are still unresolved', + () { + final redirect = appRedirectLocation( + authStatus: AuthStatus.authenticated, + notificationGateState: const NotificationGateState.initial(), + alarmGateState: const AlarmGateState.initial(), + path: '/signIn', + ); + + expect(redirect, '/startup'); + }, + ); +}