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