diff --git a/.github/workflows/deploy-ios.yml b/.github/workflows/deploy-ios.yml index 69899ce..395828d 100644 --- a/.github/workflows/deploy-ios.yml +++ b/.github/workflows/deploy-ios.yml @@ -13,6 +13,9 @@ # ASC_KEY_ID = API 키 Key ID # ASC_ISSUER_ID = API 키 Issuer ID # SUPABASE_URL / SUPABASE_ANON_KEY / API_BASE_URL = 운영 dart-define 주입값(Android 와 공유) +# (선택) GOOGLE_IOS_CLIENT_ID / GOOGLE_WEB_CLIENT_ID = iOS 네이티브 Google 로그인용. +# 미설정이면 앱이 웹 OAuth 로 폴백하므로 배포는 막지 않는다. 설정 시 Info.plist 의 +# reversed client ID scheme + Supabase Google provider Authorized Client IDs 도 함께 필요. name: deploy-ios on: @@ -96,5 +99,8 @@ jobs: SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} API_BASE_URL: ${{ secrets.API_BASE_URL }} + # iOS 네이티브 Google 로그인(선택). 미설정이면 빈 값 → 앱이 웹 OAuth 로 폴백(배포 안 막힘). + GOOGLE_IOS_CLIENT_ID: ${{ secrets.GOOGLE_IOS_CLIENT_ID }} + GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} BUILD_NUMBER: ${{ env.BUILD_NUMBER }} run: fastlane beta diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2149326..844967c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -78,11 +78,15 @@ CFBundleURLTypes @@ -94,6 +98,14 @@ app.ieoseo + + CFBundleURLName + google-native-signin + CFBundleURLSchemes + + com.googleusercontent.apps.621764515915-29s6pj2efakomiejh6q10b5na7dk2d1f + + diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 1117e6f..3310e86 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -68,11 +68,15 @@ platform :ios do # dart-define 값(SUPABASE_*/API_BASE_URL)을 명령줄에 직접 넣으면 fastlane sh() 가 # 확장된 전체 명령을 로그에 echo 하고 다른 프로세스의 /proc//cmdline 에도 노출된다. # 임시 JSON 파일 + --dart-define-from-file 로 빼서 시크릿이 명령줄에 안 실리게 한다(빌드 후 삭제). + # GOOGLE_* 는 iOS 네이티브 Google 로그인용(선택). 미설정이면 빈 문자열 → 앱이 웹 OAuth + # 로 폴백하므로 배포를 막지 않는다(필수 gate 에 넣지 않음). define_file = "#{WORKSPACE}/ios/dart-define.prod.json" File.write(define_file, JSON.generate( "SUPABASE_URL" => ENV.fetch("SUPABASE_URL"), "SUPABASE_ANON_KEY" => ENV.fetch("SUPABASE_ANON_KEY"), "API_BASE_URL" => ENV.fetch("API_BASE_URL"), + "GOOGLE_IOS_CLIENT_ID" => ENV.fetch("GOOGLE_IOS_CLIENT_ID", ""), + "GOOGLE_WEB_CLIENT_ID" => ENV.fetch("GOOGLE_WEB_CLIENT_ID", ""), )) begin sh( diff --git a/lib/data/auth/google_native.dart b/lib/data/auth/google_native.dart new file mode 100644 index 0000000..f25f07c --- /dev/null +++ b/lib/data/auth/google_native.dart @@ -0,0 +1,84 @@ +import 'package:flutter/foundation.dart' show TargetPlatform; +import 'package:google_sign_in/google_sign_in.dart'; + +import 'social_auth.dart'; + +/// iOS 네이티브 Google 로그인 지원(ADR-0028 후속: Apple 에 이어 Google 도 네이티브). +/// +/// 웹 OAuth(`signInWithOAuth`, accounts.google.com 외부 Safari 이탈)와 달리, iOS 에서는 +/// 네이티브 시트로 받은 `idToken` 을 Supabase `signInWithIdToken` 에 넘긴다. 앱은 Google API +/// 를 직접 호출하지 않으므로 accessToken·nonce 는 쓰지 않고 `idToken` 만 교환한다. +/// +/// client ID 는 dart-define(`GOOGLE_IOS_CLIENT_ID`/`GOOGLE_WEB_CLIENT_ID`)으로 주입한다. +/// 둘 다 없으면 [GoogleSignInPlugin.isConfigured] 가 false → 게이트웨이가 웹 OAuth 로 폴백한다 +/// (콘솔 미설정 환경·로컬 개발에서 네이티브가 깨지지 않게). + +/// 해당 provider/플랫폼 조합에서 네이티브 Google 로그인을 써야 하는지. +/// Google + iOS + client ID 주입됨일 때만 true. 그 외(미설정·Android·다른 provider)는 웹 OAuth. +bool shouldUseNativeGoogle( + SocialProvider provider, + TargetPlatform platform, { + required bool configured, +}) => + provider == SocialProvider.google && + platform == TargetPlatform.iOS && + configured; + +/// 네이티브 Google 자격증명에서 `idToken` 을 받는 얇은 어댑터. +/// 게이트웨이가 이 인터페이스에 의존해 테스트 시 가짜를 주입할 수 있게 한다. +abstract interface class GoogleNativeSignIn { + /// Google 네이티브 시트를 띄우고 Supabase 로 교환할 `idToken` 을 반환한다. + /// 사용자가 취소하면 [SocialSignInCancelled] 를 던진다(오류 아님, UI 는 무시). + Future idToken(); +} + +/// `google_sign_in` v7 기반 실제 구현. +/// +/// client ID 는 컴파일타임 dart-define 로 읽어 `const` 생성자를 유지한다(게이트웨이가 +/// `const GoogleSignInPlugin()` 으로 기본 주입). v7 은 `initialize()` 를 한 번만 부르길 +/// 요구하므로 모듈 전역 플래그로 최초 1회만 초기화한다. +class GoogleSignInPlugin implements GoogleNativeSignIn { + const GoogleSignInPlugin(); + + static const String _iosClientId = String.fromEnvironment( + 'GOOGLE_IOS_CLIENT_ID', + ); + static const String _webClientId = String.fromEnvironment( + 'GOOGLE_WEB_CLIENT_ID', + ); + + /// iOS·Web client ID 가 모두 dart-define 로 주입됐는지. 미설정이면 웹 OAuth 폴백. + static bool get isConfigured => + _iosClientId.isNotEmpty && _webClientId.isNotEmpty; + + @override + Future idToken() async { + await _ensureInitialized(); + try { + final GoogleSignInAccount account = await GoogleSignIn.instance + .authenticate(scopeHint: const ['email']); + final String? token = account.authentication.idToken; + if (token == null) { + throw StateError('Google idToken 이 비어 있어요.'); + } + return token; + } on GoogleSignInException catch (e) { + if (e.code == GoogleSignInExceptionCode.canceled) { + throw const SocialSignInCancelled(SocialProvider.google); + } + rethrow; + } + } + + Future _ensureInitialized() async { + if (_googleInitialized) return; + await GoogleSignIn.instance.initialize( + clientId: _iosClientId, + serverClientId: _webClientId, + ); + _googleInitialized = true; + } +} + +/// `GoogleSignIn.instance.initialize()` 는 앱 수명 동안 1회만 호출해야 한다. +bool _googleInitialized = false; diff --git a/lib/data/auth/supabase_auth_gateway.dart b/lib/data/auth/supabase_auth_gateway.dart index 01ca2b1..656c10b 100644 --- a/lib/data/auth/supabase_auth_gateway.dart +++ b/lib/data/auth/supabase_auth_gateway.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart' import 'package:supabase_flutter/supabase_flutter.dart'; import 'apple_native.dart'; +import 'google_native.dart'; import 'social_auth.dart'; import 'supabase_config.dart'; @@ -60,6 +61,7 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway { SupabaseAuthGatewayImpl({ GoTrueClient? auth, this._appleNative = const SignInWithApplePlugin(), + this._googleNative = const GoogleSignInPlugin(), }) : _injected = auth; final GoTrueClient? _injected; @@ -67,6 +69,9 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway { /// iOS 네이티브 Apple 로그인 어댑터(테스트는 가짜 주입). final AppleNativeSignIn _appleNative; + /// iOS 네이티브 Google 로그인 어댑터(테스트는 가짜 주입). + final GoogleNativeSignIn _googleNative; + /// Supabase 가 초기화되지 않았으면(예: 서버 없이 UI 점검하는 main_dev) null 을 돌려준다. /// 생성자에서 [onSignedIn] 구독 등 읽기 접근이 크래시 나지 않도록 방어한다. GoTrueClient? get _authOrNull { @@ -121,6 +126,15 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway { await _nativeAppleSignIn(); return; } + // iOS + Google 은 client ID 가 주입됐을 때 네이티브 시트(idToken) 경로. 미설정이면 웹 OAuth. + if (shouldUseNativeGoogle( + provider, + defaultTargetPlatform, + configured: GoogleSignInPlugin.isConfigured, + )) { + await _nativeGoogleSignIn(); + return; + } final (OAuthProvider oauth, String? scopes) = _oauthSpec(provider); await _auth.signInWithOAuth( oauth, @@ -144,6 +158,17 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway { ); } + /// 네이티브 Google: 네이티브 시트로 받은 idToken 을 Supabase `signInWithIdToken` 으로 + /// 교환해 세션을 **즉시** 생성한다(accessToken·nonce 불필요 — 앱은 Google API 미호출). + /// 세션 생성은 `onAuthStateChange(signedIn)` 을 발화해 기존 provisioning 흐름을 탄다. + Future _nativeGoogleSignIn() async { + final String idToken = await _googleNative.idToken(); + await _auth.signInWithIdToken( + provider: OAuthProvider.google, + idToken: idToken, + ); + } + @override Future refreshAccessToken() async { try { @@ -181,6 +206,16 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway { await _nativeAppleLink(); return; } + // iOS + Google 도 로그인과 동일하게 네이티브 시트로 연동한다(웹 accounts.google.com + // 외부 Safari 이탈 회피). client ID 미설정이면 웹 linkIdentity 로 폴백. + if (shouldUseNativeGoogle( + provider, + defaultTargetPlatform, + configured: GoogleSignInPlugin.isConfigured, + )) { + await _nativeGoogleLink(); + return; + } final (OAuthProvider oauth, String? scopes) = _oauthSpec(provider); await _auth.linkIdentity( oauth, @@ -204,6 +239,16 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway { ); } + /// 네이티브 Google 연동: 네이티브 시트로 받은 idToken 을 `linkIdentityWithIdToken` 으로 + /// 현재 계정에 즉시 연동한다. 성공 시 `onAuthStateChange(userUpdated)` 를 발화해 UI 갱신. + Future _nativeGoogleLink() async { + final String idToken = await _googleNative.idToken(); + await _auth.linkIdentityWithIdToken( + provider: OAuthProvider.google, + idToken: idToken, + ); + } + @override Future unlinkOAuth(SocialProvider provider) async { final String name = provider.wireName; diff --git a/lib/screens/today/today_screen.dart b/lib/screens/today/today_screen.dart index beea312..fe06b3e 100644 --- a/lib/screens/today/today_screen.dart +++ b/lib/screens/today/today_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../../data/dday.dart'; import '../../data/format.dart'; +import '../../data/meta.dart'; import '../../data/models.dart'; import '../../parts/app_header.dart'; import '../../theme/tokens.dart'; @@ -86,8 +87,8 @@ class _TodayScreenState extends State { .where((DkDebt d) => d.status == DkDebtStatus.overdue) .length; - // 카테고리가 2개 이상일 때만 필터 칩을 노출한다(1개면 필터 의미 없음). - final bool showFilter = categories.length >= 2; + // 등록된(이벤트 있는) 카테고리가 1개 이상이면 전체 + 그 카테고리들을 필터로 노출한다. + final bool showFilter = categories.isNotEmpty; return ListView( padding: const EdgeInsets.only(bottom: 120), @@ -169,6 +170,7 @@ class _CategoryFilter extends StatelessWidget { children: [ DkChoiceChip( label: '전체', + icon: 'list', selected: selected == null, onTap: () => onSelect(null), ), @@ -176,6 +178,8 @@ class _CategoryFilter extends StatelessWidget { const SizedBox(width: 8), DkChoiceChip( label: c, + icon: kCategoryIcon[c] ?? 'more', + iconColor: categoryHue(c).color, selected: selected == c, onTap: () => onSelect(c), ), diff --git a/lib/widgets/dk_choice_chip.dart b/lib/widgets/dk_choice_chip.dart index ec88bdd..3201c72 100644 --- a/lib/widgets/dk_choice_chip.dart +++ b/lib/widgets/dk_choice_chip.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../theme/seed_components.dart'; import '../theme/tokens.dart'; +import 'dk_icon.dart'; /// 선택 칩(on/off). seed [SeedChip] 스펙 단일 소스. /// @@ -16,12 +17,20 @@ class DkChoiceChip extends StatelessWidget { this.onTap, this.fontSize, this.expand = false, + this.icon, + this.iconColor, }); final String label; final bool selected; final VoidCallback? onTap; + /// 선택적 선행 seed 아이콘 이름(예: 'graduationCap'). null 이면 라벨만 표시. + final String? icon; + + /// 아이콘 색 override(카테고리 hue 등으로 구분). null 이면 라벨과 같은 전경색. + final Color? iconColor; + /// 글자 크기 override(기본 [SeedChip.fontSize]). final double? fontSize; @@ -52,16 +61,37 @@ class DkChoiceChip extends StatelessWidget { width: SeedChip.borderWidth, ), ), - child: Text( - label, - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: fontSize ?? SeedChip.fontSize, - fontWeight: FontWeight.values[(SeedChip.weight ~/ 100) - 1], - color: t.byKey(v.fg), - ), - ), + child: _body(t, v), + ), + ); + } + + Widget _body(DkTokens t, SeedButtonVariant v) { + final double size = fontSize ?? SeedChip.fontSize; + final Text text = Text( + label, + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: size, + fontWeight: FontWeight.values[(SeedChip.weight ~/ 100) - 1], + color: t.byKey(v.fg), ), ); + final String? name = icon; + if (name == null) return text; + // [아이콘 라벨] — 아이콘은 iconColor(카테고리 hue)로 구분, 없으면 글자와 같은 전경색. + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + DkIcon( + name, + size: size + 1, + color: iconColor ?? t.byKey(v.fg), + strokeWidth: 2, + ), + const SizedBox(width: 5), + text, + ], + ); } } diff --git a/pubspec.lock b/pubspec.lock index dc09003..8ad5ea7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -304,6 +304,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: dd86f662332689df4954cd6ec4f7f9f87b91ea40e3437ad94f159c1df7a08d8c + url: "https://pub.dev" + source: hosted + version: "7.2.14" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" gotrue: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b87d1a4..b3bcd48 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: package_info_plus: ^8.0.2 sign_in_with_apple: ^8.1.0 crypto: ^3.0.7 + google_sign_in: ^7.2.0 dev_dependencies: flutter_test: diff --git a/test/google_native_test.dart b/test/google_native_test.dart new file mode 100644 index 0000000..1be9734 --- /dev/null +++ b/test/google_native_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart' show TargetPlatform; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ieoseo/data/auth/google_native.dart'; +import 'package:ieoseo/data/auth/social_auth.dart'; + +void main() { + group('shouldUseNativeGoogle', () { + test('is true only for Google on iOS when client IDs are configured', () { + expect( + shouldUseNativeGoogle( + SocialProvider.google, + TargetPlatform.iOS, + configured: true, + ), + isTrue, + ); + }); + + test('is false when client IDs are not configured (falls back to web)', () { + expect( + shouldUseNativeGoogle( + SocialProvider.google, + TargetPlatform.iOS, + configured: false, + ), + isFalse, + ); + }); + + test('is false for Google on non-iOS platforms', () { + expect( + shouldUseNativeGoogle( + SocialProvider.google, + TargetPlatform.android, + configured: true, + ), + isFalse, + ); + }); + + test('is false for non-Google providers even on iOS', () { + expect( + shouldUseNativeGoogle( + SocialProvider.apple, + TargetPlatform.iOS, + configured: true, + ), + isFalse, + ); + expect( + shouldUseNativeGoogle( + SocialProvider.kakao, + TargetPlatform.iOS, + configured: true, + ), + isFalse, + ); + }); + }); + + group('GoogleSignInPlugin.isConfigured', () { + test('is false in tests (no GOOGLE_* dart-define injected)', () { + // 테스트 실행에는 dart-define 이 없으므로 미설정 → 웹 OAuth 폴백이 보장된다. + expect(GoogleSignInPlugin.isConfigured, isFalse); + }); + }); +} diff --git a/test/today_test.dart b/test/today_test.dart index 9254daa..0e4a3d3 100644 --- a/test/today_test.dart +++ b/test/today_test.dart @@ -1,7 +1,10 @@ import 'package:ieoseo/data/format.dart'; +import 'package:ieoseo/data/meta.dart'; import 'package:ieoseo/data/mock_data.dart'; import 'package:ieoseo/data/models.dart'; import 'package:ieoseo/screens/today/today_screen.dart'; +import 'package:ieoseo/widgets/dk_choice_chip.dart'; +import 'package:ieoseo/widgets/dk_icon.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -141,6 +144,83 @@ void main() { expect(find.text('건강'), findsWidgets); }); + testWidgets('카테고리가 1개여도 전체 + 그 카테고리 칩을 보인다', (WidgetTester tester) async { + const DkEvent a = DkEvent( + id: 'a', + type: DkEventType.single, + title: '정처기', + category: '자격증', + date: '2026-12-01', + ); + await _pumpTall(tester, _screen(events: [a])); + + // 등록된(이벤트 있는) 카테고리가 1개여도 필터를 노출한다("다 노출"). + expect(find.text('전체'), findsOneWidget); + expect(find.text('자격증'), findsWidgets); + }); + + testWidgets('필터 칩에 카테고리 아이콘을 함께 보인다', (WidgetTester tester) async { + const DkEvent a = DkEvent( + id: 'a', + type: DkEventType.single, + title: '정처기', + category: '자격증', + date: '2026-12-01', + ); + const DkEvent b = DkEvent( + id: 'b', + type: DkEventType.single, + title: '건강검진', + category: '건강', + date: '2026-12-02', + ); + await _pumpTall(tester, _screen(events: [a, b])); + + // [아이콘 전체] [아이콘 자격증] 형태 — 각 칩이 아이콘을 품는다. + final Finder chips = find.byType(DkChoiceChip); + expect(chips, findsWidgets); + for (final Element e in chips.evaluate()) { + expect( + find.descendant( + of: find.byWidget(e.widget), + matching: find.byType(DkIcon), + ), + findsOneWidget, + ); + } + }); + + testWidgets('필터 칩 아이콘은 카테고리별 hue 색으로 구분된다', (WidgetTester tester) async { + const DkEvent a = DkEvent( + id: 'a', + type: DkEventType.single, + title: '정처기', + category: '자격증', + date: '2026-12-01', + ); + const DkEvent b = DkEvent( + id: 'b', + type: DkEventType.single, + title: '건강검진', + category: '건강', + date: '2026-12-02', + ); + await _pumpTall(tester, _screen(events: [a, b])); + + Color chipIconColor(String label) { + final Finder chip = find.widgetWithText(DkChoiceChip, label); + final DkIcon icon = tester.widget( + find.descendant(of: chip, matching: find.byType(DkIcon)), + ); + return icon.color; + } + + // 각 카테고리 아이콘은 자기 hue 색으로 칠해져 서로 구분된다(가시성). + expect(chipIconColor('자격증'), categoryHue('자격증').color); + expect(chipIconColor('건강'), categoryHue('건강').color); + expect(chipIconColor('자격증'), isNot(chipIconColor('건강'))); + }); + testWidgets('카테고리 필터 탭 시 해당 카테고리만 보인다(#163)', (WidgetTester tester) async { const DkEvent a = DkEvent( id: 'a',