Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/deploy-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
22 changes: 17 additions & 5 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@
</array>
<!-- ───────────────────────────────────────────────────────────────
Supabase Auth 웹 OAuth 딥링크 복귀 scheme(ADR-0014).
소셜/이메일 로그인은 supabase_flutter 의 signInWithOAuth 가 브라우저로
처리하고, 인증 완료 후 app.ieoseo://login-callback 으로 앱에 복귀한다.
네이티브 SDK(google_sign_in·kakao)나 provider client id 는 쓰지 않는다
(인증은 Supabase web client 가 보유). Android 는 AndroidManifest.xml
의 intent-filter 가 같은 scheme 을 처리한다.
Kakao·(미설정 시)Google 웹 OAuth 는 supabase_flutter 의 signInWithOAuth 가
브라우저로 처리하고, 인증 완료 후 app.ieoseo://login-callback 으로 앱에 복귀한다.
Android 는 AndroidManifest.xml 의 intent-filter 가 같은 scheme 을 처리한다.

iOS 네이티브 Google(google_sign_in, ADR-0028 후속)은 아래 두 번째 URL type 의
reversed iOS client ID scheme 으로 콜백을 받는다. GOOGLE_IOS_CLIENT_ID/
GOOGLE_WEB_CLIENT_ID dart-define 이 주입될 때만 네이티브가 켜지므로, 값을 채우기
전 자리표시자는 무해하다(그 전까진 웹 OAuth 폴백). Apple 네이티브는 URL scheme 불필요.
아래 값은 Google Cloud iOS OAuth 클라이언트의 reversed client ID(비밀 아님, 공개값).
─────────────────────────────────────────────────────────────── -->
<key>CFBundleURLTypes</key>
<array>
Expand All @@ -94,6 +98,14 @@
<string>app.ieoseo</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>google-native-signin</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.621764515915-29s6pj2efakomiejh6q10b5na7dk2d1f</string>
</array>
</dict>
</array>
<!-- App Transport Security: arbitrary load 를 명시적으로 금지(HTTPS 강제, S8).
기본값도 안전하지만 의도를 고정해 향후 http 허용 실수를 막는다. -->
Expand Down
4 changes: 4 additions & 0 deletions ios/fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,15 @@ platform :ios do
# dart-define 값(SUPABASE_*/API_BASE_URL)을 명령줄에 직접 넣으면 fastlane sh() 가
# 확장된 전체 명령을 로그에 echo 하고 다른 프로세스의 /proc/<pid>/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(
Expand Down
84 changes: 84 additions & 0 deletions lib/data/auth/google_native.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> idToken() async {
await _ensureInitialized();
try {
final GoogleSignInAccount account = await GoogleSignIn.instance
.authenticate(scopeHint: const <String>['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<void> _ensureInitialized() async {
if (_googleInitialized) return;
await GoogleSignIn.instance.initialize(
clientId: _iosClientId,
serverClientId: _webClientId,
);
_googleInitialized = true;
}
}

/// `GoogleSignIn.instance.initialize()` 는 앱 수명 동안 1회만 호출해야 한다.
bool _googleInitialized = false;
45 changes: 45 additions & 0 deletions lib/data/auth/supabase_auth_gateway.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -60,13 +61,17 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway {
SupabaseAuthGatewayImpl({
GoTrueClient? auth,
this._appleNative = const SignInWithApplePlugin(),
this._googleNative = const GoogleSignInPlugin(),
}) : _injected = auth;

final GoTrueClient? _injected;

/// iOS 네이티브 Apple 로그인 어댑터(테스트는 가짜 주입).
final AppleNativeSignIn _appleNative;

/// iOS 네이티브 Google 로그인 어댑터(테스트는 가짜 주입).
final GoogleNativeSignIn _googleNative;

/// Supabase 가 초기화되지 않았으면(예: 서버 없이 UI 점검하는 main_dev) null 을 돌려준다.
/// 생성자에서 [onSignedIn] 구독 등 읽기 접근이 크래시 나지 않도록 방어한다.
GoTrueClient? get _authOrNull {
Expand Down Expand Up @@ -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,
Expand All @@ -144,6 +158,17 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway {
);
}

/// 네이티브 Google: 네이티브 시트로 받은 idToken 을 Supabase `signInWithIdToken` 으로
/// 교환해 세션을 **즉시** 생성한다(accessToken·nonce 불필요 — 앱은 Google API 미호출).
/// 세션 생성은 `onAuthStateChange(signedIn)` 을 발화해 기존 provisioning 흐름을 탄다.
Future<void> _nativeGoogleSignIn() async {
final String idToken = await _googleNative.idToken();
await _auth.signInWithIdToken(
provider: OAuthProvider.google,
idToken: idToken,
);
}

@override
Future<String?> refreshAccessToken() async {
try {
Expand Down Expand Up @@ -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,
Expand All @@ -204,6 +239,16 @@ class SupabaseAuthGatewayImpl implements SupabaseAuthGateway {
);
}

/// 네이티브 Google 연동: 네이티브 시트로 받은 idToken 을 `linkIdentityWithIdToken` 으로
/// 현재 계정에 즉시 연동한다. 성공 시 `onAuthStateChange(userUpdated)` 를 발화해 UI 갱신.
Future<void> _nativeGoogleLink() async {
final String idToken = await _googleNative.idToken();
await _auth.linkIdentityWithIdToken(
provider: OAuthProvider.google,
idToken: idToken,
);
}

@override
Future<void> unlinkOAuth(SocialProvider provider) async {
final String name = provider.wireName;
Expand Down
8 changes: 6 additions & 2 deletions lib/screens/today/today_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -86,8 +87,8 @@ class _TodayScreenState extends State<TodayScreen> {
.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),
Expand Down Expand Up @@ -169,13 +170,16 @@ class _CategoryFilter extends StatelessWidget {
children: <Widget>[
DkChoiceChip(
label: '전체',
icon: 'list',
selected: selected == null,
onTap: () => onSelect(null),
),
for (final String c in categories) ...<Widget>[
const SizedBox(width: 8),
DkChoiceChip(
label: c,
icon: kCategoryIcon[c] ?? 'more',
iconColor: categoryHue(c).color,
selected: selected == c,
onTap: () => onSelect(c),
),
Expand Down
48 changes: 39 additions & 9 deletions lib/widgets/dk_choice_chip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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] 스펙 단일 소스.
///
Expand All @@ -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;

Expand Down Expand Up @@ -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: <Widget>[
DkIcon(
name,
size: size + 1,
color: iconColor ?? t.byKey(v.fg),
strokeWidth: 2,
),
const SizedBox(width: 5),
text,
],
);
}
}
Loading
Loading