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
13 changes: 13 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,14 @@ PODS:
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- OrderedSet (6.0.3)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
Expand All @@ -106,7 +111,9 @@ DEPENDENCIES:
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- kakao_flutter_sdk_common (from `.symlinks/plugins/kakao_flutter_sdk_common/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
Expand Down Expand Up @@ -144,8 +151,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_picker_ios/ios"
kakao_flutter_sdk_common:
:path: ".symlinks/plugins/kakao_flutter_sdk_common/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sign_in_with_apple:
Expand Down Expand Up @@ -174,8 +185,10 @@ SPEC CHECKSUMS:
kakao_flutter_sdk_common: 600d55b532da0bd37268a529e1add49302477710
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
Expand Down
62 changes: 62 additions & 0 deletions lib/bridge/bridge_setup_script.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/// WebView에 주입되는 JavaScript 브릿지 스크립트
class BridgeSetupScript {
BridgeSetupScript._();

static const String script = """
(function() {
window.AppBridge = window.AppBridge || {};

function _setupChannel(name) {
window.AppBridge[name] = {
postMessage: function(...args) {
return window.flutter_inappwebview.callHandler('AppBridge.' + name, ...args);
}
};
}

// Legacy FlutterMessageQueue (구버전 웹 호환)
if (typeof window.FlutterMessageQueue === 'undefined') {
window.FlutterMessageQueue = {
postMessage: function(message, ...args) {
return window.flutter_inappwebview.callHandler('FlutterMessageQueue', message, ...args);
}
};
}

// 개별 채널 인터페이스
_setupChannel('deviceToken');
_setupChannel('platform');
_setupChannel('album');
_setupChannel('albumMultiple');
_setupChannel('camera');
_setupChannel('kakaoLogin');
_setupChannel('appleLogin');
_setupChannel('haptic');
_setupChannel('env');
_setupChannel('share');

// 로깅 채널
if (typeof window.LogToFlutter === 'undefined') {
window.LogToFlutter = {
postMessage: function(message) {
window.flutter_inappwebview.callHandler('LogToFlutter', message);
}
};
}

window.isInApp = true;

console.log('[Flutter Bridge] AppBridge channels:', Object.keys(window.AppBridge));

if (window.LogToFlutter && typeof window.LogToFlutter.postMessage === 'function') {
try {
window.addEventListener('error', function(event) {
window.LogToFlutter.postMessage('[WindowError] ' + event.message + ' at ' + event.filename + ':' + event.lineno);
});
} catch (bridgeLoggingError) {
console.error('[Flutter Bridge] Failed to wire logging listeners', bridgeLoggingError);
}
}
})();
""";
}
62 changes: 62 additions & 0 deletions lib/bridge/handlers/auth_bridge_handler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'dart:convert';

import 'package:app/bridge/handlers/bridge_handler_base.dart';
import 'package:app/bridge/social_login_handler.dart';

/// 소셜 로그인 관련 브릿지 핸들러
mixin AuthBridgeHandler on BridgeHandlerBase {
Future<void> handleKakaoLogin() async {
try {
onShowLoading?.call('카카오 로그인 중...');
KakaoLoginResult? kakaoLoginResult = await loginWithKakao();

if (kakaoLoginResult == null) {
return;
}

await controller.evaluateJavascript(
source: "onKakaoLoginSuccess('${kakaoLoginResult.accessToken}')",
);
} catch (error) {
try {
await controller.evaluateJavascript(
source: "onKakaoLoginError('$error')",
);
} catch (_) {}
} finally {
onHideLoading?.call();
}
Comment on lines +17 to +28

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] onHideLoadingevaluateJavascript 성공을 전제로 호출되고 있어, JS 실행이 실패하면 로딩이 영구히 남을 수 있습니다. try/finallyonHideLoading?.call()을 보장하고, 에러 시 JS 호출도 실패할 수 있으니 그 부분도 별도 보호 로직이 필요합니다.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 0bc0dba

변경 내용: handleKakaoLogin/handleAppleLogin에서 onHideLoadingfinally 블록으로 이동하여 로딩 해제를 보장합니다. JS 에러 콜백도 별도 try-catch로 보호했습니다.

}

Future<void> handleAppleLogin(List<dynamic> arguments) async {
try {
onShowLoading?.call('애플 로그인 중...');

String nonce = arguments[0]['nonce'];
AppleLoginResult? appleLoginResult = await loginWithApple(nonce);

if (appleLoginResult == null) {
return;
}

final json = jsonEncode({
'idToken': appleLoginResult.idToken,
'nonce': nonce,
});

logger.d('appleLoginResult: ${appleLoginResult.idToken}, $nonce');

await controller.evaluateJavascript(
source: "onAppleLoginSuccess('$json')",
);
} catch (error) {
try {
await controller.evaluateJavascript(
source: "onAppleLoginError('$error')",
);
} catch (_) {}
} finally {
onHideLoading?.call();
}
}
}
71 changes: 71 additions & 0 deletions lib/bridge/handlers/bridge_handler_base.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'dart:io';

import 'package:app/main.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:logger/logger.dart';

/// 브릿지 핸들러 공통 의존성 및 유틸리티
abstract class BridgeHandlerBase {
InAppWebViewController get controller;
Logger get logger;
Function(String)? get onShowLoading;
VoidCallback? get onHideLoading;
BuildContext get context;

/// 권한 요청 다이얼로그 (iOS: Cupertino, Android: Material)
void showPermissionDialog({
required String title,
required String content,
required VoidCallback onConfirm,
}) {
final ctx = navigatorKey.currentContext;
if (ctx == null) return;

Widget dialog = Platform.isIOS
? CupertinoAlertDialog(
title: Text(title),
content: Text(content),
actions: <Widget>[
CupertinoDialogAction(
child: const Text('취소'),
onPressed: () {
Navigator.of(ctx).pop();
},
),
CupertinoDialogAction(
child: const Text('설정으로 이동'),
onPressed: () {
onConfirm();
Navigator.of(ctx).pop();
},
),
],
)
: AlertDialog(
title: Text(title),
content: Text(content),
actions: <Widget>[
TextButton(
child: const Text('취소'),
onPressed: () {
Navigator.of(ctx).pop();
},
),
TextButton(
child: const Text('설정으로 이동'),
onPressed: () {
onConfirm();
Navigator.of(ctx).pop();
},
),
],
);

showDialog(
context: ctx,
builder: (BuildContext context) => dialog,
);
}
}
89 changes: 89 additions & 0 deletions lib/bridge/handlers/device_bridge_handler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import 'dart:io';

import 'package:app/bridge/handlers/bridge_handler_base.dart';
import 'package:app/permissions/FirebaseConfig.dart';
import 'package:app/utils/env/env.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';

/// 디바이스 토큰, 햅틱, 환경 전환 관련 브릿지 핸들러
mixin DeviceBridgeHandler on BridgeHandlerBase {
Future<void> sendDeviceToken() async {
try {
String? token = await getDeviceToken();

if (token == null) {
logger.w("Device token is null");
return;
}

String platformType = Platform.isIOS ? "IOS" : "ANDROID";
await controller.evaluateJavascript(
source: "getDeviceToken('$token', '$platformType')",
);
} catch (e) {
logger.e("Error getting device token: $e");
}
}

Future<void> triggerHaptic(List<dynamic> arguments) async {
if (arguments.isEmpty) {
logger.w('햅틱 피드백 타입이 전달되지 않았습니다.');
return;
}

try {
if (arguments[0] is Map) {
final Map<String, dynamic> hapticArgs =
Map<String, dynamic>.from(arguments[0]);
final hapticType = (hapticArgs['type'] as String?)?.toLowerCase();

if (hapticType == null) {
logger.w('Haptic feedback type not specified in arguments');
return;
}

switch (hapticType) {
case 'light':
await HapticFeedback.lightImpact();
break;
case 'medium':
await HapticFeedback.mediumImpact();
break;
case 'heavy':
await HapticFeedback.heavyImpact();
break;
case 'selection':
await HapticFeedback.selectionClick();
break;
case 'vibrate':
await HapticFeedback.vibrate();
break;
default:
logger.w('Unknown haptic feedback type: $hapticType');
}
} else {
logger.w(
'Haptic feedback arguments should be an object with type property');
}
} catch (e) {
logger.e('Error triggering haptic feedback: $e');
}
}

Future<void> switchEnvironment(List<dynamic> arguments) async {
logger.d('switchEnvironment called with arguments: $arguments');
if (arguments.isNotEmpty && arguments[0] is String) {
final env = arguments[0] as String;
String url;
if (env == 'prod') {
url = 'https://bottle-note.com/';
} else if (env == 'dev') {
url = 'https://development.bottle-note.com/';
} else {
url = Env.webViewUrl;
}
await controller.loadUrl(urlRequest: URLRequest(url: WebUri(url)));
}
}
}
Loading