From d83f7054f0a798befe00f032c371356d42a61bc6 Mon Sep 17 00:00:00 2001 From: pkdee <122339395+pkdee@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:29:53 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20iOS=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20Google=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - google_native.dart: shouldUseNativeGoogle + GoogleNativeSignIn 어댑터 (google_sign_in v7, idToken 만 교환 — accessToken·nonce 불필요) - gateway signInWithOAuth/linkOAuth 에 iOS+Google 네이티브 분기 추가 (signInWithIdToken/linkIdentityWithIdToken, 외부 Safari 이탈 제거) - client ID 는 GOOGLE_IOS/WEB_CLIENT_ID dart-define, 미설정 시 웹 OAuth 폴백 - deploy-ios.yml + Fastfile 선택적 주입, Info.plist reversed scheme 자리 - google_native 단위 테스트, docs(ADR-0031·기술스택·가이드) 동기화 Closes #180 --- .github/workflows/deploy-ios.yml | 6 ++ ios/Runner/Info.plist | 23 +++++-- ios/fastlane/Fastfile | 4 ++ lib/data/auth/google_native.dart | 84 ++++++++++++++++++++++++ lib/data/auth/supabase_auth_gateway.dart | 45 +++++++++++++ pubspec.lock | 48 ++++++++++++++ pubspec.yaml | 1 + test/google_native_test.dart | 67 +++++++++++++++++++ 8 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 lib/data/auth/google_native.dart create mode 100644 test/google_native_test.dart 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..5bcdc7e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -78,11 +78,16 @@ CFBundleURLTypes @@ -94,6 +99,14 @@ app.ieoseo + + CFBundleURLName + google-native-signin + CFBundleURLSchemes + + com.googleusercontent.apps.REPLACE_WITH_REVERSED_IOS_CLIENT_ID + + 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/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); + }); + }); +} From 2377c7b8e34a75cc00f9025639b0178a57ff86f5 Mon Sep 17 00:00:00 2001 From: pkdee <122339395+pkdee@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:02:17 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=ED=99=88=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=95=84=ED=84=B0=20=EC=B9=A9=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80=C2=B71=EA=B0=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=EB=85=B8=EC=B6=9C=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DkChoiceChip 에 선택적 icon 파라미터 추가([아이콘 라벨] Row) - 홈 필터 칩: 전체=list, 카테고리=kCategoryIcon 아이콘 표시 - 노출 조건을 카테고리 1개 이상으로 완화(전체 + 등록 카테고리) - 위젯 테스트 2건 추가, docs 화면별-스펙 동기화 Closes #182 --- lib/screens/today/today_screen.dart | 6 ++-- lib/widgets/dk_choice_chip.dart | 39 +++++++++++++++++------ test/today_test.dart | 48 +++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/lib/screens/today/today_screen.dart b/lib/screens/today/today_screen.dart index beea312..8b206ee 100644 --- a/lib/screens/today/today_screen.dart +++ b/lib/screens/today/today_screen.dart @@ -86,8 +86,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 +169,7 @@ class _CategoryFilter extends StatelessWidget { children: [ DkChoiceChip( label: '전체', + icon: 'list', selected: selected == null, onTap: () => onSelect(null), ), @@ -176,6 +177,7 @@ class _CategoryFilter extends StatelessWidget { const SizedBox(width: 8), DkChoiceChip( label: c, + icon: kCategoryIcon[c] ?? 'more', selected: selected == c, onTap: () => onSelect(c), ), diff --git a/lib/widgets/dk_choice_chip.dart b/lib/widgets/dk_choice_chip.dart index ec88bdd..115cb0c 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,16 @@ class DkChoiceChip extends StatelessWidget { this.onTap, this.fontSize, this.expand = false, + this.icon, }); final String label; final bool selected; final VoidCallback? onTap; + /// 선택적 선행 seed 아이콘 이름(예: 'graduationCap'). null 이면 라벨만 표시. + final String? icon; + /// 글자 크기 override(기본 [SeedChip.fontSize]). final double? fontSize; @@ -52,16 +57,32 @@ 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; + // [아이콘 라벨] — 아이콘은 글자와 같은 전경색, 라벨 크기에 맞춰 살짝 작게. + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + DkIcon(name, size: size + 1, color: t.byKey(v.fg), strokeWidth: 2), + const SizedBox(width: 5), + text, + ], + ); } } diff --git a/test/today_test.dart b/test/today_test.dart index 9254daa..b96d493 100644 --- a/test/today_test.dart +++ b/test/today_test.dart @@ -2,6 +2,8 @@ import 'package:ieoseo/data/format.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 +143,52 @@ 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('카테고리 필터 탭 시 해당 카테고리만 보인다(#163)', (WidgetTester tester) async { const DkEvent a = DkEvent( id: 'a', From 7e406a1f4ed4ee5f5794e327eaf38c622dffcb36 Mon Sep 17 00:00:00 2001 From: pkdee <122339395+pkdee@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:25:18 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=ED=99=88=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=95=84=ED=84=B0=20=EC=B9=A9=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20hue=20=EC=83=89=20=EA=B5=AC=EB=B6=84=20(#1?= =?UTF-8?q?85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DkChoiceChip 에 선택적 iconColor 추가(없으면 전경색) - 홈 필터 카테고리 칩 아이콘을 categoryHue 색으로 칠해 구분(가시성) - 전체 칩은 중립 전경색 유지 - 카테고리별 hue 색·상호 구분 위젯 테스트 추가, docs 동기화 Closes #184 --- lib/screens/today/today_screen.dart | 2 ++ lib/widgets/dk_choice_chip.dart | 13 ++++++++++-- test/today_test.dart | 32 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/screens/today/today_screen.dart b/lib/screens/today/today_screen.dart index 8b206ee..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'; @@ -178,6 +179,7 @@ class _CategoryFilter extends StatelessWidget { 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 115cb0c..3201c72 100644 --- a/lib/widgets/dk_choice_chip.dart +++ b/lib/widgets/dk_choice_chip.dart @@ -18,6 +18,7 @@ class DkChoiceChip extends StatelessWidget { this.fontSize, this.expand = false, this.icon, + this.iconColor, }); final String label; @@ -27,6 +28,9 @@ class DkChoiceChip extends StatelessWidget { /// 선택적 선행 seed 아이콘 이름(예: 'graduationCap'). null 이면 라벨만 표시. final String? icon; + /// 아이콘 색 override(카테고리 hue 등으로 구분). null 이면 라벨과 같은 전경색. + final Color? iconColor; + /// 글자 크기 override(기본 [SeedChip.fontSize]). final double? fontSize; @@ -75,11 +79,16 @@ class DkChoiceChip extends StatelessWidget { ); final String? name = icon; if (name == null) return text; - // [아이콘 라벨] — 아이콘은 글자와 같은 전경색, 라벨 크기에 맞춰 살짝 작게. + // [아이콘 라벨] — 아이콘은 iconColor(카테고리 hue)로 구분, 없으면 글자와 같은 전경색. return Row( mainAxisSize: MainAxisSize.min, children: [ - DkIcon(name, size: size + 1, color: t.byKey(v.fg), strokeWidth: 2), + DkIcon( + name, + size: size + 1, + color: iconColor ?? t.byKey(v.fg), + strokeWidth: 2, + ), const SizedBox(width: 5), text, ], diff --git a/test/today_test.dart b/test/today_test.dart index b96d493..0e4a3d3 100644 --- a/test/today_test.dart +++ b/test/today_test.dart @@ -1,4 +1,5 @@ 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'; @@ -189,6 +190,37 @@ void main() { } }); + 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', From 02085c8bfdeb5e14e02642251eaa22f08e81f720 Mon Sep 17 00:00:00 2001 From: pkdee <122339395+pkdee@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:34:51 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20iOS=20Google=20reversed=20client?= =?UTF-8?q?=20ID=20Info.plist=20=EB=B0=98=EC=98=81=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 네이티브 Google 콜백용 iOS URL scheme 자리표시자를 실제 reversed client ID 로 교체 - 값은 공개값(iOS OAuth 는 client secret 없음, 앱 번들에 실림) Closes #186 --- ios/Runner/Info.plist | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5bcdc7e..844967c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -86,8 +86,7 @@ reversed iOS client ID scheme 으로 콜백을 받는다. GOOGLE_IOS_CLIENT_ID/ GOOGLE_WEB_CLIENT_ID dart-define 이 주입될 때만 네이티브가 켜지므로, 값을 채우기 전 자리표시자는 무해하다(그 전까진 웹 OAuth 폴백). Apple 네이티브는 URL scheme 불필요. - ⚠️ 배포 전 REPLACE_WITH_REVERSED_IOS_CLIENT_ID 를 Google Cloud iOS OAuth - 클라이언트의 "reversed client ID"(com.googleusercontent.apps.XXXX)로 교체한다. + 아래 값은 Google Cloud iOS OAuth 클라이언트의 reversed client ID(비밀 아님, 공개값). ─────────────────────────────────────────────────────────────── --> CFBundleURLTypes @@ -104,7 +103,7 @@ google-native-signin CFBundleURLSchemes - com.googleusercontent.apps.REPLACE_WITH_REVERSED_IOS_CLIENT_ID + com.googleusercontent.apps.621764515915-29s6pj2efakomiejh6q10b5na7dk2d1f From d3e52152e258ef767ae81229bf5c775131a0a94d Mon Sep 17 00:00:00 2001 From: pkdee Date: Fri, 3 Jul 2026 16:45:28 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=200.10.3=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google 네이티브 로그인·홈 카테고리 칩 개선 배치 릴리스 - feat 포함이나 요청대로 패치(+0.0.1)로 고정 Release-As: 0.10.3