diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 967c781..423fd65 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 @@ -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`) @@ -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: @@ -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 diff --git a/lib/bridge/bridge_setup_script.dart b/lib/bridge/bridge_setup_script.dart new file mode 100644 index 0000000..7a06391 --- /dev/null +++ b/lib/bridge/bridge_setup_script.dart @@ -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); + } + } + })(); + """; +} diff --git a/lib/bridge/handlers/auth_bridge_handler.dart b/lib/bridge/handlers/auth_bridge_handler.dart new file mode 100644 index 0000000..18fe205 --- /dev/null +++ b/lib/bridge/handlers/auth_bridge_handler.dart @@ -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 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(); + } + } + + Future handleAppleLogin(List 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(); + } + } +} diff --git a/lib/bridge/handlers/bridge_handler_base.dart b/lib/bridge/handlers/bridge_handler_base.dart new file mode 100644 index 0000000..b020bd9 --- /dev/null +++ b/lib/bridge/handlers/bridge_handler_base.dart @@ -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: [ + 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: [ + 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, + ); + } +} diff --git a/lib/bridge/handlers/device_bridge_handler.dart b/lib/bridge/handlers/device_bridge_handler.dart new file mode 100644 index 0000000..750a1c5 --- /dev/null +++ b/lib/bridge/handlers/device_bridge_handler.dart @@ -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 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 triggerHaptic(List arguments) async { + if (arguments.isEmpty) { + logger.w('햅틱 피드백 타입이 전달되지 않았습니다.'); + return; + } + + try { + if (arguments[0] is Map) { + final Map hapticArgs = + Map.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 switchEnvironment(List 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))); + } + } +} diff --git a/lib/bridge/handlers/media_bridge_handler.dart b/lib/bridge/handlers/media_bridge_handler.dart new file mode 100644 index 0000000..8b70fdd --- /dev/null +++ b/lib/bridge/handlers/media_bridge_handler.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; + +import 'package:app/bridge/handlers/bridge_handler_base.dart'; +import 'package:app/utils/url_restore_manager.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// 이미지 선택 및 카메라 관련 브릿지 핸들러 +mixin MediaBridgeHandler on BridgeHandlerBase { + Future pickImgFromAlbum() async { + final status = await Permission.photos.status; + + if (status.isPermanentlyDenied) { + logger.d('앨범 권한 영구 거부됨. 설정으로 안내'); + showPermissionDialog( + title: '앨범 접근 권한 안내', + content: '사진을 첨부하려면 접근 권한이 필요해요.\n설정에서 허용해 주시겠어요?', + onConfirm: openAppSettings, + ); + return; + } + + try { + onShowLoading?.call('이미지 처리 중...'); + final ImagePicker picker = ImagePicker(); + final XFile? image = + await picker.pickImage(source: ImageSource.gallery); + + if (image != null) { + final imageBytes = await image.readAsBytes(); + final base64Image = base64Encode(imageBytes); + await controller.evaluateJavascript( + source: "openAlbum('data:image/png;base64,$base64Image')", + ); + } else { + logger.d('이미지 선택 취소'); + } + onHideLoading?.call(); + } catch (e) { + logger.e('Error picking image: $e'); + onHideLoading?.call(); + } + } + + Future pickImgFromCamera() async { + final status = await Permission.camera.status; + + if (status.isPermanentlyDenied) { + logger.d('카메라 권한 영구 거부됨. 설정으로 안내'); + showPermissionDialog( + title: '카메라 접근 권한 안내', + content: '사진을 촬영하려면 접근 권한이 필요해요.\n설정에서 허용해 주시겠어요?', + onConfirm: openAppSettings, + ); + return; + } + + try { + final currentUrl = await controller.getUrl(); + if (currentUrl != null) { + await UrlRestoreManager.saveUrlBeforeCamera(currentUrl.toString()); + logger.d('카메라 호출 전 URL 저장: $currentUrl'); + } + + onShowLoading?.call('사진 촬영 중...'); + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.camera, + maxWidth: 800, + maxHeight: 800, + imageQuality: 80, + ); + + if (image != null) { + final imageBytes = await image.readAsBytes(); + final base64Image = base64Encode(imageBytes); + await controller.evaluateJavascript( + source: "openAlbum('data:image/png;base64,$base64Image')", + ); + } + + await UrlRestoreManager.clearSavedUrl(); + onHideLoading?.call(); + } catch (e) { + logger.e('Error taking image: $e'); + await UrlRestoreManager.clearSavedUrl(); + onHideLoading?.call(); + } + } + + Future pickMultipleImgsFromAlbum() async { + const int maxImages = 5; + + final status = await Permission.photos.status; + + if (status.isPermanentlyDenied) { + logger.d('앨범 권한 영구 거부됨. 설정으로 안내'); + showPermissionDialog( + title: '앨범 접근 권한 안내', + content: '사진을 첨부하려면 접근 권한이 필요해요.\n설정에서 허용해 주시겠어요?', + onConfirm: openAppSettings, + ); + return; + } + + try { + onShowLoading?.call('이미지 선택 중...'); + final ImagePicker picker = ImagePicker(); + final List images = await picker.pickMultiImage( + imageQuality: 80, + maxWidth: 1800, + maxHeight: 1800, + ); + + if (images.isEmpty) { + logger.d('이미지 선택 취소'); + onHideLoading?.call(); + return; + } + + if (images.length > maxImages) { + logger.w('최대 $maxImages장까지만 선택 가능'); + await controller.evaluateJavascript( + source: "alert('최대 $maxImages장까지만 선택할 수 있습니다.')", + ); + onHideLoading?.call(); + return; + } + + List base64Images = []; + onShowLoading?.call('이미지 처리 중... (0/${images.length})'); + + for (var i = 0; i < images.length; i++) { + onShowLoading?.call('이미지 처리 중... (${i + 1}/${images.length})'); + final imageBytes = await images[i].readAsBytes(); + final base64Image = base64Encode(imageBytes); + base64Images.add('data:image/png;base64,$base64Image'); + } + + final jsonImages = jsonEncode(base64Images); + final escapedJson = jsonImages.replaceAll("'", "\\'"); + + onShowLoading?.call('이미지 전송 중...'); + await controller + .evaluateJavascript( + source: "openAlbumMultiple('$escapedJson')", + ) + .timeout( + const Duration(seconds: 30), + onTimeout: () { + logger.e('이미지 전송 타임아웃'); + throw Exception('이미지 전송 시간 초과'); + }, + ); + + logger.d('Successfully sent ${images.length} images to WebView'); + onHideLoading?.call(); + } catch (e) { + logger.e('Error picking multiple images: $e'); + onHideLoading?.call(); + } + } +} diff --git a/lib/bridge/handlers/share_bridge_handler.dart b/lib/bridge/handlers/share_bridge_handler.dart new file mode 100644 index 0000000..c21c217 --- /dev/null +++ b/lib/bridge/handlers/share_bridge_handler.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +import 'package:app/bridge/handlers/bridge_handler_base.dart'; +import 'package:share_plus/share_plus.dart'; + +/// 네이티브 공유 시트 관련 브릿지 핸들러 +mixin ShareBridgeHandler on BridgeHandlerBase { + Future handleShare(List arguments) async { + if (arguments.isEmpty || arguments[0] is! Map) { + logger.w('공유 인자가 올바르지 않습니다: $arguments'); + return; + } + + try { + final Map shareArgs = + Map.from(arguments[0]); + + final String title = shareArgs['title'] ?? ''; + final String description = shareArgs['description'] ?? ''; + final String linkUrl = shareArgs['linkUrl'] ?? ''; + + final StringBuffer shareText = StringBuffer(); + if (title.isNotEmpty) { + shareText.writeln(title); + } + if (description.isNotEmpty) { + shareText.writeln(description); + } + if (linkUrl.isNotEmpty) { + shareText.write(linkUrl); + } + + final String textToShare = shareText.toString().trim(); + + if (textToShare.isEmpty) { + logger.w('공유할 내용이 없습니다.'); + return; + } + + logger.d('공유 시작: $textToShare'); + + final result = await Share.share(textToShare); + + logger.d('공유 결과: ${result.status}'); + + if (result.status == ShareResultStatus.success) { + await controller.evaluateJavascript( + source: "onShareSuccess && onShareSuccess()", + ); + } else if (result.status == ShareResultStatus.dismissed) { + await controller.evaluateJavascript( + source: "onShareDismissed && onShareDismissed()", + ); + } + } catch (e) { + logger.e('공유 중 오류 발생: $e'); + final escapedError = jsonEncode(e.toString()); + await controller.evaluateJavascript( + source: "onShareError && onShareError($escapedError)", + ); + } + } +} diff --git a/lib/bridge/web_view_bridge_handler.dart b/lib/bridge/web_view_bridge_handler.dart index 487aef8..d6b5b2b 100644 --- a/lib/bridge/web_view_bridge_handler.dart +++ b/lib/bridge/web_view_bridge_handler.dart @@ -1,63 +1,34 @@ -import 'dart:convert'; import 'dart:io'; -import 'package:app/bridge/social_login_handler.dart'; -import 'package:app/main.dart'; -import 'package:app/permissions/FirebaseConfig.dart'; -import 'package:app/utils/env/env.dart'; -import 'package:flutter/cupertino.dart'; + +import 'package:app/bridge/bridge_setup_script.dart'; +import 'package:app/bridge/handlers/bridge_handler_base.dart'; +import 'package:app/bridge/handlers/auth_bridge_handler.dart'; +import 'package:app/bridge/handlers/device_bridge_handler.dart'; +import 'package:app/bridge/handlers/media_bridge_handler.dart'; +import 'package:app/bridge/handlers/share_bridge_handler.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:logger/logger.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:app/utils/url_restore_manager.dart'; +import 'package:logger/logger.dart'; -class WebViewBridgeHandler { +class WebViewBridgeHandler extends BridgeHandlerBase + with + MediaBridgeHandler, + AuthBridgeHandler, + ShareBridgeHandler, + DeviceBridgeHandler { + @override final BuildContext context; + @override final InAppWebViewController controller; + @override final Logger logger; - final MethodChannel platform = - const MethodChannel('com.bottlenote.official.app/intents'); + @override final Function(String)? onShowLoading; + @override final VoidCallback? onHideLoading; - bool _handlersRegistered = false; - - static const String bridgeSetupScript = """ - if (typeof window.FlutterMessageQueue === 'undefined') { - window.FlutterMessageQueue = { - postMessage: function(message, ...args) { - window.flutter_inappwebview.callHandler('FlutterMessageQueue', message, ...args); - } - }; - } - - if (typeof window.LogToFlutter === 'undefined') { - window.LogToFlutter = { - postMessage: function(message) { - window.flutter_inappwebview.callHandler('LogToFlutter', message); - } - }; - } - - if (typeof window.isInApp === 'undefined') { - window.isInApp = true; - } - console.log('[Flutter Bridge] FlutterMessageQueue initialized:', window.FlutterMessageQueue); - console.log('[Flutter Bridge] LogToFlutter initialized:', window.LogToFlutter); - - 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); - } - } - """; + bool _handlersRegistered = false; WebViewBridgeHandler({ required this.controller, @@ -69,6 +40,7 @@ class WebViewBridgeHandler { Future setupJavaScriptChannels() async { if (!_handlersRegistered) { + // Legacy FlutterMessageQueue (구버전 웹 호환) controller.addJavaScriptHandler( handlerName: 'FlutterMessageQueue', callback: (args) { @@ -79,7 +51,8 @@ class WebViewBridgeHandler { final message = args[0].toString(); final arguments = args.length > 1 ? args.sublist(1) : []; - logger.d('Received message from JS: $message, arguments: $arguments'); + logger.d( + 'Received message from JS: $message, arguments: $arguments'); _handleFlutterMessageQueue(message, arguments); }, ); @@ -93,6 +66,9 @@ class WebViewBridgeHandler { }, ); + // 개별 채널 핸들러 등록 + _registerIndividualChannels(); + _handlersRegistered = true; } @@ -101,402 +77,114 @@ class WebViewBridgeHandler { /// 웹 페이지에 JavaScript 채널 객체를 정의하는 메서드 Future setupWebViewJavaScriptChannels() async { - await controller.evaluateJavascript(source: bridgeSetupScript); + await controller.evaluateJavascript(source: BridgeSetupScript.script); + } + + /// 개별 채널 핸들러를 등록하는 헬퍼 + void _registerChannel( + String name, + Future Function(List) handler, + ) { + controller.addJavaScriptHandler( + handlerName: name, + callback: (args) async { + try { + logger.d('Channel $name called (args count: ${args.length})'); + final result = await handler(args); + if (result is Map) return result; + return {'success': true}; + } catch (e) { + logger.e('Error in channel $name: $e'); + return {'error': e.toString()}; + } + }, + ); + } + + /// 각 기능별 개별 채널 핸들러 등록 + void _registerIndividualChannels() { + _registerChannel('AppBridge.deviceToken', (_) => sendDeviceToken()); + + _registerChannel('AppBridge.platform', (_) async { + final platform = Platform.operatingSystem; + await controller.evaluateJavascript( + source: "checkPlatform('$platform')", + ); + return {'platform': platform}; + }); + + _registerChannel('AppBridge.album', (_) => pickImgFromAlbum()); + + _registerChannel( + 'AppBridge.albumMultiple', (_) => pickMultipleImgsFromAlbum()); + + _registerChannel('AppBridge.camera', (_) => pickImgFromCamera()); + + _registerChannel('AppBridge.kakaoLogin', (_) => handleKakaoLogin()); + + _registerChannel( + 'AppBridge.appleLogin', (args) => handleAppleLogin(args)); + + _registerChannel('AppBridge.haptic', (args) async { + await triggerHaptic(args); + }); + + _registerChannel('AppBridge.env', (args) async { + await switchEnvironment(args); + }); + + _registerChannel('AppBridge.share', (args) => handleShare(args)); } - void _handleFlutterMessageQueue(String message, List arguments) { + /// Legacy FlutterMessageQueue 메시지 라우팅 (구버전 웹 호환) + Future _handleFlutterMessageQueue( + String message, List arguments) async { switch (message) { case 'deviceToken': - _sendDeviceToken(); + await sendDeviceToken(); break; case 'checkPlatform': logger.d('checkPlatform called'); logger.d('Platform: ${Platform.operatingSystem}'); - controller.evaluateJavascript( + await controller.evaluateJavascript( source: "checkPlatform('${Platform.operatingSystem}')", ); break; case 'openAlbum': logger.d('openAlbum called with arguments: $arguments'); - _pickImgFromAlbum(); + await pickImgFromAlbum(); break; case 'openAlbumMultiple': logger.d('openAlbumMultiple called with arguments: $arguments'); - _pickMultipleImgsFromAlbum(); + await pickMultipleImgsFromAlbum(); break; case 'openCamera': logger.d('openCamera called with arguments: $arguments'); - _pickImgFromCamera(); + await pickImgFromCamera(); break; case 'loginWithKakao': logger.d('loginWithKakao called with arguments: $arguments'); - _handleKakaoLogin(); + await handleKakaoLogin(); break; case 'loginWithApple': logger.d('loginWithApple called with arguments: $arguments'); - _handleAppleLogin(arguments); + await handleAppleLogin(arguments); break; case 'triggerHaptic': logger.d('triggerHaptic called with arguments: $arguments'); - _triggerHaptic(arguments); + await triggerHaptic(arguments); break; case 'switchEnv': logger.d('switchEnvironment called with arguments: $arguments'); - _switchEnvironment(arguments); + await switchEnvironment(arguments); + break; + case 'share': + logger.d('share called with arguments: $arguments'); + await handleShare(arguments); break; default: logger .w("Unknown message received: $message with arguments: $arguments"); } } - - Future _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 _pickImgFromAlbum() async { - // 권한 상태만 체크 (요청은 image_picker가 자체적으로 처리) - final status = await Permission.photos.status; - - // 영구 거부된 경우에만 설정으로 안내 - if (status.isPermanentlyDenied) { - logger.d('앨범 권한 영구 거부됨. 설정으로 안내'); - _showPermissionDialog( - title: '앨범 접근 권한 안내', - content: '사진을 첨부하려면 접근 권한이 필요해요.\n설정에서 허용해 주시겠어요?', - onConfirm: openAppSettings, - ); - return; - } - - try { - onShowLoading?.call('이미지 처리 중...'); - final ImagePicker picker = ImagePicker(); - final XFile? image = await picker.pickImage(source: ImageSource.gallery); - - if (image != null) { - final imageBytes = await image.readAsBytes(); - final base64Image = base64Encode(imageBytes); - await controller.evaluateJavascript( - source: "openAlbum('data:image/png;base64,$base64Image')", - ); - } else { - logger.d('이미지 선택 취소'); - } - onHideLoading?.call(); - } catch (e) { - logger.e('Error picking image: $e'); - onHideLoading?.call(); - } - } - - Future _pickImgFromCamera() async { - // 권한 상태만 체크 (요청은 image_picker가 자체적으로 처리) - final status = await Permission.camera.status; - - // 영구 거부된 경우에만 설정으로 안내 - if (status.isPermanentlyDenied) { - logger.d('카메라 권한 영구 거부됨. 설정으로 안내'); - _showPermissionDialog( - title: '카메라 접근 권한 안내', - content: '사진을 촬영하려면 접근 권한이 필요해요.\n설정에서 허용해 주시겠어요?', - onConfirm: openAppSettings, - ); - return; - } - - try { - // Android에서 카메라 호출 전 현재 URL 저장 (앱이 kill될 경우 복원용) - final currentUrl = await controller.getUrl(); - if (currentUrl != null) { - await UrlRestoreManager.saveUrlBeforeCamera(currentUrl.toString()); - logger.d('카메라 호출 전 URL 저장: $currentUrl'); - } - - onShowLoading?.call('사진 촬영 중...'); - final ImagePicker picker = ImagePicker(); - final XFile? image = await picker.pickImage( - source: ImageSource.camera, - maxWidth: 800, - maxHeight: 800, - imageQuality: 80, - ); - - if (image != null) { - final imageBytes = await image.readAsBytes(); - final base64Image = base64Encode(imageBytes); - await controller.evaluateJavascript( - source: "openAlbum('data:image/png;base64,$base64Image')", - ); - } - - // 정상 복귀 시 저장된 URL 삭제 (앱이 kill되지 않았으므로) - await UrlRestoreManager.clearSavedUrl(); - onHideLoading?.call(); - } catch (e) { - logger.e('Error taking image: $e'); - // 에러 시에도 저장된 URL 삭제 - await UrlRestoreManager.clearSavedUrl(); - onHideLoading?.call(); - } - } - - Future _pickMultipleImgsFromAlbum() async { - const int maxImages = 5; - - // 권한 상태만 체크 (요청은 image_picker가 자체적으로 처리) - final status = await Permission.photos.status; - - // 영구 거부된 경우에만 설정으로 안내 - if (status.isPermanentlyDenied) { - logger.d('앨범 권한 영구 거부됨. 설정으로 안내'); - _showPermissionDialog( - title: '앨범 접근 권한 안내', - content: '사진을 첨부하려면 접근 권한이 필요해요.\n설정에서 허용해 주시겠어요?', - onConfirm: openAppSettings, - ); - return; - } - - try { - onShowLoading?.call('이미지 선택 중...'); - final ImagePicker picker = ImagePicker(); - final List images = await picker.pickMultiImage( - imageQuality: 80, - maxWidth: 1800, - maxHeight: 1800, - ); - - if (images.isEmpty) { - logger.d('이미지 선택 취소'); - onHideLoading?.call(); - return; - } - - // 최대 5장 제한 - if (images.length > maxImages) { - logger.w('최대 $maxImages장까지만 선택 가능'); - await controller.evaluateJavascript( - source: "alert('최대 $maxImages장까지만 선택할 수 있습니다.')", - ); - onHideLoading?.call(); - return; - } - - // 여러 이미지를 base64로 변환 - List base64Images = []; - onShowLoading?.call('이미지 처리 중... (0/${images.length})'); - - for (var i = 0; i < images.length; i++) { - onShowLoading?.call('이미지 처리 중... (${i + 1}/${images.length})'); - final imageBytes = await images[i].readAsBytes(); - final base64Image = base64Encode(imageBytes); - base64Images.add('data:image/png;base64,$base64Image'); - } - - // JSON 배열로 변환하여 WebView로 전송 - final jsonImages = jsonEncode(base64Images); - final escapedJson = jsonImages.replaceAll("'", "\\'"); - - onShowLoading?.call('이미지 전송 중...'); - await controller.evaluateJavascript( - source: "openAlbumMultiple('$escapedJson')", - ).timeout( - const Duration(seconds: 30), - onTimeout: () { - logger.e('이미지 전송 타임아웃'); - throw Exception('이미지 전송 시간 초과'); - }, - ); - - logger.d('Successfully sent ${images.length} images to WebView'); - onHideLoading?.call(); - } catch (e) { - logger.e('Error picking multiple images: $e'); - onHideLoading?.call(); - } - } - - Future _handleKakaoLogin() async { - try { - onShowLoading?.call('카카오 로그인 중...'); - KakaoLoginResult? kakaoLoginResult = await loginWithKakao(); - - if (kakaoLoginResult == null) { - onHideLoading?.call(); - return; - } - - await controller.evaluateJavascript( - source: "onKakaoLoginSuccess('${kakaoLoginResult.accessToken}')", - ); - onHideLoading?.call(); - } catch (error) { - await controller.evaluateJavascript( - source: "onKakaoLoginError('$error')", - ); - onHideLoading?.call(); - } - } - - Future _handleAppleLogin(List arguments) async { - try { - onShowLoading?.call('애플 로그인 중...'); - - String nonce = arguments[0]['nonce']; - AppleLoginResult? appleLoginResult = await loginWithApple(nonce); - - if (appleLoginResult == null) { - onHideLoading?.call(); - return; - } - - final json = jsonEncode({ - 'idToken': appleLoginResult.idToken, - 'nonce': nonce, - }); - - logger.d('appleLoginResult: ${appleLoginResult.idToken}, $nonce'); - - await controller.evaluateJavascript( - source: "onAppleLoginSuccess('$json')", - ); - onHideLoading?.call(); - } catch (error) { - await controller.evaluateJavascript( - source: "onAppleLoginError('$error')", - ); - onHideLoading?.call(); - } - } - - // 웹뷰 -> flutter {type: 'light'} 와 같은 형식으로 전달됨 - void _triggerHaptic(List arguments) async { - if (arguments.isEmpty) { - logger.w('햅틱 피드백 타입이 전달되지 않았습니다.'); - return; - } - - try { - if (arguments[0] is Map) { - final Map hapticArgs = - Map.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'); - } - } - - void _showPermissionDialog({ - required String title, - required String content, - required VoidCallback onConfirm, - }) { - final context = navigatorKey.currentContext; - if (context == null) return; - - Widget dialog = Platform.isIOS - ? CupertinoAlertDialog( - title: Text(title), - content: Text(content), - actions: [ - CupertinoDialogAction( - child: const Text('취소'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - CupertinoDialogAction( - child: const Text('설정으로 이동'), - onPressed: () { - onConfirm(); - Navigator.of(context).pop(); - }, - ), - ], - ) - : AlertDialog( - title: Text(title), - content: Text(content), - actions: [ - TextButton( - child: const Text('취소'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - TextButton( - child: const Text('설정으로 이동'), - onPressed: () { - onConfirm(); - Navigator.of(context).pop(); - }, - ), - ], - ); - - showDialog( - context: context, - builder: (BuildContext context) => dialog, - ); - } - - void _switchEnvironment(List 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))); - } - } } diff --git a/lib/web_view/web_view.dart b/lib/web_view/web_view.dart index 4a2561d..30af8b2 100644 --- a/lib/web_view/web_view.dart +++ b/lib/web_view/web_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; +import 'package:app/bridge/bridge_setup_script.dart'; import 'package:app/bridge/web_view_bridge_handler.dart'; import 'package:app/utils/network/connectivity_service.dart'; import 'package:app/ui/offline_screen.dart'; @@ -66,7 +67,7 @@ class BottleNoteWebViewState extends State _setupPullToRefresh(); _initialUserScripts = UnmodifiableListView([ UserScript( - source: WebViewBridgeHandler.bridgeSetupScript, + source: BridgeSetupScript.script, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, ), ]); diff --git a/pubspec.lock b/pubspec.lock index 2fc179f..1b4b555 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -896,6 +896,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + url: "https://pub.dev" + source: hosted + version: "2.2.19" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -1040,6 +1064,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: "direct main" description: @@ -1309,6 +1349,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_graphics: dependency: transitive description: @@ -1413,6 +1461,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.17.0" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" xdg_directories: dependency: transitive description: @@ -1438,5 +1494,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index ac13787..00e1a95 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: svg_path_parser: ^1.1.2 flutter_native_splash: ^2.4.6 shared_preferences: ^2.5.2 + share_plus: ^10.1.4 dev_dependencies: flutter_test: