diff --git a/ios/DDang/Info.plist b/ios/DDang/Info.plist index 7b1a018..167e5f1 100644 --- a/ios/DDang/Info.plist +++ b/ios/DDang/Info.plist @@ -24,10 +24,14 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS + NMFClientId + j6vmx4vlbe NSAppTransportSecurity NSAllowsArbitraryLoads + NSAllowsLocalNetworking + NSExceptionDomains ddang.site @@ -38,13 +42,17 @@ - NSAllowsLocalNetworking - + NSAppleMusicUsageDescription + 사진 등록시 사진 선택을 위해 라이브러리 접근 권한이 필요합니다. + NSCameraUsageDescription + 카메라 촬영시 카메라 접근 권한이 필요합니다. NSLocationAlwaysAndWhenInUseUsageDescription 내 위치 정보 표시와 산책 기능 제공을 위해 권한 허용이 필요합니다. 설정에서 언제든 변경이 가능합니다. NSLocationAlwaysUsageDescription 내 위치 정보 표시와 산책 기능 제공을 위해 권한 허용이 필요합니다. 설정에서 언제든 변경이 가능합니다. + NSLocationTemporaryUsageDescriptionDictionary + 네이버 맵에서 내 위치를 표시하기 위해 권한을 요청합니다. NSLocationWhenInUseUsageDescription 내 위치 정보 표시와 산책 기능 제공을 위해 권한 허용이 필요합니다. 설정에서 언제든 변경이 가능합니다. NSMotionUsageDescription @@ -53,27 +61,8 @@ 사진 등록시 사진 선택을 위해 라이브러리 접근 권한이 필요합니다. NSPhotoLibraryUsageDescription 사진 등록시 사진 선택을 위해 라이브러리 접근 권한이 필요합니다. - NSAppleMusicUsageDescription - 사진 등록시 사진 선택을 위해 라이브러리 접근 권한이 필요합니다. - NSCameraUsageDescription - 카메라 촬영시 카메라 접근 권한이 필요합니다. NSUserNotificationUsageDescription 앱에서 알림을 보내기 위해 권한이 필요합니다. - - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - arm64 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - UIAppFonts AntDesign.ttf @@ -105,13 +94,25 @@ SUIT-SemiBold.ttf SUIT-Thin.ttf - NMFClientId - j6vmx4vlbe - NSLocationAlwaysAndWhenInUseUsageDescription - 내 위치 정보 표시와 산책 기능 제공을 위해 권한 허용이 필요합니다. 설정에서 언제든 변경이 가능합니다. - NSLocationTemporaryUsageDescriptionDictionary - 내 위치 정보 표시와 산책 기능 제공을 위해 권한 허용이 필요합니다. 설정에서 언제든 변경이 가능합니다. - NSLocationWhenInUseUsageDescription - 내 위치 정보 표시와 산책 기능 제공을 위해 권한 허용이 필요합니다. 설정에서 언제든 변경이 가능합니다. + UIBackgroundModes + + fetch + processing + location + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b476ef7..129c7c0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1269,6 +1269,27 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-cameraroll (7.9.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-config (1.5.5): - react-native-config/App (= 1.5.5) - react-native-config/App (1.5.5): @@ -1430,6 +1451,27 @@ PODS: - Yoga - react-native-splash-screen (3.3.0): - React-Core + - react-native-view-shot (4.0.3): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-webview (13.13.2): - DoubleConversion - glog @@ -2051,6 +2093,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)" - react-native-config (from `../node_modules/react-native-config`) - react-native-date-picker (from `../node_modules/react-native-date-picker`) - react-native-encrypted-storage (from `../node_modules/react-native-encrypted-storage`) @@ -2059,6 +2102,7 @@ DEPENDENCIES: - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - "react-native-slider (from `../node_modules/@react-native-community/slider`)" - react-native-splash-screen (from `../node_modules/react-native-splash-screen`) + - react-native-view-shot (from `../node_modules/react-native-view-shot`) - react-native-webview (from `../node_modules/react-native-webview`) - React-nativeconfig (from `../node_modules/react-native/ReactCommon`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -2181,6 +2225,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-cameraroll: + :path: "../node_modules/@react-native-camera-roll/camera-roll" react-native-config: :path: "../node_modules/react-native-config" react-native-date-picker: @@ -2197,6 +2243,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-community/slider" react-native-splash-screen: :path: "../node_modules/react-native-splash-screen" + react-native-view-shot: + :path: "../node_modules/react-native-view-shot" react-native-webview: :path: "../node_modules/react-native-webview" React-nativeconfig: @@ -2314,6 +2362,7 @@ SPEC CHECKSUMS: React-logger: ae95f0effa7e1791bd6f7283caddca323d4fbc1e React-Mapbuffer: 7eb5d69e1154e7743487ef0c8d7261e5b59afb32 React-microtasksnativemodule: 01dd998649ff5f8814846b7eee84c4d57f5d3671 + react-native-cameraroll: 31e39d303319ba20657808f33104afa9d52d83aa react-native-config: 644074ab88db883fcfaa584f03520ec29589d7df react-native-date-picker: 26cdb1a94ec72dbc9210c3379e57ff6ba8bc73f2 react-native-encrypted-storage: 569d114e329b1c2c2d9f8c84bcdbe4478dda2258 @@ -2322,6 +2371,7 @@ SPEC CHECKSUMS: react-native-safe-area-context: 5e53e2b0fc3a2994ad0c89a2486e545b6566d8c4 react-native-slider: d1a9121980fc81678c6d30b82f312c778fba563c react-native-splash-screen: 95994222cc95c236bd3cdc59fe45ed5f27969594 + react-native-view-shot: 60117a0ac87a504a39ee9f079e632011916e7724 react-native-webview: dd55f7d3b8c0dcdf8835a44ffc5bd03303dffb30 React-nativeconfig: f7ab6c152e780b99a8c17448f2d99cf5f69a2311 React-NativeModulesApple: 9aeb901b9bfcc9235e912445fb3cf4780a99baf4 diff --git a/package-lock.json b/package-lock.json index 0a6d585..5ee84b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,16 @@ "@emotion/react": "^11.14.0", "@mj-studio/react-native-naver-map": "^2.2.0", "@react-native-async-storage/async-storage": "^2.1.1", + "@react-native-camera-roll/camera-roll": "^7.9.0", "@react-native-community/geolocation": "^3.4.0", "@react-native-community/netinfo": "^11.4.1", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", + "@react-navigation/stack": "^7.1.1", "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.63.0", + "axios": "^1.7.9", "d3": "^7.9.0", "fast-text-encoding": "^1.0.6", "jotai": "^2.11.1", @@ -44,6 +47,7 @@ "react-native-toast-message": "^2.2.1", "react-native-url-polyfill": "^2.0.0", "react-native-vector-icons": "^10.2.0", + "react-native-view-shot": "^4.0.3", "react-native-webview": "^13.13.2", "sockjs-client": "^1.6.1" }, @@ -2187,7 +2191,6 @@ "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", - "dev": true, "dependencies": { "@types/hammerjs": "^2.0.36" }, @@ -3676,6 +3679,18 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-camera-roll/camera-roll": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@react-native-camera-roll/camera-roll/-/camera-roll-7.9.0.tgz", + "integrity": "sha512-Ra0lB1G2H11MzL5aIH3bwlxU1zaHGSZeRs4lBXLBO64Ai1gUgZPR7TYgKDeeRPzNPtSbZKmzs+fuZ/7XoCf1SA==", + "license": "MIT", + "engines": { + "node": ">= 18.17.0" + }, + "peerDependencies": { + "react-native": ">=0.59" + } + }, "node_modules/@react-native-community/cli": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-15.0.1.tgz", @@ -4373,6 +4388,24 @@ "nanoid": "3.3.8" } }, + "node_modules/@react-navigation/stack": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.1.1.tgz", + "integrity": "sha512-CBTKQlIkELp05zRiTAv5Pa7OMuCpKyBXcdB3PGMN2Mm55/5MkDsA1IaZorp/6TsVCdllITD6aTbGX/HA/88A6w==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.2.5", + "color": "^4.2.3" + }, + "peerDependencies": { + "@react-navigation/native": "^7.0.14", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-gesture-handler": ">= 2.0.0", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -5596,8 +5629,7 @@ "node_modules/@types/hammerjs": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", - "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "dev": true + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -6776,6 +6808,12 @@ "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6791,6 +6829,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-core": { "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", @@ -7060,6 +7109,15 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -7642,6 +7700,18 @@ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/command-exists": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", @@ -7851,6 +7921,15 @@ "node": ">=4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -8529,6 +8608,15 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -9982,6 +10070,26 @@ "node": ">=0.4.0" } }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.4.tgz", @@ -9997,6 +10105,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -10430,6 +10552,19 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -14118,6 +14253,12 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -14418,7 +14559,6 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.23.0.tgz", "integrity": "sha512-xtkdIU4S4uc4J2WO4hy7AXxD/1M8Be2yOrLdPTuWKAOF3KyL0D0xSdvuaWhI+GdZCNQQisj9kvbnMQGGb9XZNQ==", - "dev": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -14766,6 +14906,19 @@ "node": ">=10" } }, + "node_modules/react-native-view-shot": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz", + "integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==", + "license": "MIT", + "dependencies": { + "html2canvas": "^1.4.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-webview": { "version": "13.13.2", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.13.2.tgz", @@ -16420,6 +16573,15 @@ "node": "*" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -16828,6 +16990,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/package.json b/package.json index 2011554..498d1c1 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,16 @@ "@emotion/react": "^11.14.0", "@mj-studio/react-native-naver-map": "^2.2.0", "@react-native-async-storage/async-storage": "^2.1.1", + "@react-native-camera-roll/camera-roll": "^7.9.0", "@react-native-community/geolocation": "^3.4.0", "@react-native-community/netinfo": "^11.4.1", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", + "@react-navigation/stack": "^7.1.1", "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.63.0", + "axios": "^1.7.9", "d3": "^7.9.0", "fast-text-encoding": "^1.0.6", "jotai": "^2.11.1", @@ -47,6 +50,7 @@ "react-native-toast-message": "^2.2.1", "react-native-url-polyfill": "^2.0.0", "react-native-vector-icons": "^10.2.0", + "react-native-view-shot": "^4.0.3", "react-native-webview": "^13.13.2", "sockjs-client": "^1.6.1" }, diff --git a/src/App.tsx b/src/App.tsx index a1ad81f..a0f2312 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,6 @@ import { AppProviders } from '~providers/AppProviders'; import { lightTheme } from '~styles/theme'; import StoryBookUI from '../.storybook'; import { RootNavigator } from '~navigation/RootNavigator'; -import { useWebSocket } from '~hooks/useWebSocket'; -import { WebSocketProvider } from '~providers/WebSocketProvider'; import 'react-native-url-polyfill/auto'; import 'fast-text-encoding'; @@ -20,16 +18,12 @@ const navTheme = { }; const MainApp = () => { - const { client, sendMessage } = useWebSocket(); - return ( - - - - - - - + + + + + ); }; diff --git a/src/apis/walk/completeWalk.ts b/src/apis/walk/completeWalk.ts new file mode 100644 index 0000000..f1744aa --- /dev/null +++ b/src/apis/walk/completeWalk.ts @@ -0,0 +1,128 @@ +import { HTTPError } from 'ky'; +import { APIResponse } from '~types/api'; +import { api } from '~apis/api'; + +export interface RequestCompleteWalk { + request: { + totalDistanceMeter: number; + totalWalkTimeSecond: number; + }; + walkImgFile: string; +} + +export interface ResponseCompleteWalk { + code: string; + status: string; + message: string; + data: { + date: string; + memberName: string; + dogName: string; + totalDistanceMeter: number; + timeDuration: { + hours: number; + minutes: number; + seconds: number; + }; + totalCalorie: number; + walkImg: string; + walkWithDogInfo: { + dogId: number; + dogProfileImg: string; + dogName: string; + breed: string; + dogAge: number; + dogGender: string; + memberId: number; + }; + }; +} + +const processImageForFormData = (imageUri: string): any => { + console.log('[API] 이미지 처리 시작, URI 유형:', typeof imageUri); + + if (!imageUri) { + console.error('[API] 이미지 URI가 없음'); + throw new Error('이미지 URI가 제공되지 않았습니다.'); + } + + try { + if (imageUri.startsWith('file://')) { + console.log('[API] 로컬 파일 URI 감지:', imageUri.substring(0, 40) + '...'); + return { + uri: imageUri, + name: `walk-map-${Date.now()}.png`, + type: 'image/png', + }; + } + + if (imageUri.startsWith('data:image/')) { + console.log('[API] Data URI 감지 (base64)'); + return { + uri: imageUri, + name: `walk-map-${Date.now()}.png`, + type: imageUri.split(';')[0].replace('data:', ''), + }; + } + + console.log('[API] 기타 형식의 URI, 기본 처리 적용'); + return { + uri: imageUri, + name: `walk-map-${Date.now()}.png`, + type: 'image/png', + }; + } catch (error) { + console.error('[API] 이미지 처리 실패:', error); + throw new Error('이미지 변환에 실패했습니다: ' + (error instanceof Error ? error.message : '알 수 없는 오류')); + } +}; + +export const completeWalk = async (userInfo: RequestCompleteWalk): Promise> => { + try { + console.log('[API] completeWalk 요청 시작:', userInfo); + + const formData = new FormData(); + + const mapImageFile = processImageForFormData(userInfo.walkImgFile); + console.log('[API] 처리된 이미지 파일:', mapImageFile.name); + formData.append('walkImgFile', mapImageFile); + + const requestData = JSON.stringify({ + totalDistanceMeter: userInfo.request.totalDistanceMeter, + totalWalkTimeSecond: userInfo.request.totalWalkTimeSecond, + }); + + console.log('[API] JSON 요청 데이터:', requestData); + formData.append('request', requestData); + + console.log('[API] FormData 구성 완료'); + + console.log('[API] API 요청 시작'); + + try { + const response = await api + .post('walk/complete', { + body: formData, + headers: { + Accept: 'application/json', + }, + timeout: 30000, // 30초 타임아웃 (파일 업로드는 시간이 더 걸릴 수 있음) + }) + .json>(); + + console.log('[API] completeWalk 응답 성공:', response); + return response; + } catch (error) { + if (error instanceof HTTPError) { + const errorData = await error.response.json(); + console.error('[API] HTTP 오류:', errorData); + } else { + console.error('[API] 알 수 없는 오류:', error); + } + throw error; + } + } catch (error) { + console.error('[API] completeWalk 오류:', error); + throw error; + } +}; diff --git a/src/apis/walk/startWalk.ts b/src/apis/walk/startWalk.ts new file mode 100644 index 0000000..4897024 --- /dev/null +++ b/src/apis/walk/startWalk.ts @@ -0,0 +1,33 @@ +import { HTTPError } from 'ky'; +import { APIResponse } from '~types/api'; +import { api } from '~apis/api'; + +export interface RequestStartWalk { + dogIds: number[]; +} + +export interface ResponseStartWalk { + code: string; + status: string; + message: string; + data: {} | string; +} + +export const startWalk = async (userInfo: RequestStartWalk): Promise> => { + try { + const response = api + .post('walk/start', { + json: userInfo, + }) + .json>(); + return response; + } catch (error) { + if (error instanceof HTTPError) { + const errorData = await error.response.json(); + console.error(errorData); + } else { + console.error(error); + } + throw error; + } +}; diff --git a/src/assets/avatars/Avatar1.png b/src/assets/avatars/Avatar1.png new file mode 100644 index 0000000..a567eec Binary files /dev/null and b/src/assets/avatars/Avatar1.png differ diff --git a/src/assets/walk-summary.svg b/src/assets/walk-summary.svg new file mode 100644 index 0000000..444d580 --- /dev/null +++ b/src/assets/walk-summary.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Common/Icons/index.tsx b/src/components/Common/Icons/index.tsx index 64d40f4..5238696 100644 --- a/src/components/Common/Icons/index.tsx +++ b/src/components/Common/Icons/index.tsx @@ -33,6 +33,7 @@ import FamilyInvitationGuide from '~assets/family-invitation-guide.svg'; import FamilyJoinGuide from '~assets/family-join-guide.svg'; import FamilyJoinGuide2 from '~assets/family-join-guide2.svg'; import FamilyJoinGuide3 from '~assets/family-join-guide3.svg'; +import WalkSummary from '~assets/walk-summary.svg'; import DogTurnedBack from '~assets/dogs/dog-turned-back.svg'; import Crown from '~assets/crown.svg'; @@ -71,6 +72,7 @@ export const Icon = { FamilyJoinGuide: (props: SvgProps) => , FamilyJoinGuide2: (props: SvgProps) => , FamilyJoinGuide3: (props: SvgProps) => , + WalkSummary: (props: SvgProps) => , DogTurnedBack: (props: SvgProps) => , Crown: (props: SvgProps) => , }; diff --git a/src/components/Common/ListModal/index.tsx b/src/components/Common/ListModal/index.tsx index 339be19..c1f9ace 100644 --- a/src/components/Common/ListModal/index.tsx +++ b/src/components/Common/ListModal/index.tsx @@ -1,37 +1,55 @@ import { Modal, ScrollView, Animated, Dimensions, GestureResponderEvent } from 'react-native'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Separator } from '~components/Common/Seperator'; import * as S from './styles'; +interface Dog { + dogId: number; + dogName: string; + breed: string; + dogBirthDate: string; + dogGender: string; + walkCount: number; + dogProfileImg: string; +} + interface DogListModalProps { isVisible: boolean; onClose: () => void; - dogs: { - id: string; - name: string; - breed: string; - age: string; - gender: string; - walkCount: number; - imageUrl: string; - }[]; - onSelectDog: (dog: { - id: string; - name: string; - breed: string; - age: string; - gender: string; - walkCount: number; - imageUrl: string; - }) => void; - type?: 'walk' | 'default' | 'select'; + dogs: Dog[]; + onSelectDog: (dog: Dog) => void; + onSelectMultipleDogs?: (dogs: Dog[]) => void; + type?: 'walk' | 'default' | 'multi-select' | 'select'; } const SCREEN_HEIGHT = Dimensions.get('window').height; const WALK_MODAL_HEIGHT = SCREEN_HEIGHT * 0.85; -export const DogListModal = ({ isVisible, onClose, dogs, onSelectDog, type = 'default' }: DogListModalProps) => { +const calculateAge = (birthDate: string): number => { + const birth = new Date(birthDate); + const today = new Date(); + let age = today.getFullYear() - birth.getFullYear(); + const monthDiff = today.getMonth() - birth.getMonth(); + + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { + age--; + } + + return age; +}; + +export const DogListModal = ({ + isVisible, + onClose, + dogs, + onSelectDog, + onSelectMultipleDogs, + type = 'default', +}: DogListModalProps) => { const slideAnim = useRef(new Animated.Value(type === 'walk' ? WALK_MODAL_HEIGHT : 500)).current; + const [selectedDogs, setSelectedDogs] = useState([]); + + console.log(dogs); useEffect(() => { if (isVisible) { @@ -52,6 +70,23 @@ export const DogListModal = ({ isVisible, onClose, dogs, onSelectDog, type = 'de // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible, type]); + const toggleDogSelection = (dog: Dog) => { + setSelectedDogs(prevSelected => { + if (prevSelected.some(selectedDog => selectedDog.dogId === dog.dogId)) { + return prevSelected.filter(selectedDog => selectedDog.dogId !== dog.dogId); + } else { + return [...prevSelected, dog]; + } + }); + }; + + const handleConfirmSelection = () => { + if (onSelectMultipleDogs) { + onSelectMultipleDogs(selectedDogs); + } + onClose(); + }; + return ( @@ -65,29 +100,47 @@ export const DogListModal = ({ isVisible, onClose, dogs, onSelectDog, type = 'de > {type === 'walk' ? '강번따 리스트' : '강아지 리스트'} + {type === 'multi-select' && ( + + 선택 완료 + + )} {dogs.map(dog => ( - + selectedDog.dogId === dog.dogId) + ? 'lightgray' + : 'white', + }} + > - {dog.name} + {dog.dogName} {dog.breed} - {dog.age} + {calculateAge(dog.dogBirthDate)}살 - {dog.gender} + {dog.dogGender === 'MALE' ? '남' : '여'} 산책 횟수 {dog.walkCount}회 onSelectDog(dog)} + onPress={() => { + if (type === 'multi-select') { + toggleDogSelection(dog); + } else { + onSelectDog(dog); + } + }} type="roundedRect" bgColor="lighten_3" text={type === 'walk' ? '강번따' : type === 'select' ? '선택' : '추가'} diff --git a/src/components/Common/ListModal/styles.ts b/src/components/Common/ListModal/styles.ts index e9cf45a..a202444 100644 --- a/src/components/Common/ListModal/styles.ts +++ b/src/components/Common/ListModal/styles.ts @@ -11,7 +11,7 @@ export const ModalBackground = styled.TouchableOpacity` background-color: rgba(0, 0, 0, 0.5); `; -export const ModalContainer = styled(Animated.View)<{ type: 'walk' | 'default' | 'select' }>` +export const ModalContainer = styled(Animated.View)<{ type: 'walk' | 'default' | 'multi-select' | 'select' }>` position: absolute; bottom: 0; left: 0; @@ -74,3 +74,26 @@ export const InfoContainer = styled.View` export const WalkText = styled(TextBold)``; export const SelectButton = styled(ResizeButton)``; + +interface ConfirmButtonProps { + bgColor?: string; +} + +export const ConfirmButton = styled.TouchableOpacity` + position: absolute; + top: 10px; + right: 10px; + background-color: ${({ theme, bgColor }) => (bgColor === 'default' ? theme.colors.default : theme.colors.lighten_3)}; + padding: 5px 10px; + border-radius: 5px; + align-items: center; + justify-content: center; + width: 70px; + height: 40px; +`; + +export const ConfirmButtonText = styled.Text` + color: ${({ theme }) => theme.colors.font_1}; + font-size: 14px; + font-weight: bold; +`; diff --git a/src/components/Walk/Header/index.tsx b/src/components/Walk/Header/index.tsx index 062f8cb..f090047 100644 --- a/src/components/Walk/Header/index.tsx +++ b/src/components/Walk/Header/index.tsx @@ -1,10 +1,16 @@ import { useNavigation } from '@react-navigation/native'; import * as S from './styles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import React from 'react'; +import { getAvatar } from '~utils/getAvatar'; +import { useUser } from '~apis/member/useUser'; const WalkHeader = () => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const avatars = getAvatar(); + const user = useUser(); + const AvatarComponent = avatars[user.memberProfileImg]; return ( @@ -17,10 +23,8 @@ const WalkHeader = () => { navigation.goBack()}> - 강남구 논현동 - - - + {user.address} + {AvatarComponent && } ); diff --git a/src/components/Walk/MapView/index.tsx b/src/components/Walk/MapView/index.tsx index 4ede62a..61e1b13 100644 --- a/src/components/Walk/MapView/index.tsx +++ b/src/components/Walk/MapView/index.tsx @@ -1,12 +1,100 @@ -import { NaverMapView } from '@mj-studio/react-native-naver-map'; -import { useEffect, useState } from 'react'; +import { + Camera, + NaverMapMarkerOverlay, + NaverMapView, + NaverMapViewRef, + NaverMapCircleOverlay, + NaverMapPathOverlay, +} from '@mj-studio/react-native-naver-map'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, Linking, Platform } from 'react-native'; +import Geolocation from '@react-native-community/geolocation'; +import { request, PERMISSIONS, requestLocationAccuracy, requestMultiple } from 'react-native-permissions'; import { formatDuration, formatDistance } from '~screens/Home/WalkScreen'; import * as S from './styles'; +import { useMyDogInfo } from '~apis/dog/useMyDogInfo'; +import { DogListModal } from '~components/Common/ListModal'; +import axios from 'axios'; + +import ViewShot, { captureRef } from 'react-native-view-shot'; +import { CameraRoll } from '@react-native-camera-roll/camera-roll'; +import WalkSummaryModal from '../WalkSummary'; +import { startWalk } from '~apis/walk/startWalk'; +import { completeWalk } from '~apis/walk/completeWalk'; +import { useWebSocket } from '~hooks/useWebSocket'; +import React from 'react'; +import { getAvatar } from '~utils/getAvatar'; +import { useUser } from '~apis/member/useUser'; + +const WALKING_INTERVAL = 5000; +const MIN_ACCURACY = 30; +const MIN_MARKER_DISTANCE = 20; +const ROUTE_API_URL = 'https://ruehan-home.com:8004/ors/v2/directions/foot-walking/geojson'; + +const calculateDirectDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + const R = 6371e3; + const φ1 = (lat1 * Math.PI) / 180; + const φ2 = (lat2 * Math.PI) / 180; + const Δφ = ((lat2 - lat1) * Math.PI) / 180; + const Δλ = ((lon2 - lon1) * Math.PI) / 180; + + const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +}; const MapView = () => { + const myDogInfo = useMyDogInfo(); + const { sendMessage, responseData, connectionStatus } = useWebSocket(); + const avatars = getAvatar(); + const user = useUser(); + const AvatarComponent = avatars[user.memberProfileImg]; + + console.log('[Walk] 유저 정보:', user); + + useEffect(() => { + if (responseData) { + console.log('[Walk] WebSocket 응답 데이터 수신:', responseData); + } + }, [responseData]); + + console.log('[Walk] WebSocket 연결 상태:', connectionStatus); + + const mapRef = useRef(null); const [isWalking, setIsWalking] = useState(false); const [walkTime, setWalkTime] = useState(0); - const [distance, _setDistance] = useState(0); + const [distance, setDistance] = useState(0); + const [camera, _setCamera] = useState({ + latitude: 37.50497126, + longitude: 127.04905021, + zoom: 18, + }); + + const [currentLocation, setCurrentLocation] = useState<{ + latitude: number; + longitude: number; + }>({ + latitude: 37.50497126, + longitude: 127.04905021, + }); + const previousLocationRef = useRef<{ latitude: number; longitude: number } | null>(null); + const [locationMarkers, setLocationMarkers] = useState< + { + latitude: number; + longitude: number; + index: number; + }[] + >([]); + const lastUpdateTimeRef = useRef(Date.now()); + + const [isLocationCentered, setIsLocationCentered] = useState(true); + const [isModalVisible, setIsModalVisible] = useState(false); + const [routeCoordinates, setRouteCoordinates] = useState([]); + const viewShotRef = useRef(null); + const [screenshotUri, setScreenshotUri] = useState(''); + const [isWalkSummaryVisible, setIsWalkSummaryVisible] = useState(false); + const [locationPermissionGranted, setLocationPermissionGranted] = useState(false); useEffect(() => { let interval: NodeJS.Timeout; @@ -17,35 +105,628 @@ const MapView = () => { } return () => clearInterval(interval); }, [isWalking]); + const filterPosition = (position: { coords: { accuracy: number; latitude: number; longitude: number } }): boolean => { + const isAccurate = position.coords.accuracy <= MIN_ACCURACY; + if (!isAccurate) { + console.log('[Walk] 위치 정확도 낮음, 무시됨:', position.coords.accuracy); + } + return isAccurate; + }; + const shouldAddMarker = useCallback( + (newPosition: { latitude: number; longitude: number }): boolean => { + if (locationMarkers.length === 0) { + console.log('[Walk] 첫 마커 추가'); + return true; + } + + const lastMarker = locationMarkers[locationMarkers.length - 1]; + const calDistance = calculateDirectDistance( + lastMarker.latitude, + lastMarker.longitude, + newPosition.latitude, + newPosition.longitude, + ); + + const shouldAdd = calDistance >= MIN_MARKER_DISTANCE; + console.log('[Walk] 마커 추가 여부:', shouldAdd, '거리:', calDistance.toFixed(2) + 'm'); + return shouldAdd; + }, + [locationMarkers], + ); + const requestLocationPermission = useCallback(async () => { + try { + if (Platform.OS === 'ios') { + const status = await request(PERMISSIONS.IOS.LOCATION_ALWAYS); + console.log('[Walk] iOS 위치 권한 상태:', status); + + if (status === 'granted') { + try { + await requestLocationAccuracy({ purposeKey: 'common-purpose' }); + setLocationPermissionGranted(true); + } catch (e) { + console.error('[Walk] iOS 위치 정확도 요청 실패:', e); + Alert.alert('위치 정확도 설정 필요', '정확한 위치 정보가 필요합니다. 설정에서 권한을 변경해주세요.', [ + { text: '취소', style: 'cancel' }, + { + text: '설정으로 이동', + onPress: () => { + Linking.openURL('app-settings:'); + }, + }, + ]); + } + } else if (status === 'denied' || status === 'blocked') { + Alert.alert( + '위치 권한이 필요합니다', + '산책 기능을 사용하려면 위치 권한이 필요합니다. 설정에서 위치 권한을 허용해주세요.', + [ + { text: '취소', style: 'cancel' }, + { + text: '설정으로 이동', + onPress: () => { + Linking.openURL('app-settings:'); + }, + }, + ], + ); + } else { + Alert.alert('위치 권한 필요', '산책 기능을 사용하려면 항상 또는 앱 사용 중 위치 권한이 필요합니다.'); + } + } else { + const statuses = await requestMultiple([ + PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION, + PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION, + ]); + console.log('[Walk] Android 위치 권한 상태:', statuses); + + if (statuses[PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION] === 'granted') { + setLocationPermissionGranted(true); + } else if ( + statuses[PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION] === 'denied' || + statuses[PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION] === 'blocked' + ) { + Alert.alert( + '위치 권한이 필요합니다', + '산책 기능을 사용하려면 위치 권한이 필요합니다. 설정에서 위치 권한을 허용해주세요.', + [ + { text: '취소', style: 'cancel' }, + { + text: '설정으로 이동', + onPress: () => { + Linking.openSettings(); + }, + }, + ], + ); + } else { + Alert.alert('위치 권한 필요', '산책 기능을 사용하려면 위치 권한이 필요합니다.'); + } + } + } catch (e) { + console.error('[Walk] 위치 권한 요청 실패:', e); + Alert.alert('오류', '위치 권한을 요청하는 중 오류가 발생했습니다.'); + } + }, []); + + useEffect(() => { + const initializeLocation = async () => { + await requestLocationPermission(); + + Geolocation.getCurrentPosition( + position => { + const { latitude, longitude } = position.coords; + console.log('[Walk] 초기 위치 가져오기 성공:', { latitude, longitude }); + + setCurrentLocation({ latitude, longitude }); + + mapRef.current?.animateCameraTo({ + latitude, + longitude, + zoom: 18, + duration: 500, + easing: 'Fly', + }); + }, + error => { + console.error('[Walk] 초기 위치 가져오기 실패:', error); + Alert.alert('위치 오류', '현재 위치를 가져올 수 없습니다. 위치 서비스가 활성화되어 있는지 확인해주세요.'); + }, + { + enableHighAccuracy: true, + timeout: 5000, + }, + ); + }; + + initializeLocation(); + }, [requestLocationPermission]); + + useEffect(() => { + if (!locationPermissionGranted) { + console.log('[Walk] 위치 권한 없음, 추적 중단'); + return; + } + + console.log('[Walk] 위치 추적 시작 (권한 있음)'); + + const watchId = Geolocation.watchPosition( + position => { + const currentTime = Date.now(); + const timeSinceLastUpdate = currentTime - lastUpdateTimeRef.current; + + console.log('[Walk] 위치 데이터 수신:', { + accuracy: position.coords.accuracy, + lastUpdate: timeSinceLastUpdate, + isWalking: isWalking, + coords: { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }, + }); + + if (timeSinceLastUpdate < WALKING_INTERVAL) { + return; + } + + if (!filterPosition(position)) { + console.log('[Walk] 정확도 불충분, 무시:', position.coords.accuracy); + return; + } + + const { latitude, longitude } = position.coords; + const newPosition = { latitude, longitude }; + + previousLocationRef.current = currentLocation; + setCurrentLocation(newPosition); + lastUpdateTimeRef.current = currentTime; + + console.log('[Walk] 위치 업데이트 완료:', { latitude, longitude }); + + if (isWalking) { + console.log('[Walk] 산책 중 위치 업데이트 처리'); + + if (shouldAddMarker(newPosition)) { + console.log('[Walk] 새 마커 추가:', newPosition); + setLocationMarkers(prev => [ + ...prev, + { + latitude, + longitude, + index: prev.length + 1, + }, + ]); + } + + if (isLocationCentered) { + console.log('[Walk] 카메라 위치 이동'); + mapRef.current?.animateCameraTo({ + latitude, + longitude, + zoom: 18, + duration: 500, + easing: 'Fly', + }); + } + } + }, + error => { + console.error('[Walk] 위치 추적 오류:', error); + Alert.alert('위치 추적 오류', '위치를 추적하는 중 오류가 발생했습니다.'); + }, + { + enableHighAccuracy: true, + distanceFilter: 0, + interval: 1000, + timeout: 10000, // 타임아웃을 10초로 늘림 + }, + ); + + return () => { + console.log('[Walk] 위치 추적 중지'); + Geolocation.clearWatch(watchId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [locationPermissionGranted, isWalking, isLocationCentered, shouldAddMarker]); + + const handleLocationButtonPress = useCallback(() => { + console.log('[Walk] 내 위치로 이동'); + mapRef.current?.animateCameraTo({ + latitude: currentLocation.latitude, + longitude: currentLocation.longitude, + zoom: 18, + duration: 500, + easing: 'Fly', + }); + setIsLocationCentered(true); + }, [currentLocation]); + + const handleStartWalkPress = useCallback(() => { + console.log('[Walk] 산책 시작 버튼 클릭'); + + if (!locationPermissionGranted) { + console.log('[Walk] 위치 권한 없음, 권한 요청 시도'); + Alert.alert('위치 권한 필요', '산책 기능을 사용하려면 위치 권한이 필요합니다. 권한을 허용하시겠습니까?', [ + { text: '취소', style: 'cancel' }, + { + text: '권한 요청', + onPress: async () => { + await requestLocationPermission(); + if (locationPermissionGranted) { + setIsModalVisible(true); + } + }, + }, + ]); + return; + } + + setIsModalVisible(true); + }, [locationPermissionGranted, requestLocationPermission]); + + const handleStopWalkPress = useCallback(async () => { + console.log('[Walk] 산책 종료 버튼 클릭'); + setIsWalking(false); + + try { + console.log('[Walk] 스크린샷 촬영 시작'); + const uri = await captureRef(viewShotRef, { + format: 'png', + quality: 0.8, + }); - const renderWalkButton = () => { + const savedUri = await CameraRoll.save(uri, { type: 'photo' }); + console.log('[Walk] 스크린샷 저장 성공:', savedUri); + + console.log('[Walk] 산책 데이터 준비:', { + 시간: walkTime, + 거리: distance, + 이미지URI: uri.substring(0, 50) + '...', + }); + + const response = await completeWalk({ + request: { + totalDistanceMeter: distance, + totalWalkTimeSecond: walkTime, + }, + walkImgFile: uri, + }); + + console.log('[Walk] 산책 완료 API 성공:', response); + + setScreenshotUri(savedUri); + setIsWalkSummaryVisible(true); + } catch (error) { + console.error('[Walk] 산책 종료 처리 실패:', error); + if (error instanceof Error) { + console.error('[Walk] 오류 메시지:', error.message); + console.error('[Walk] 오류 스택:', error.stack); + } + Alert.alert('오류', '산책 종료 처리 중 오류가 발생했습니다.'); + } + }, [walkTime, distance]); + + const handleWalkSummaryClose = useCallback(() => { + console.log('[Walk] 산책 요약 닫기'); + setWalkTime(0); + setDistance(0); + setLocationMarkers([]); + setRouteCoordinates([]); + setIsWalkSummaryVisible(false); + }, []); + + const handleSelectDog = useCallback( + async (dogs: any) => { + console.log('[Walk] 선택된 강아지:', dogs); + setIsModalVisible(false); + + try { + const dogIds = dogs.map((d: any) => d.dogId); + console.log('[Walk] 산책 시작 강아지 IDs:', dogIds); + + const response = await startWalk({ dogIds }); + console.log('[Walk] 산책 시작 API 성공:', response); + + if ( + !currentLocation || + (currentLocation.latitude === 37.50497126 && currentLocation.longitude === 127.04905021) + ) { + console.log('[Walk] 현재 위치 업데이트 필요, 위치 가져오기 시도'); + + Geolocation.getCurrentPosition( + position => { + const { latitude, longitude } = position.coords; + console.log('[Walk] 산책 시작 전 위치 업데이트 성공:', { latitude, longitude }); + + setCurrentLocation({ latitude, longitude }); + + mapRef.current?.animateCameraTo({ + latitude, + longitude, + zoom: 18, + duration: 500, + easing: 'Fly', + }); + + setLocationMarkers([ + { + latitude, + longitude, + index: 0, + }, + ]); + + setIsWalking(true); + setIsLocationCentered(true); + setDistance(0); + lastProcessedMarkerIndexRef.current = -1; + }, + error => { + console.error('[Walk] 산책 시작 위치 가져오기 실패:', error); + Alert.alert( + '위치 오류', + '현재 위치를 가져올 수 없습니다. 위치 서비스가 활성화되어 있는지 확인 후 다시 시도해주세요.', + ); + }, + { + enableHighAccuracy: true, + timeout: 10000, + }, + ); + } else { + console.log('[Walk] 현재 위치로 산책 시작:', currentLocation); + + mapRef.current?.animateCameraTo({ + latitude: currentLocation.latitude, + longitude: currentLocation.longitude, + zoom: 18, + duration: 500, + easing: 'Fly', + }); + + setLocationMarkers([ + { + latitude: currentLocation.latitude, + longitude: currentLocation.longitude, + index: 0, + }, + ]); + + setIsWalking(true); + setIsLocationCentered(true); + setDistance(0); + lastProcessedMarkerIndexRef.current = -1; + } + } catch (error) { + console.error('[Walk] 산책 시작 실패:', error); + Alert.alert('오류', '산책을 시작하는 중 오류가 발생했습니다.'); + } + }, + [currentLocation], + ); + + const renderWalkButton = useCallback(() => { if (!isWalking) { - return setIsWalking(true)} bgColor="default" text="산책 시작" />; + return ; } return ( {formatDuration(walkTime)} - setIsWalking(false)} bgColor="lighten_2" text="산책 끝" /> - {formatDistance(distance)}km + + {formatDistance(distance)} ); - }; + }, [isWalking, walkTime, distance, handleStartWalkPress, handleStopWalkPress]); + + const handleCameraChange = useCallback( + (event: any) => { + const { latitude, longitude } = event; + + const calDistance = calculateDirectDistance( + latitude, + longitude, + currentLocation.latitude, + currentLocation.longitude, + ); + + const centered = calDistance < 20; + if (isLocationCentered !== centered) { + setIsLocationCentered(centered); + console.log('[Walk] 지도 중심 상태 변경:', centered); + } + }, + [currentLocation, isLocationCentered], + ); + + const fetchRouteData = useCallback( + async (markers: { longitude: number; latitude: number }[]) => { + if (markers.length < 2) { + console.log('[Walk] 경로 계산 건너뜀: 마커가 부족함'); + return; + } + + const lastTwoMarkers = markers.slice(-2); + const lastTwoCoordinates = lastTwoMarkers.map(marker => [marker.longitude, marker.latitude]); + + console.log('[Walk] 경로 계산 요청:', lastTwoCoordinates); + + try { + const response = await axios.post( + ROUTE_API_URL, + { + coordinates: lastTwoCoordinates, + }, + { + timeout: 10000, + }, + ); + + const routeData = response.data; + + if ( + !routeData?.features?.[0]?.geometry?.coordinates || + !routeData?.features?.[0]?.properties?.segments?.[0]?.distance + ) { + console.error('[Walk] 경로 데이터 형식 오류:', routeData); + return; + } + + const newRouteCoordinates = routeData.features[0].geometry.coordinates; + const segmentDistance = routeData.features[0].properties.segments[0].distance; + + console.log('[Walk] 경로 계산 성공:', { + 좌표수: newRouteCoordinates.length, + 구간거리: segmentDistance, + 현재총거리: distance, + }); + + setRouteCoordinates(prev => { + if ( + prev.length > 0 && + prev[prev.length - 1][0] === newRouteCoordinates[0][0] && + prev[prev.length - 1][1] === newRouteCoordinates[0][1] + ) { + return [...prev, ...newRouteCoordinates.slice(1)]; + } + return [...prev, ...newRouteCoordinates]; + }); + + if (lastProcessedMarkerIndexRef.current === 2) { + console.log('[Walk] 첫 구간 거리 설정:', segmentDistance); + setDistance(segmentDistance); + } else if (segmentDistance < 1000) { + console.log('[Walk] 거리 추가:', distance, '+', segmentDistance, '=', distance + segmentDistance); + setDistance(prevDistance => prevDistance + segmentDistance); + } else { + console.warn('[Walk] 비정상적으로 큰 구간 거리 무시:', segmentDistance); + } + + if (connectionStatus === 'connected' && newRouteCoordinates.length > 0) { + const lastCoordinate = newRouteCoordinates[newRouteCoordinates.length - 1]; + + const message = JSON.stringify({ + latitude: lastCoordinate[1], + longitude: lastCoordinate[0], + }); + + console.log('[Walk] WebSocket publish 시도 - /pub/api/v1/walk-alone'); + const sent = sendMessage('/pub/api/v1/walk-alone', message); + console.log('[Walk] WebSocket 메시지 전송 결과:', sent); + + if (sent) { + console.log('[Walk] 메시지 내용:', JSON.parse(message)); + } else { + console.error('[Walk] WebSocket 메시지 전송 실패'); + } + } else { + console.log('[Walk] WebSocket 메시지 전송 건너뜀:', { + 연결상태: connectionStatus, + 좌표수: newRouteCoordinates ? newRouteCoordinates.length : 0, + }); + } + } catch (error) { + console.error('[Walk] 경로 데이터 가져오기 실패:', error); + if (axios.isAxiosError(error)) { + console.error('[Walk] 오류 세부 정보:', { + message: error.message, + status: error.response?.status, + data: error.response?.data, + }); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [connectionStatus, sendMessage, distance, walkTime], + ); + + const drawRoutePolygon = useCallback(() => { + if (isWalking && routeCoordinates.length >= 2) { + console.log('[Walk] 경로 폴리곤 그리기:', routeCoordinates.length, '개 좌표'); + return ( + ({ latitude, longitude }))} + /> + ); + } + return null; + }, [isWalking, routeCoordinates]); + + const lastProcessedMarkerIndexRef = useRef(-1); + + useEffect(() => { + if (isWalking && locationMarkers.length >= 2) { + if (locationMarkers.length > lastProcessedMarkerIndexRef.current) { + console.log(`[Walk] 새 마커 처리: ${lastProcessedMarkerIndexRef.current + 1} -> ${locationMarkers.length}`); + fetchRouteData(locationMarkers); + lastProcessedMarkerIndexRef.current = locationMarkers.length; + } + } + }, [locationMarkers, isWalking, fetchRouteData]); return ( <> - + + + + {AvatarComponent && } + + {locationMarkers.map((marker, index) => ( + + ))} + {drawRoutePolygon()} + + - {}} text="⊕ 내 위치로" bgColor="font_1" /> + {!isLocationCentered && ( + + )} {renderWalkButton()} + + {isModalVisible && ( + setIsModalVisible(false)} + dogs={myDogInfo.data} + onSelectMultipleDogs={handleSelectDog} + type="multi-select" + /> + )} + + ); }; diff --git a/src/components/Walk/WalkSummary/index.tsx b/src/components/Walk/WalkSummary/index.tsx new file mode 100644 index 0000000..5be88a8 --- /dev/null +++ b/src/components/Walk/WalkSummary/index.tsx @@ -0,0 +1,58 @@ +import { Text, Image } from 'react-native'; +import * as S from './styles'; +import { Icon } from '~components/Common/Icons'; + +interface WalkSummaryModalProps { + visible: boolean; + walkTime: number; + distance: number; + screenshotUri: string; + onClose: () => void; +} + +const WalkSummaryModal = ({ visible, walkTime, distance, screenshotUri }: WalkSummaryModalProps) => { + if (!visible) { + return null; + } + + console.log(walkTime, distance); + + const formattedDate = new Date().toISOString().split('T')[0].replace(/-/g, '.'); + + return ( + + + + + {formattedDate} + {'견주님과 밤톨이가'} + + {`${Math.floor(walkTime / 60)}분`} + {'동안 산책했어요.'} + + + + + + {`${Math.floor(walkTime / 3600)}:${Math.floor((walkTime % 3600) / 60)}:${ + walkTime % 60 + }`} + 산책 시간 + + + {`${(distance / 1000).toFixed(1)}km`} + 산책 거리 + + + 200kcal + 소모한 칼로리 + + + + + + + ); +}; + +export default WalkSummaryModal; diff --git a/src/components/Walk/WalkSummary/styles.ts b/src/components/Walk/WalkSummary/styles.ts new file mode 100644 index 0000000..85e0032 --- /dev/null +++ b/src/components/Walk/WalkSummary/styles.ts @@ -0,0 +1,70 @@ +import styled from '@emotion/native'; +import { BgBox } from '~components/Common/BgBox'; +import { TextBold, TextExtraBold, TextMedium } from '~components/Common/Text'; + +export const SafeAreaView = styled.SafeAreaView` + flex: 1; + justify-content: flex-end; + align-items: center; + pointer-events: box-none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + background-color: ${({ theme }) => theme.colors.lighten_3}; +`; + +export const Container = styled.View` + width: 100%; + height: 100%; + padding: 20px; + elevation: 5; +`; + +export const Content = styled.View` + align-items: center; +`; + +export const Header = styled.View` + width: 100%; + margin-bottom: 20px; + align-items: start; +`; + +export const Date = styled(TextBold)` + margin-bottom: 23px; + color: ${({ theme }) => theme.colors.font_1}; +`; + +export const Title = styled(TextExtraBold)` + margin-bottom: 10px; + color: ${({ theme }) => theme.colors.font_1}; +`; + +export const Minute = styled(TextExtraBold)` + color: ${({ theme }) => theme.colors.default}; +`; + +export const InfoContainer = styled(BgBox)` + margin-top: 25px; + margin-bottom: 20px; + flex-direction: row; + justify-content: space-around; + width: 100%; +`; + +export const InfoMain = styled(TextExtraBold)` + flex-direction: row; + align-items: center; +`; + +export const InfoSub = styled(TextMedium)` + flex-direction: row; + align-items: center; +`; + +export const InfoItem = styled.View` + align-items: center; // 중앙 정렬 +`; diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index c44e77b..595aebf 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,47 +1,126 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import SockJS from 'sockjs-client'; -import { Client } from '@stomp/stompjs'; - -const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInByb3ZpZGVyIjoiR09PR0xFIiwiZXhwIjoxNzQwMjQ4MjA3LCJlbWFpbCI6ImJibGJibGFuNjlAZ21haWwuY29tIn0.CpauBw9_yXlYQjr-BZP7xqm1u63pj1g1aM3kX9HwCm37BMhpOQGz1Mq8R42CihtC8henTRy0OHaxa7q9-1Svzw'; +import { Client, IMessage } from '@stomp/stompjs'; +import { getAccessToken } from '~utils/controlAccessToken'; +import { useUser } from '~apis/member/useUser'; const SERVER_URL = 'https://ddang.site/ws'; export const useWebSocket = () => { const stompClientRef = useRef(null); + const [responseData, setResponseData] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [connectionStatus, setConnectionStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected'); + const reconnectAttemptRef = useRef(0); + + const { email } = useUser(); + + console.log('[WebSocket] User email:', email); + // Token 가져오기 useEffect(() => { + const getToken = async () => { + const Token = await getAccessToken(); + console.log('[WebSocket] Token retrieved:', Token ? 'Success' : 'Failed'); + setAccessToken(Token); + }; + getToken(); + }, []); + + // WebSocket 연결 설정 + useEffect(() => { + if (!accessToken || accessToken === 'none' || !email) { + console.log('[WebSocket] Connection skipped - missing token or email'); + return; + } + + console.log('[WebSocket] Setting up connection with token and email:', email); + setConnectionStatus('connecting'); + const stompClient = new Client({ webSocketFactory: () => new SockJS(SERVER_URL), reconnectDelay: 5000, - debug: msg => console.log(msg), + debug: process.env.NODE_ENV === 'development' ? msg => console.log('[WebSocket Debug]', msg) : undefined, connectHeaders: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${accessToken}`, }, }); stompClient.onConnect = frame => { - console.log('STOMP 연결 성공:', frame); + console.log('[WebSocket] Connection established:', frame?.command); + setConnectionStatus('connected'); + reconnectAttemptRef.current = 0; + + // 이메일로 구독 - walk 응답 처리 + console.log(`[WebSocket] Subscribing to /sub/walk/${email}`); + stompClient.subscribe(`/sub/walk/${email}`, message => { + try { + const response = JSON.parse(message.body); + console.log('[WebSocket] Message received from /sub/walk:', response); - // 메시지 구독 예제 - // stompClient.subscribe('/sub/walk/bblbblan69@gmail.com', message => { - // console.log('받은 메시지:', message.body); - // }); + // 응답 데이터 저장 및 처리 + setResponseData(prevData => { + console.log('[WebSocket] Updating response data:', { + previous: prevData, + new: response, + }); + return response; + }); + } catch (error) { + console.error('[WebSocket] Error parsing message:', error); + } + }); + }; + + // 연결 오류 처리 + stompClient.onStompError = frame => { + console.error('[WebSocket] Connection error:', frame.headers?.message); + setConnectionStatus('disconnected'); + reconnectAttemptRef.current += 1; + }; + + stompClient.onWebSocketClose = () => { + console.log('[WebSocket] Connection closed'); + setConnectionStatus('disconnected'); }; stompClient.activate(); stompClientRef.current = stompClient; return () => { - stompClient.deactivate(); + console.log('[WebSocket] Cleaning up connection'); + if (stompClient.connected) { + stompClient.deactivate(); + } }; - }, []); + }, [accessToken, email]); // email과 accessToken 변경 시 재연결 const sendMessage = (destination: string, body: string) => { if (stompClientRef.current && stompClientRef.current.connected) { + console.log(`[WebSocket] Sending message to ${destination}:`, body); stompClientRef.current.publish({ destination, body }); + return true; + } else { + console.warn('[WebSocket] Cannot send message - not connected'); + return false; } }; - return { client: stompClientRef.current, sendMessage }; + const subscribe = (destination: string, callback: (message: IMessage) => void) => { + if (stompClientRef.current && stompClientRef.current.connected) { + console.log(`[WebSocket] Subscribing to ${destination}`); + return stompClientRef.current.subscribe(destination, callback); + } else { + console.warn('[WebSocket] Cannot subscribe - not connected'); + return undefined; + } + }; + + return { + client: stompClientRef.current, + sendMessage, + subscribe, + responseData, + connectionStatus, + }; }; diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index ecca4ed..f77046a 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -2,12 +2,15 @@ import { PropsWithChildren } from 'react'; import { EmotionProvider } from '~providers/EmotionProvider'; import { TanstackQueryProvider } from '~providers/QueryClientProvider'; import { ToastProvider } from '~providers/ToastProvider'; +import { WebSocketProvider } from '~providers/WebSocketProvider'; export const AppProviders = ({ children }: PropsWithChildren) => { return ( - {children} + + {children} + ); diff --git a/src/providers/WebSocketProvider.tsx b/src/providers/WebSocketProvider.tsx index af85047..afd1f28 100644 --- a/src/providers/WebSocketProvider.tsx +++ b/src/providers/WebSocketProvider.tsx @@ -1,28 +1,23 @@ import { Client } from '@stomp/stompjs'; import { createContext, useContext, PropsWithChildren } from 'react'; +import { useWebSocket } from '~hooks/useWebSocket'; type WebSocketContextType = { client: Client | null; sendMessage: (destination: string, body: any) => void; }; -const WebSocketContext = createContext({ - client: null, - sendMessage: () => {}, // 기본 빈 함수 -}); +const WebSocketContext = createContext(undefined); -type WebSocketProviderProps = { - client: Client | null; - sendMessage: (destination: string, body: any) => void; -}; +export const WebSocketProvider = ({ children }: PropsWithChildren) => { + const { client, sendMessage } = useWebSocket(); -export const WebSocketProvider = ({ children, client, sendMessage }: PropsWithChildren) => { return {children}; }; export const useWebSocketContext = () => { const context = useContext(WebSocketContext); - if (context === undefined) { + if (!context) { throw new Error('useWebSocketContext must be used within a WebSocketProvider'); } return context; diff --git a/src/screens/Home/WalkScreen.tsx b/src/screens/Home/WalkScreen.tsx index 3cde54d..7277725 100644 --- a/src/screens/Home/WalkScreen.tsx +++ b/src/screens/Home/WalkScreen.tsx @@ -2,18 +2,24 @@ import { useNavigation } from '@react-navigation/native'; import { useLayoutEffect } from 'react'; import WalkHeader from '~components/Walk/Header'; import MapView from '~components/Walk/MapView'; -import WalkMessage from '~components/Walk/WalkMessage'; import { SafeAreaView, View } from 'react-native'; -export const formatDuration = (seconds: number) => { +export const formatDuration = (seconds: number): string => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; - return `${hours}시간 ${minutes}분`; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart( + 2, + '0', + )}`; }; -export const formatDistance = (meters: number) => { - return (meters / 1000).toFixed(1); +export const formatDistance = (meters: number): string => { + if (meters >= 1000) { + return `${(meters / 1000).toFixed(2)}km`; + } + return `${Math.round(meters)}m`; }; export const WalkScreen = () => { @@ -30,7 +36,7 @@ export const WalkScreen = () => { - + {/* */} );