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',