diff --git a/.gitignore b/.gitignore index d28688d..0aa6b57 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ fastlane/test_output/ # # Tuist 프로젝트의 설정 파일 /.tuist/ + +# Secret file +*.xcconfig diff --git a/.package.resolved b/.package.resolved new file mode 100644 index 0000000..e3dc19e --- /dev/null +++ b/.package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "48d44fe9560aaa48bc97ae34cdb596f62fa2d739be3dafd4261b95db8f8c86ab", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "kakao-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk", + "state" : { + "revision" : "e14a8d1fad75645fd5677a295a8b1956ebd14d3d", + "version" : "2.25.0" + } + } + ], + "version" : 3 +} diff --git a/Derived/Sources/TuistBundle+Megabox.swift b/Derived/Sources/TuistBundle+Megabox.swift new file mode 100644 index 0000000..5c2d78a --- /dev/null +++ b/Derived/Sources/TuistBundle+Megabox.swift @@ -0,0 +1,25 @@ +// periphery:ignore:all +// swiftlint:disable:this file_name +// swiftlint:disable all +// swift-format-ignore-file +// swiftformat:disable all +#if hasFeature(InternalImportsByDefault) +public import Foundation +#else +import Foundation +#endif +// MARK: - Swift Bundle Accessor for Frameworks +private class BundleFinder {} +extension Foundation.Bundle { +/// Since megabox is a application, the bundle for classes within this module can be used directly. + static let module = Bundle(for: BundleFinder.self) +} +// MARK: - Objective-C Bundle Accessor +@objc +public class MegaboxResources: NSObject { +@objc public class var bundle: Bundle { + return .module +} +} +// swiftformat:enable all +// swiftlint:enable all \ No newline at end of file diff --git a/Project.swift b/Project.swift index 838f637..1cb89a5 100644 --- a/Project.swift +++ b/Project.swift @@ -2,6 +2,10 @@ import ProjectDescription let project = Project( name: "megabox", + packages: [ + .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.20.0"), + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0") + ], targets: [ .target( name: "megabox", @@ -24,6 +28,22 @@ let project = Project( "Pretendard-Regular.otf", "Pretendard-SemiBold.otf", "Pretendard-Thin.otf" + ], + + // Secret.xcconfig 키 사용 + "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", + + // 카카오 로그인용 URL Scheme 등록 + "CFBundleURLTypes": [ + [ + "CFBundleURLSchemes": ["kakao$(KAKAO_NATIVE_APP_KEY)"] + ] + ], + + // 카카오 SDK가 외부 앱을 열 수 있게 허용 + "LSApplicationQueriesSchemes": [ + "kakaokompassauth", + "kakaolink" ] ] ), @@ -31,7 +51,24 @@ let project = Project( "megabox/Sources", "megabox/Resources", ], - dependencies: [] + dependencies: [ + .package(product: "KakaoSDKCommon"), + .package(product: "KakaoSDKAuth"), + .package(product: "KakaoSDKUser"), + .package(product: "Alamofire") + ], + + // xcconfig 연결 + settings: .settings( + base: [ + "OTHER_SWIFT_FLAGS": "-DDEBUG" + ], + configurations: [ + // Secret.xcconfig 연결 + .debug(name: "Debug", xcconfig: "./megabox/Resources/Secret/Secret.xcconfig"), + .release(name: "Release", xcconfig: "./megabox/Resources/Secret/Secret.xcconfig") + ] + ) ), .target( name: "megaboxTests", diff --git a/megabox.xcodeproj/project.pbxproj b/megabox.xcodeproj/project.pbxproj index 726ab6f..9d9edb1 100644 --- a/megabox.xcodeproj/project.pbxproj +++ b/megabox.xcodeproj/project.pbxproj @@ -6,6 +6,14 @@ objectVersion = 70; objects = { +/* Begin PBXBuildFile section */ + 1C39AFCEC0A9B6DE7B156A6A /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 0E54DCF0B8D4BA3D7D76E461 /* KakaoSDKUser */; }; + 33FE1AAB9E90978EB0B68B23 /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = D00B2BA63ADCF363F9C89B55 /* KakaoSDKAuth */; }; + A3F8D55EE960672101CABED8 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = EE94CB5FE1A59E17DE90F38A /* Alamofire */; }; + DA03228E240E5C5472DE4742 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 3325E827733F1870F34F1F94 /* KakaoSDKCommon */; }; + E3BF9CFEC5D98C1A4CF20187 /* TuistBundle+Megabox.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB45D5E01F2BFC1A6AB653F6 /* TuistBundle+Megabox.swift */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 8837CD7A1D909152C3B592D5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -43,6 +51,7 @@ 2BDDA1D2F6458E30323A05E7 /* megaboxTests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "megaboxTests-Info.plist"; sourceTree = ""; }; BD44B1395767484FD2C78AE0 /* megaboxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = megaboxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C1FE0491C1C7A83A5D01373E /* megabox.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = megabox.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CB45D5E01F2BFC1A6AB653F6 /* TuistBundle+Megabox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistBundle+Megabox.swift"; sourceTree = ""; }; E6D1D2FA149FE294E6CBD89D /* megabox-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "megabox-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -57,6 +66,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DA03228E240E5C5472DE4742 /* KakaoSDKCommon in Frameworks */, + 33FE1AAB9E90978EB0B68B23 /* KakaoSDKAuth in Frameworks */, + 1C39AFCEC0A9B6DE7B156A6A /* KakaoSDKUser in Frameworks */, + A3F8D55EE960672101CABED8 /* Alamofire in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -70,6 +83,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0AE3DD2CC8948F6F6D70B01D /* Sources */ = { + isa = PBXGroup; + children = ( + CB45D5E01F2BFC1A6AB653F6 /* TuistBundle+Megabox.swift */, + ); + path = Sources; + sourceTree = ""; + }; 0D19F4DA33D004128909F5EB = { isa = PBXGroup; children = ( @@ -110,6 +131,7 @@ isa = PBXGroup; children = ( 59C62F6E03885E77D562E1C8 /* InfoPlists */, + 0AE3DD2CC8948F6F6D70B01D /* Sources */, ); path = Derived; sourceTree = ""; @@ -169,6 +191,10 @@ ); name = megabox; packageProductDependencies = ( + 3325E827733F1870F34F1F94 /* KakaoSDKCommon */, + D00B2BA63ADCF363F9C89B55 /* KakaoSDKAuth */, + 0E54DCF0B8D4BA3D7D76E461 /* KakaoSDKUser */, + EE94CB5FE1A59E17DE90F38A /* Alamofire */, ); productName = megabox; productReference = C1FE0491C1C7A83A5D01373E /* megabox.app */; @@ -196,6 +222,10 @@ en, ); mainGroup = 0D19F4DA33D004128909F5EB; + packageReferences = ( + C6CD5CE48056BC6D353A43CF /* XCRemoteSwiftPackageReference "Alamofire" */, + E291957838B5BD9C398FDF11 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */, + ); productRefGroup = B92C82379AC30C9C0D6329B2 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -235,6 +265,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E3BF9CFEC5D98C1A4CF20187 /* TuistBundle+Megabox.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -262,6 +293,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-DDEBUG"; PRODUCT_BUNDLE_IDENTIFIER = dev.tuist.megabox; PRODUCT_NAME = megabox; SDKROOT = iphoneos; @@ -313,6 +345,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-DDEBUG"; PRODUCT_BUNDLE_IDENTIFIER = dev.tuist.megabox; PRODUCT_NAME = megabox; SDKROOT = iphoneos; @@ -497,6 +530,44 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + C6CD5CE48056BC6D353A43CF /* XCRemoteSwiftPackageReference "Alamofire" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Alamofire/Alamofire.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.8.0; + }; + }; + E291957838B5BD9C398FDF11 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kakao/kakao-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.20.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0E54DCF0B8D4BA3D7D76E461 /* KakaoSDKUser */ = { + isa = XCSwiftPackageProductDependency; + productName = KakaoSDKUser; + }; + 3325E827733F1870F34F1F94 /* KakaoSDKCommon */ = { + isa = XCSwiftPackageProductDependency; + productName = KakaoSDKCommon; + }; + D00B2BA63ADCF363F9C89B55 /* KakaoSDKAuth */ = { + isa = XCSwiftPackageProductDependency; + productName = KakaoSDKAuth; + }; + EE94CB5FE1A59E17DE90F38A /* Alamofire */ = { + isa = XCSwiftPackageProductDependency; + productName = Alamofire; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = D0ABC90962FFD8356ADE03F1 /* Project object */; } diff --git a/megabox/Resources/Assets.xcassets/MovieSchedule.dataset/Contents.json b/megabox/Resources/Assets.xcassets/MovieSchedule.dataset/Contents.json new file mode 100644 index 0000000..0358039 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/MovieSchedule.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "MovieSchedule.json", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/MovieSchedule.dataset/MovieSchedule.json b/megabox/Resources/Assets.xcassets/MovieSchedule.dataset/MovieSchedule.json new file mode 100644 index 0000000..b95dbe7 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/MovieSchedule.dataset/MovieSchedule.json @@ -0,0 +1,385 @@ +{ + "status": "success", + "message": "Showtimes fetched successfully", + "data": { + "movies": [ + { + "id": "m-001", + "title": "어쩔수가없다", + "age_rating": "15", + "schedules": [ + { + "date": "2025-09-22", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "크리클라이너 1관", + "format": "2D", + "showtimes": [ + { "start": "11:30", "end": "13:58", "available": 109, "total": 116 }, + { "start": "14:20", "end": "16:48", "available": 19, "total": 116 }, + { "start": "17:05", "end": "19:28", "available": 1, "total": 116 }, + { "start": "19:45", "end": "22:02", "available": 100, "total": 116 }, + { "start": "22:20", "end": "00:04", "available": 116, "total": 116 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "BTS관 (7층 1관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "09:30", "end": "11:50", "available": 75, "total": 116 }, + { "start": "12:00", "end": "14:26", "available": 102, "total": 116 }, + { "start": "14:45", "end": "17:04", "available": 88, "total": 116 } + ] + }, + { + "auditorium": "BTS관 (9층 2관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "11:30", "end": "13:58", "available": 34, "total": 116 }, + { "start": "14:10", "end": "16:32", "available": 100, "total": 116 }, + { "start": "16:50", "end": "19:00", "available": 13, "total": 116 }, + { "start": "19:20", "end": "21:40", "available": 92, "total": 116 } + ] + } + ] + } + ] + }, + { + "date": "2025-09-23", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "크리클라이너 1관", + "format": "2D", + "showtimes": [ + { "start": "10:30", "end": "12:58", "available": 112, "total": 116 }, + { "start": "13:40", "end": "16:08", "available": 54, "total": 116 }, + { "start": "16:20", "end": "18:48", "available": 22, "total": 116 }, + { "start": "19:30", "end": "21:58", "available": 97, "total": 116 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "BTS관 (7층 1관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "09:50", "end": "12:10", "available": 81, "total": 116 }, + { "start": "12:30", "end": "14:56", "available": 99, "total": 116 }, + { "start": "15:20", "end": "17:39", "available": 61, "total": 116 } + ] + }, + { + "auditorium": "BTS관 (9층 2관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "11:10", "end": "13:38", "available": 45, "total": 116 }, + { "start": "14:00", "end": "16:22", "available": 88, "total": 116 }, + { "start": "16:40", "end": "18:58", "available": 24, "total": 116 }, + { "start": "19:10", "end": "21:30", "available": 90, "total": 116 } + ] + } + ] + } + ] + }, + { + "date": "2025-09-24", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "크리클라이너 1관", + "format": "2D", + "showtimes": [ + { "start": "11:00", "end": "13:28", "available": 106, "total": 116 }, + { "start": "13:50", "end": "16:18", "available": 33, "total": 116 }, + { "start": "16:40", "end": "19:08", "available": 5, "total": 116 }, + { "start": "19:20", "end": "21:48", "available": 84, "total": 116 }, + { "start": "22:10", "end": "00:34", "available": 116, "total": 116 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "BTS관 (7층 1관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "10:10", "end": "12:30", "available": 72, "total": 116 }, + { "start": "12:50", "end": "15:16", "available": 104, "total": 116 }, + { "start": "15:40", "end": "18:00", "available": 76, "total": 116 } + ] + }, + { + "auditorium": "BTS관 (9층 2관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "11:40", "end": "14:08", "available": 29, "total": 116 }, + { "start": "14:20", "end": "16:42", "available": 93, "total": 116 }, + { "start": "17:10", "end": "19:30", "available": 18, "total": 116 }, + { "start": "19:40", "end": "22:00", "available": 87, "total": 116 } + ] + } + ] + } + ] + } + ] + }, + { + "id": "m-002", + "title": "F1 더 무비", + "age_rating": "12", + "schedules": [ + { + "date": "2025-09-22", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "IMAX 1관", + "format": "IMAX", + "showtimes": [ + { "start": "10:00", "end": "12:15", "available": 45, "total": 50 }, + { "start": "13:30", "end": "15:45", "available": 12, "total": 50 }, + { "start": "17:00", "end": "19:15", "available": 8, "total": 50 }, + { "start": "20:30", "end": "22:45", "available": 35, "total": 50 } + ] + }, + { + "auditorium": "4DX 2관", + "format": "4DX", + "showtimes": [ + { "start": "11:15", "end": "13:30", "available": 28, "total": 40 }, + { "start": "15:45", "end": "18:00", "available": 5, "total": 40 }, + { "start": "19:15", "end": "21:30", "available": 22, "total": 40 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "IMAX 3관", + "format": "IMAX", + "showtimes": [ + { "start": "09:45", "end": "12:00", "available": 38, "total": 50 }, + { "start": "13:15", "end": "15:30", "available": 15, "total": 50 }, + { "start": "16:45", "end": "19:00", "available": 3, "total": 50 }, + { "start": "20:15", "end": "22:30", "available": 42, "total": 50 } + ] + } + ] + } + ] + }, + { + "date": "2025-09-23", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "IMAX 1관", + "format": "IMAX", + "showtimes": [ + { "start": "10:30", "end": "12:45", "available": 48, "total": 50 }, + { "start": "14:00", "end": "16:15", "available": 25, "total": 50 }, + { "start": "17:30", "end": "19:45", "available": 18, "total": 50 }, + { "start": "21:00", "end": "23:15", "available": 41, "total": 50 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "IMAX 3관", + "format": "IMAX", + "showtimes": [ + { "start": "10:00", "end": "12:15", "available": 33, "total": 50 }, + { "start": "13:30", "end": "15:45", "available": 7, "total": 50 }, + { "start": "17:00", "end": "19:15", "available": 12, "total": 50 }, + { "start": "20:30", "end": "22:45", "available": 39, "total": 50 } + ] + } + ] + } + ] + }, + { + "date": "2025-09-24", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "IMAX 1관", + "format": "IMAX", + "showtimes": [ + { "start": "09:30", "end": "11:45", "available": 42, "total": 50 }, + { "start": "12:45", "end": "15:00", "available": 18, "total": 50 }, + { "start": "15:30", "end": "17:45", "available": 6, "total": 50 }, + { "start": "18:15", "end": "20:30", "available": 29, "total": 50 }, + { "start": "21:00", "end": "23:15", "available": 44, "total": 50 } + ] + }, + { + "auditorium": "4DX 2관", + "format": "4DX", + "showtimes": [ + { "start": "10:30", "end": "12:45", "available": 15, "total": 40 }, + { "start": "14:00", "end": "16:15", "available": 3, "total": 40 }, + { "start": "17:30", "end": "19:45", "available": 1, "total": 40 }, + { "start": "20:00", "end": "22:15", "available": 25, "total": 40 } + ] + } + ] + } + ] + } + ] + }, + { + "id": "m-003", + "title": "귀멸의 칼날: 무한성", + "age_rating": "15", + "schedules": [ + { + "date": "2025-09-22", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "2D", + "format": "2D", + "showtimes": [ + { "start": "09:30", "end": "12:20", "available": 85, "total": 120 }, + { "start": "13:00", "end": "15:50", "available": 23, "total": 120 }, + { "start": "16:30", "end": "19:20", "available": 2, "total": 120 }, + { "start": "20:00", "end": "22:50", "available": 78, "total": 120 }, + { "start": "23:30", "end": "02:20", "available": 95, "total": 120 } + ] + }, + { + "auditorium": "4DX 3관", + "format": "4DX", + "showtimes": [ + { "start": "10:45", "end": "13:35", "available": 15, "total": 40 }, + { "start": "14:15", "end": "17:05", "available": 4, "total": 40 }, + { "start": "17:45", "end": "20:35", "available": 1, "total": 40 }, + { "start": "21:15", "end": "00:05", "available": 28, "total": 40 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "2D", + "format": "2D", + "showtimes": [ + { "start": "09:00", "end": "11:50", "available": 67, "total": 120 }, + { "start": "12:30", "end": "15:20", "available": 19, "total": 120 }, + { "start": "16:00", "end": "18:50", "available": 5, "total": 120 }, + { "start": "19:30", "end": "22:20", "available": 89, "total": 120 } + ] + }, + { + "auditorium": "Dolby Cinema 4관", + "format": "Dolby", + "showtimes": [ + { "start": "11:00", "end": "13:50", "available": 22, "total": 60 }, + { "start": "14:30", "end": "17:20", "available": 8, "total": 60 }, + { "start": "18:00", "end": "20:50", "available": 3, "total": 60 }, + { "start": "21:30", "end": "00:20", "available": 45, "total": 60 } + ] + } + ] + } + ] + }, + { + "date": "2025-09-23", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "2D", + "format": "2D", + "showtimes": [ + { "start": "09:45", "end": "12:35", "available": 92, "total": 120 }, + { "start": "13:15", "end": "16:05", "available": 31, "total": 120 }, + { "start": "16:45", "end": "19:35", "available": 7, "total": 120 }, + { "start": "20:15", "end": "23:05", "available": 84, "total": 120 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "2D", + "format": "2D", + "showtimes": [ + { "start": "09:30", "end": "12:20", "available": 74, "total": 120 }, + { "start": "13:00", "end": "15:50", "available": 26, "total": 120 }, + { "start": "16:30", "end": "19:20", "available": 11, "total": 120 }, + { "start": "20:00", "end": "22:50", "available": 96, "total": 120 } + ] + } + ] + } + ] + }, + { + "date": "2025-09-24", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "돌비시네마", + "format": "2D", + "showtimes": [ + { "start": "09:15", "end": "12:05", "available": 88, "total": 120 }, + { "start": "12:45", "end": "15:35", "available": 25, "total": 120 }, + { "start": "16:15", "end": "19:05", "available": 4, "total": 120 }, + { "start": "19:45", "end": "22:35", "available": 81, "total": 120 }, + { "start": "23:15", "end": "02:05", "available": 98, "total": 120 } + ] + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/Contents.json b/megabox/Resources/Assets.xcassets/menu/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/disney_pixar.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/disney_pixar.imageset/Contents.json new file mode 100644 index 0000000..265d998 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/disney_pixar.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "disney_pixar.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/disney_pixar.imageset/disney_pixar.pdf b/megabox/Resources/Assets.xcassets/menu/disney_pixar.imageset/disney_pixar.pdf new file mode 100644 index 0000000..38d96f7 Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/disney_pixar.imageset/disney_pixar.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/double_combo.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/double_combo.imageset/Contents.json new file mode 100644 index 0000000..1fe43a6 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/double_combo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "double_combo.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/double_combo.imageset/double_combo.pdf b/megabox/Resources/Assets.xcassets/menu/double_combo.imageset/double_combo.pdf new file mode 100644 index 0000000..8b727ff Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/double_combo.imageset/double_combo.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/family_combo_package.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/family_combo_package.imageset/Contents.json new file mode 100644 index 0000000..9d027ee --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/family_combo_package.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "family_combo_package.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/family_combo_package.imageset/family_combo_package.pdf b/megabox/Resources/Assets.xcassets/menu/family_combo_package.imageset/family_combo_package.pdf new file mode 100644 index 0000000..ba01bb6 Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/family_combo_package.imageset/family_combo_package.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/inside_out.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/inside_out.imageset/Contents.json new file mode 100644 index 0000000..8a09a83 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/inside_out.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "inside_out.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/inside_out.imageset/inside_out.pdf b/megabox/Resources/Assets.xcassets/menu/inside_out.imageset/inside_out.pdf new file mode 100644 index 0000000..7ad6232 Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/inside_out.imageset/inside_out.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/love_combo.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/love_combo.imageset/Contents.json new file mode 100644 index 0000000..bab69cf --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/love_combo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "love_combo.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/love_combo.imageset/love_combo.pdf b/megabox/Resources/Assets.xcassets/menu/love_combo.imageset/love_combo.pdf new file mode 100644 index 0000000..7b6d9ab Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/love_combo.imageset/love_combo.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/love_combo_package.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/love_combo_package.imageset/Contents.json new file mode 100644 index 0000000..496fe48 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/love_combo_package.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "love_combo_package.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/love_combo_package.imageset/love_combo_package.pdf b/megabox/Resources/Assets.xcassets/menu/love_combo_package.imageset/love_combo_package.pdf new file mode 100644 index 0000000..838ce54 Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/love_combo_package.imageset/love_combo_package.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/mappin.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/mappin.imageset/Contents.json new file mode 100644 index 0000000..4f75a04 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/mappin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mappin.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/mappin.imageset/mappin.pdf b/megabox/Resources/Assets.xcassets/menu/mappin.imageset/mappin.pdf new file mode 100644 index 0000000..3956b87 Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/mappin.imageset/mappin.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/single_combo.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/single_combo.imageset/Contents.json new file mode 100644 index 0000000..ddd76c1 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/single_combo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "single_combo.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/single_combo.imageset/single_combo.pdf b/megabox/Resources/Assets.xcassets/menu/single_combo.imageset/single_combo.pdf new file mode 100644 index 0000000..483cc2e Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/single_combo.imageset/single_combo.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/single_package.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/single_package.imageset/Contents.json new file mode 100644 index 0000000..4b98a4b --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/single_package.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "single_package.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/single_package.imageset/single_package.pdf b/megabox/Resources/Assets.xcassets/menu/single_package.imageset/single_package.pdf new file mode 100644 index 0000000..4420204 Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/single_package.imageset/single_package.pdf differ diff --git a/megabox/Resources/Assets.xcassets/menu/ticket_book.imageset/Contents.json b/megabox/Resources/Assets.xcassets/menu/ticket_book.imageset/Contents.json new file mode 100644 index 0000000..adefa74 --- /dev/null +++ b/megabox/Resources/Assets.xcassets/menu/ticket_book.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ticket_book.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/megabox/Resources/Assets.xcassets/menu/ticket_book.imageset/ticket_book.pdf b/megabox/Resources/Assets.xcassets/menu/ticket_book.imageset/ticket_book.pdf new file mode 100644 index 0000000..5702871 Binary files /dev/null and b/megabox/Resources/Assets.xcassets/menu/ticket_book.imageset/ticket_book.pdf differ diff --git a/megabox/Resources/MovieSchedule.json b/megabox/Resources/MovieSchedule.json new file mode 100644 index 0000000..35ce270 --- /dev/null +++ b/megabox/Resources/MovieSchedule.json @@ -0,0 +1,385 @@ +{ + "status": "success", + "message": "Showtimes fetched successfully", + "data": { + "movies": [ + { + "id": "m-001", + "title": "어쩔수가없다", + "age_rating": "15", + "schedules": [ + { + "date": "2025-10-30", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "크리클라이너 1관", + "format": "2D", + "showtimes": [ + { "start": "11:30", "end": "13:58", "available": 109, "total": 116 }, + { "start": "14:20", "end": "16:48", "available": 19, "total": 116 }, + { "start": "17:05", "end": "19:28", "available": 1, "total": 116 }, + { "start": "19:45", "end": "22:02", "available": 100, "total": 116 }, + { "start": "22:20", "end": "00:04", "available": 116, "total": 116 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "BTS관 (7층 1관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "09:30", "end": "11:50", "available": 75, "total": 116 }, + { "start": "12:00", "end": "14:26", "available": 102, "total": 116 }, + { "start": "14:45", "end": "17:04", "available": 88, "total": 116 } + ] + }, + { + "auditorium": "BTS관 (9층 2관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "11:30", "end": "13:58", "available": 34, "total": 116 }, + { "start": "14:10", "end": "16:32", "available": 100, "total": 116 }, + { "start": "16:50", "end": "19:00", "available": 13, "total": 116 }, + { "start": "19:20", "end": "21:40", "available": 92, "total": 116 } + ] + } + ] + } + ] + }, + { + "date": "2025-10-31", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "크리클라이너 1관", + "format": "2D", + "showtimes": [ + { "start": "10:30", "end": "12:58", "available": 112, "total": 116 }, + { "start": "13:40", "end": "16:08", "available": 54, "total": 116 }, + { "start": "16:20", "end": "18:48", "available": 22, "total": 116 }, + { "start": "19:30", "end": "21:58", "available": 97, "total": 116 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "BTS관 (7층 1관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "09:50", "end": "12:10", "available": 81, "total": 116 }, + { "start": "12:30", "end": "14:56", "available": 99, "total": 116 }, + { "start": "15:20", "end": "17:39", "available": 61, "total": 116 } + ] + }, + { + "auditorium": "BTS관 (9층 2관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "11:10", "end": "13:38", "available": 45, "total": 116 }, + { "start": "14:00", "end": "16:22", "available": 88, "total": 116 }, + { "start": "16:40", "end": "18:58", "available": 24, "total": 116 }, + { "start": "19:10", "end": "21:30", "available": 90, "total": 116 } + ] + } + ] + } + ] + }, + { + "date": "2025-11-01", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "크리클라이너 1관", + "format": "2D", + "showtimes": [ + { "start": "11:00", "end": "13:28", "available": 106, "total": 116 }, + { "start": "13:50", "end": "16:18", "available": 33, "total": 116 }, + { "start": "16:40", "end": "19:08", "available": 5, "total": 116 }, + { "start": "19:20", "end": "21:48", "available": 84, "total": 116 }, + { "start": "22:10", "end": "00:34", "available": 116, "total": 116 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "BTS관 (7층 1관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "10:10", "end": "12:30", "available": 72, "total": 116 }, + { "start": "12:50", "end": "15:16", "available": 104, "total": 116 }, + { "start": "15:40", "end": "18:00", "available": 76, "total": 116 } + ] + }, + { + "auditorium": "BTS관 (9층 2관 [Laser])", + "format": "2D", + "showtimes": [ + { "start": "11:40", "end": "14:08", "available": 29, "total": 116 }, + { "start": "14:20", "end": "16:42", "available": 93, "total": 116 }, + { "start": "17:10", "end": "19:30", "available": 18, "total": 116 }, + { "start": "19:40", "end": "22:00", "available": 87, "total": 116 } + ] + } + ] + } + ] + } + ] + }, + { + "id": "m-002", + "title": "F1 더 무비", + "age_rating": "12", + "schedules": [ + { + "date": "2025-10-30", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "IMAX 1관", + "format": "IMAX", + "showtimes": [ + { "start": "10:00", "end": "12:15", "available": 45, "total": 50 }, + { "start": "13:30", "end": "15:45", "available": 12, "total": 50 }, + { "start": "17:00", "end": "19:15", "available": 8, "total": 50 }, + { "start": "20:30", "end": "22:45", "available": 35, "total": 50 } + ] + }, + { + "auditorium": "4DX 2관", + "format": "4DX", + "showtimes": [ + { "start": "11:15", "end": "13:30", "available": 28, "total": 40 }, + { "start": "15:45", "end": "18:00", "available": 5, "total": 40 }, + { "start": "19:15", "end": "21:30", "available": 22, "total": 40 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "IMAX 3관", + "format": "IMAX", + "showtimes": [ + { "start": "09:45", "end": "12:00", "available": 38, "total": 50 }, + { "start": "13:15", "end": "15:30", "available": 15, "total": 50 }, + { "start": "16:45", "end": "19:00", "available": 3, "total": 50 }, + { "start": "20:15", "end": "22:30", "available": 42, "total": 50 } + ] + } + ] + } + ] + }, + { + "date": "2025-10-31", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "IMAX 1관", + "format": "IMAX", + "showtimes": [ + { "start": "10:30", "end": "12:45", "available": 48, "total": 50 }, + { "start": "14:00", "end": "16:15", "available": 25, "total": 50 }, + { "start": "17:30", "end": "19:45", "available": 18, "total": 50 }, + { "start": "21:00", "end": "23:15", "available": 41, "total": 50 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "IMAX 3관", + "format": "IMAX", + "showtimes": [ + { "start": "10:00", "end": "12:15", "available": 33, "total": 50 }, + { "start": "13:30", "end": "15:45", "available": 7, "total": 50 }, + { "start": "17:00", "end": "19:15", "available": 12, "total": 50 }, + { "start": "20:30", "end": "22:45", "available": 39, "total": 50 } + ] + } + ] + } + ] + }, + { + "date": "2025-11-01", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "IMAX 1관", + "format": "IMAX", + "showtimes": [ + { "start": "09:30", "end": "11:45", "available": 42, "total": 50 }, + { "start": "12:45", "end": "15:00", "available": 18, "total": 50 }, + { "start": "15:30", "end": "17:45", "available": 6, "total": 50 }, + { "start": "18:15", "end": "20:30", "available": 29, "total": 50 }, + { "start": "21:00", "end": "23:15", "available": 44, "total": 50 } + ] + }, + { + "auditorium": "4DX 2관", + "format": "4DX", + "showtimes": [ + { "start": "10:30", "end": "12:45", "available": 15, "total": 40 }, + { "start": "14:00", "end": "16:15", "available": 3, "total": 40 }, + { "start": "17:30", "end": "19:45", "available": 1, "total": 40 }, + { "start": "20:00", "end": "22:15", "available": 25, "total": 40 } + ] + } + ] + } + ] + } + ] + }, + { + "id": "m-003", + "title": "귀멸의 칼날: 무한성", + "age_rating": "15", + "schedules": [ + { + "date": "2025-10-30", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "2D", + "format": "2D", + "showtimes": [ + { "start": "09:30", "end": "12:20", "available": 85, "total": 120 }, + { "start": "13:00", "end": "15:50", "available": 23, "total": 120 }, + { "start": "16:30", "end": "19:20", "available": 2, "total": 120 }, + { "start": "20:00", "end": "22:50", "available": 78, "total": 120 }, + { "start": "23:30", "end": "02:20", "available": 95, "total": 120 } + ] + }, + { + "auditorium": "4DX 3관", + "format": "4DX", + "showtimes": [ + { "start": "10:45", "end": "13:35", "available": 15, "total": 40 }, + { "start": "14:15", "end": "17:05", "available": 4, "total": 40 }, + { "start": "17:45", "end": "20:35", "available": 1, "total": 40 }, + { "start": "21:15", "end": "00:05", "available": 28, "total": 40 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "2D", + "format": "2D", + "showtimes": [ + { "start": "09:00", "end": "11:50", "available": 67, "total": 120 }, + { "start": "12:30", "end": "15:20", "available": 19, "total": 120 }, + { "start": "16:00", "end": "18:50", "available": 5, "total": 120 }, + { "start": "19:30", "end": "22:20", "available": 89, "total": 120 } + ] + }, + { + "auditorium": "Dolby Cinema 4관", + "format": "Dolby", + "showtimes": [ + { "start": "11:00", "end": "13:50", "available": 22, "total": 60 }, + { "start": "14:30", "end": "17:20", "available": 8, "total": 60 }, + { "start": "18:00", "end": "20:50", "available": 3, "total": 60 }, + { "start": "21:30", "end": "00:20", "available": 45, "total": 60 } + ] + } + ] + } + ] + }, + { + "date": "2025-10-31", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "2D", + "format": "2D", + "showtimes": [ + { "start": "09:45", "end": "12:35", "available": 92, "total": 120 }, + { "start": "13:15", "end": "16:05", "available": 31, "total": 120 }, + { "start": "16:45", "end": "19:35", "available": 7, "total": 120 }, + { "start": "20:15", "end": "23:05", "available": 84, "total": 120 } + ] + } + ] + }, + { + "area": "홍대", + "items": [ + { + "auditorium": "2D", + "format": "2D", + "showtimes": [ + { "start": "09:30", "end": "12:20", "available": 74, "total": 120 }, + { "start": "13:00", "end": "15:50", "available": 26, "total": 120 }, + { "start": "16:30", "end": "19:20", "available": 11, "total": 120 }, + { "start": "20:00", "end": "22:50", "available": 96, "total": 120 } + ] + } + ] + } + ] + }, + { + "date": "2025-11-01", + "areas": [ + { + "area": "강남", + "items": [ + { + "auditorium": "돌비시네마", + "format": "2D", + "showtimes": [ + { "start": "09:15", "end": "12:05", "available": 88, "total": 120 }, + { "start": "12:45", "end": "15:35", "available": 25, "total": 120 }, + { "start": "16:15", "end": "19:05", "available": 4, "total": 120 }, + { "start": "19:45", "end": "22:35", "available": 81, "total": 120 }, + { "start": "23:15", "end": "02:05", "available": 98, "total": 120 } + ] + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/megabox/Sources/Common/Kakao/KakaoDTO.swift b/megabox/Sources/Common/Kakao/KakaoDTO.swift new file mode 100644 index 0000000..7c019f9 --- /dev/null +++ b/megabox/Sources/Common/Kakao/KakaoDTO.swift @@ -0,0 +1,28 @@ +import Foundation + +// MARK: - 토큰 응답 DTO +struct KakaoTokenResponse: Codable { + let access_token: String + let token_type: String + let refresh_token: String? + let expires_in: Int + let scope: String? + let refresh_token_expires_in: Int? +} + +// MARK: - 유저 정보 응답 DTO +struct KakaoUserResponse: Codable { + let id: Int + let connected_at: String? + let kakao_account: KakaoAccount? +} + +struct KakaoAccount: Codable { + let profile: KakaoProfile? +} + +struct KakaoProfile: Codable { + let nickname: String? + let thumbnail_image_url: String? + let profile_image_url: String? +} diff --git a/megabox/Sources/Common/Kakao/KakaoLoginServiceManager.swift b/megabox/Sources/Common/Kakao/KakaoLoginServiceManager.swift new file mode 100644 index 0000000..42e5add --- /dev/null +++ b/megabox/Sources/Common/Kakao/KakaoLoginServiceManager.swift @@ -0,0 +1,93 @@ +import Foundation +import Alamofire +import SwiftUI + +final class KaKaoServiceManager : ObservableObject { + + static let shared = KaKaoServiceManager() + + @Published var kakaoNickname: String = "" + @Published var isLoggedIn: Bool = false + + private let session: Session + //kakao 인가코드 url (GET) + private let authKaKaoUrl = "https://kauth.kakao.com/oauth/authorize"; + //kakao 토큰발급 요청 url (POST) + private let tokenKaKaoUrl = "https://kauth.kakao.com/oauth/token"; + //kakao 유저 정보 요청 + private let userInfoKaKaoUrl = "https://kapi.kakao.com/v2/user/me"; + + init() { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 10 + + self.session = Session(configuration: configuration) + } + + // MARK: - 카카오 로그인 시작 (웹 인증 GET 요청) + func requestKakaoAuthCode() { + let appKey = Bundle.main.infoDictionary?["KAKAO_NATIVE_APP_KEY"] as? String ?? "" + let redirectURI = "kakao\(appKey)://oauth" + let authURL = "\(authKaKaoUrl)?client_id=\(appKey)&redirect_uri=\(redirectURI)&response_type=code" + + if let url = URL(string: authURL) { + UIApplication.shared.open(url) + } + } + + // MARK: - 인증 코드 → Access Token 요청 + func requestAccessToken(authCode: String, completion: @escaping (String?) -> Void) { + let appKey = Bundle.main.infoDictionary?["KAKAO_NATIVE_APP_KEY"] as? String ?? "" + let redirectURI = "kakao\(appKey)://oauth" + let url = "\(tokenKaKaoUrl)" + + let parameters: [String: String] = [ + "grant_type": "authorization_code", + "client_id": appKey, + "redirect_uri": redirectURI, + "code": authCode + ] + AF.request(url, method: .post, parameters: parameters) + .responseDecodable(of: KakaoTokenResponse.self) { response in + switch response.result { + case .success(let token): + print("Access Token: \(token.access_token)") + completion(token.access_token) + case .failure(let error): + print("Token Error: \(error.localizedDescription)") + completion(nil) + } + } + + } + + // MARK: - 사용자 정보 요청 + func requestUserInfo(accessToken: String, router: NavigationRouter) { + let url = "\(userInfoKaKaoUrl)" + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(accessToken)", + "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" + ] + + + AF.request(url, method: .get, headers: headers) + .responseDecodable(of: KakaoUserResponse.self) { response in + switch response.result { + case .success(let user): + DispatchQueue.main.async { + self.kakaoNickname = user.kakao_account?.profile?.nickname ?? "비회원" + print("Kakao User: \(self.kakaoNickname)") + + KeychainHelper.shared.save(self.kakaoNickname, forKey: "savedName") + self.isLoggedIn = true + router.push(.mainTab) + } + + case .failure(let error): + print("User Info Error: \(error.localizedDescription)") + } + } + + } +} + diff --git a/megabox/Sources/ContentView.swift b/megabox/Sources/ContentView.swift index 1303e95..8ed9a8c 100644 --- a/megabox/Sources/ContentView.swift +++ b/megabox/Sources/ContentView.swift @@ -2,26 +2,48 @@ import SwiftUI public struct ContentView: View { @StateObject private var router = NavigationRouter() // 라우터 인스턴스 생성 + @State private var isLoggedIn = false // 로그인 상태 추적 public var body: some View { - NavigationStack(path: $router.path) { - MainTabView() - .navigationDestination(for: Route.self) { route in - switch route { - case .home: - HomeView() - case .profile: - ProfileView() - case .memberInfo: - MemberInfoView() - case .mainTab: + Group { + if isLoggedIn { + // 로그인된 상태면 메인 탭 + NavigationStack(path: $router.path) { MainTabView() - case .login: - LoginView() + .navigationDestination(for: Route.self) { route in + switch route { + case .home: + HomeView() + case .profile: + ProfileView() + case .memberInfo: + MemberInfoView() + case .mainTab: + MainTabView() + case .login: + LoginView() + case .directOrderDetail: + DirectOrderDetailView() + } + } } + .environmentObject(router) + } else { + // 로그인 안 되어 있으면 로그인 화면 + LoginView() + .environmentObject(router) + } + } + .onAppear { + // Keychain에서 로그인 정보 확인 + if let id = KeychainHelper.shared.read(forKey: "savedId"), !id.isEmpty { + print("로그인 유지: \(id)") + isLoggedIn = true + } else { + print("로그인 필요") + isLoggedIn = false } } - .environmentObject(router) // Observation 기반 환경 주입 } } diff --git a/megabox/Sources/Home/Views/MainTabView.swift b/megabox/Sources/Home/Views/MainTabView.swift index f6ca8d1..8d02322 100644 --- a/megabox/Sources/Home/Views/MainTabView.swift +++ b/megabox/Sources/Home/Views/MainTabView.swift @@ -17,7 +17,7 @@ struct MainTabView: View { Tab("모바일 오더", systemImage: "ticket.fill", value: 2) { - HomeView() + OrderView() } diff --git a/megabox/Sources/Login/ViewModels/LoginViewModel.swift b/megabox/Sources/Login/ViewModels/LoginViewModel.swift index 2748dbb..9828168 100644 --- a/megabox/Sources/Login/ViewModels/LoginViewModel.swift +++ b/megabox/Sources/Login/ViewModels/LoginViewModel.swift @@ -1,16 +1,35 @@ -// -// LoginViewModel.swift -// megabox -// -// Created by 김세은 on 9/27/25. -// import Foundation +import KakaoSDKAuth +import KakaoSDKUser class LoginViewModel: ObservableObject { @Published var loginModel: LoginModel + private let kakaoManager = KaKaoServiceManager.shared + init(loginModel: LoginModel) { self.loginModel = loginModel } + // MARK: - 카카오 로그인 시작 + func loginWithKakao(router: NavigationRouter) { + kakaoManager.requestKakaoAuthCode() + } + + // MARK: - 인증 후 AccessToken 처리 (onOpenURL에서 호출) + func handleKakaoAuthCallback(url: URL, router: NavigationRouter) { + if let code = extractAuthCode(from: url) { + kakaoManager.requestAccessToken(authCode: code) { token in + if let token = token { + self.kakaoManager.requestUserInfo(accessToken: token, router: router) + } + } + } + } + + private func extractAuthCode(from url: URL) -> String? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { return nil } + return queryItems.first(where: { $0.name == "code" })?.value + } } diff --git a/megabox/Sources/Login/Views/LoginView.swift b/megabox/Sources/Login/Views/LoginView.swift index bb8ca0f..ad61578 100644 --- a/megabox/Sources/Login/Views/LoginView.swift +++ b/megabox/Sources/Login/Views/LoginView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import KakaoSDKUser +import KakaoSDKAuth struct LoginView: View { @@ -48,6 +50,22 @@ struct LoginView: View { } .padding(.horizontal, 16) // 좌우 여백 } +// .onAppear(){ +// if AuthApi.hasToken() { +// UserApi.shared.accessTokenInfo { info, error in +// if let error = error { +// print("❌ Token invalid:", error) +// } else { +// print("✅ Token valid, expires in:", info?.expiresIn ?? 0) +// } +// } +// } else { +// print("❌ No Kakao token found") +// } +// } + .onOpenURL { url in + loginViewModel.handleKakaoAuthCallback(url: url, router: router) + } } @@ -83,9 +101,16 @@ struct LoginView: View { Button(action: { // print("현재 입력된 아이디:", loginViewModel.loginModel.id) // print("현재 입력된 비번:", loginViewModel.loginModel.pwd) - savedId = loginViewModel.loginModel.id - savedPwd = loginViewModel.loginModel.pwd - savedName = loginViewModel.loginModel.id + "_init" + + let id = loginViewModel.loginModel.id + let pwd = loginViewModel.loginModel.pwd + let name = id + "_init" + + // Keychain 저장 + KeychainHelper.shared.save(id, forKey: "savedId") + KeychainHelper.shared.save(pwd, forKey: "savedPwd") + KeychainHelper.shared.save(name, forKey: "savedName") + router.push(.mainTab) // print("저장 완료 - id:\(savedId), name:\(savedName)") @@ -114,7 +139,10 @@ struct LoginView: View { Spacer() - Button(action: {}) { + Button(action: { + loginViewModel.loginWithKakao(router: router) + + }) { Image("kakao") } diff --git a/megabox/Sources/MegaboxApp.swift b/megabox/Sources/MegaboxApp.swift index 3d17acb..b22ceae 100644 --- a/megabox/Sources/MegaboxApp.swift +++ b/megabox/Sources/MegaboxApp.swift @@ -1,7 +1,20 @@ import SwiftUI +import KakaoSDKCommon +import KakaoSDKAuth @main struct MegaboxApp: App { + + init() { + // Kakao SDK 초기화 + if let appKey = Bundle.main.infoDictionary?["KAKAO_NATIVE_APP_KEY"] as? String { + KakaoSDK.initSDK(appKey: appKey) +// print("Kakao SDK initialized with key: \(appKey)") + } else { +// print("Kakao SDK 초기화 실패: 앱 키를 찾을 수 없음") + } + } + var body: some Scene { WindowGroup { ContentView() diff --git a/megabox/Sources/MovieBooking/DTO/MovieScheduleDTO.swift b/megabox/Sources/MovieBooking/DTO/MovieScheduleDTO.swift new file mode 100644 index 0000000..57e21ce --- /dev/null +++ b/megabox/Sources/MovieBooking/DTO/MovieScheduleDTO.swift @@ -0,0 +1,76 @@ +import Foundation + +struct MovieScheduleResponseDTO: Decodable { + let status: String + let message: String + let data: MovieScheduleDataDTO +} + +struct MovieScheduleDataDTO: Decodable { + let movies: [MovieDTO] +} + +struct MovieDTO: Decodable { + let id: String + let title: String + let ageRating: String + let schedules: [ScheduleDTO] + + enum CodingKeys: String, CodingKey { + case id, title + case ageRating = "age_rating" + case schedules + } +} + +struct ScheduleDTO: Decodable { + let date: String + let areas: [AreaDTO] +} + +struct AreaDTO: Decodable { + let area: String + let items: [ItemDTO] +} + +struct ItemDTO: Decodable { + let auditorium: String + let format: String + let showtimes: [ShowtimeDTO] +} + +struct ShowtimeDTO: Decodable { + let start: String + let end: String + let available: Int + let total: Int +} + +extension MovieDTO { + func toTheaterShowtimes(for date: Date, theaters selectedTheaters: [String]) -> [TheaterShowtimes] { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let dateString = formatter.string(from: date) + + // 선택한 날짜의 일정만 찾기 + guard let schedule = schedules.first(where: { $0.date == dateString }) else { return [] } + + // 선택된 지역(극장)만 필터링 + let filteredAreas = schedule.areas.filter { selectedTheaters.contains($0.area) } + + return filteredAreas.map { area in + TheaterShowtimes( + branch: area.area, + rooms: area.items.map { item in + RoomShowtimes( + name: item.auditorium, + format: item.format, + times: item.showtimes.map { show in + Showtime(start: show.start, end: show.end, reserved: show.total - show.available, total: show.total) + } + ) + } + ) + } + } +} diff --git a/megabox/Sources/MovieBooking/Models/MovieBookingModel.swift b/megabox/Sources/MovieBooking/Models/MovieBookingModel.swift index 5db218a..5b3e9be 100644 --- a/megabox/Sources/MovieBooking/Models/MovieBookingModel.swift +++ b/megabox/Sources/MovieBooking/Models/MovieBookingModel.swift @@ -6,3 +6,24 @@ struct MovieBooking: Identifiable { let imageName: String let ageRating: Int? } + +struct Showtime: Identifiable, Hashable { + let id = UUID() + let start: String + let end: String + let reserved: Int + let total: Int +} + +struct RoomShowtimes: Identifiable, Hashable { + let id = UUID() + let name: String // 예: "리클라이너 1관", "BTS관 (7층 1관 [Laser])" + let format: String? // 예: "2D", "3D", "4DX" 등 (필요 없으면 nil) + let times: [Showtime] +} + +struct TheaterShowtimes: Identifiable, Hashable { + let id = UUID() + let branch: String // 예: "강남", "홍대" + let rooms: [RoomShowtimes] +} diff --git a/megabox/Sources/MovieBooking/ViewModels/MovieBookingViewModel.swift b/megabox/Sources/MovieBooking/ViewModels/MovieBookingViewModel.swift index 5517b99..c2d52b5 100644 --- a/megabox/Sources/MovieBooking/ViewModels/MovieBookingViewModel.swift +++ b/megabox/Sources/MovieBooking/ViewModels/MovieBookingViewModel.swift @@ -7,14 +7,22 @@ final class MovieBookingViewModel: ObservableObject { @Published var selectedMovie: MovieBooking? = nil @Published var selectedTheaters: [String] = [] @Published var selectedDate: Date? = nil + @Published var availableDates: [Date] = [] + var canShowDates: Bool { selectedMovie != nil && !selectedTheaters.isEmpty } + @Published var showtimeGroups: [TheaterShowtimes] = [] + @Published var canSelectTheater: Bool = false + var canShowTimes: Bool { canShowDates && selectedDate != nil } + + private let calendar = Calendar.current + private let weekdaySymbolsKOR = ["일", "월", "화", "수", "목", "금", "토"] private var cancellables = Set() init() { movies = [ MovieBooking(title: "F1 더 무비", imageName: "poster_f1", ageRating: 12), - MovieBooking(title: "극장판 귀멸의 칼날", imageName: "poster_demonslayer", ageRating: 15), + MovieBooking(title: "귀멸의 칼날: 무한성", imageName: "poster_demonslayer", ageRating: 15), MovieBooking(title: "어쩔수가없다", imageName: "poster_noway", ageRating: 15), MovieBooking(title: "얼굴", imageName: "poster_face", ageRating: 15), MovieBooking(title: "모노노케 히메", imageName: "poster_princess", ageRating: 15), @@ -24,8 +32,10 @@ final class MovieBookingViewModel: ObservableObject { $selectedMovie .map { $0 != nil } // 영화가 있으면 true, 없으면 false .assign(to: &$canSelectTheater) // 자동으로 canSelectTheater 업데이트 + + regenerateWeek() } - + func selectMovie(_ movie: MovieBooking) { selectedMovie = movie selectedTheaters = [] // 영화 변경 시 극장 초기화 @@ -47,7 +57,7 @@ final class MovieBookingViewModel: ObservableObject { } return "\(age)" } - + func getAgeRatingColor(for movie: MovieBooking?) -> Color { guard let age = movie?.ageRating else { return .green } @@ -58,5 +68,86 @@ final class MovieBookingViewModel: ObservableObject { default: return .gray } } + + + func regenerateWeek(startingFrom date: Date = Date()) { + let start = calendar.startOfDay(for: date) + availableDates = (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: start) } + // 선택 초기화(옵션) + if let sel = selectedDate, !availableDates.contains(where: { calendar.isDate($0, inSameDayAs: sel) }) { + selectedDate = nil + } + } + + // 셀 라벨들 + func weekday(_ date: Date) -> String { + let w = calendar.component(.weekday, from: date) // 1=일 + return weekdaySymbolsKOR[w-1] + } + func day(_ date: Date) -> String { + let d = calendar.component(.day, from: date) + return String(d) + } + func isToday(_ date: Date) -> Bool { calendar.isDateInToday(date) } + func isSameDay(_ a: Date?, _ b: Date) -> Bool { + guard let a else { return false } + return calendar.isDate(a, inSameDayAs: b) + } + + // "오늘/내일/요일" 캡션 + func dayCaption(_ date: Date) -> String { + if calendar.isDateInToday(date) { return "오늘" } + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: Date()), + calendar.isDate(date, inSameDayAs: tomorrow) { return "내일" } + return weekday(date) // "일"~"토" + } + + // 주말 판별 + func weekendColor(for date: Date, selected: Bool) -> Color { + if selected { return .white } // 선택 시는 전부 흰색 + let w = calendar.component(.weekday, from: date) // 1=일, 7=토 + if w == 1 { return .red } // 일 + if w == 7 { return .teal } // 토 (시스템 컬러) + return .black // 평일 + } + + func fetchShowtimes(for movie: MovieBooking, theaters: [String], date: Date) async { +// isLoading = true +// defer { isLoading = false } // 끝나면 자동으로 false + + // 1️⃣ 번들에서 JSON 파일 읽기 + guard let url = Bundle.main.url(forResource: "MovieSchedule", withExtension: "json"), + let data = try? Data(contentsOf: url) else { + print("파일을 찾을 수 없습니다.") + showtimeGroups = [] + return + } + + do { + + // JSON 디코딩 (DTO 구조 사용) + let decoded = try JSONDecoder().decode(MovieScheduleResponseDTO.self, from: data) + print("JSON decode 성공. 영화 개수:", decoded.data.movies.count) + // 해당 영화 데이터 찾기 + guard let movieDTO = decoded.data.movies.first(where: { $0.title == movie.title }) else { + print("해당 영화 데이터 없음: \(movie.title)") + showtimeGroups = [] + return + } + + // DTO → Domain 매핑 + let mapped = movieDTO.toTheaterShowtimes(for: date, theaters: theaters) + print("변환된 상영 데이터 개수:", mapped.count) + // View 반영 + showtimeGroups = mapped + + } catch { + print("디코딩 에러:", error) + showtimeGroups = [] + } + } + + + } diff --git a/megabox/Sources/MovieBooking/Views/MovieBookingView.swift b/megabox/Sources/MovieBooking/Views/MovieBookingView.swift index 8d592cc..2cbec74 100644 --- a/megabox/Sources/MovieBooking/Views/MovieBookingView.swift +++ b/megabox/Sources/MovieBooking/Views/MovieBookingView.swift @@ -9,7 +9,17 @@ struct MovieBookingView: View { movieSection .padding(.bottom, 10) theaterSection + + if viewModel.canShowDates { + dateWeekSection + } + + if viewModel.canShowTimes { + showtimeSection + } + Spacer() + } .padding(16) .background(Color.white) @@ -126,6 +136,123 @@ struct MovieBookingView: View { } .frame(maxWidth: .infinity, alignment: .leading) } + + private var dateWeekSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("날짜 선택") + .font(.semiBold16) + .foregroundColor(.black) + + // 7열 고정 (한 줄) + let cols = Array(repeating: GridItem(.flexible(), spacing: 6), count: 7) + + LazyVGrid(columns: cols, spacing: 0) { + ForEach(Array(viewModel.availableDates.enumerated()), id: \.offset) { idx, date in + let selected = viewModel.isSameDay(viewModel.selectedDate, date) + + Button { + viewModel.selectedDate = date + + Task { + if let movie = viewModel.selectedMovie, + !viewModel.selectedTheaters.isEmpty, + let date = viewModel.selectedDate { + await viewModel.fetchShowtimes(for: movie, theaters: viewModel.selectedTheaters, date: date) + } + } + } label: { + VStack(spacing: 4) { + Text(viewModel.day(date)) + .font(.semiBold16) + .foregroundColor(selected ? .white + : viewModel.weekendColor(for: date, selected: false)) + Text(viewModel.dayCaption(date)) // "오늘/내일/요일" + .font(.medium13) + .foregroundColor(selected ? .white : Color("gray05")) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(selected ? Color("purple03") : .clear) + .cornerRadius(12) + } + .disabled(!viewModel.canShowDates) + } + } + } + } + + private var showtimeSection: some View { + let groups: [TheaterShowtimes] = viewModel.showtimeGroups + + return VStack(alignment: .leading, spacing: 12) { + ForEach(groups, id: \.id) { theater in + TheaterBlock(theater: theater) + } + } + } + + private struct TheaterBlock: View { + let theater: TheaterShowtimes + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // 극장명: 항상 왼쪽 정렬 + Text(theater.branch) + .font(.bold18) + .foregroundColor(.black) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 8) + + // 상영관/시간 유무에 따른 분기 + if theater.rooms.isEmpty { + Text("상영 정보가 없습니다") + .font(.medium14) + .foregroundColor(Color("gray05")) + .padding(.leading, 8) + } else { + ForEach(theater.rooms, id: \.id) { room in + RoomRow(room: room) + } + } + } + .padding(.bottom, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private struct RoomRow: View { + let room: RoomShowtimes + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(room.name) + .font(.semiBold14) + + if let format = room.format { + Text(format) + .font(.medium08) + .foregroundColor(.gray) + } + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(room.times, id: \.id) { time in + Text(time.start) + .font(.semiBold14) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color("gray01")) + .cornerRadius(8) + } + } + } + } + } + } + + } #Preview { @@ -134,9 +261,9 @@ struct MovieBookingView: View { #Preview("iPhone 11") { - HomeView() + MovieBookingView() } #Preview("iPhone 16 Pro Max") { - HomeView() + MovieBookingView() } diff --git a/megabox/Sources/Order/Models/OrderModel.swift b/megabox/Sources/Order/Models/OrderModel.swift new file mode 100644 index 0000000..aa1e8b6 --- /dev/null +++ b/megabox/Sources/Order/Models/OrderModel.swift @@ -0,0 +1,25 @@ +import Foundation + +struct MenuItem: Identifiable { + let id = UUID() + let name: String + let price: Int + let imageName: String + let isBest: Bool + let isRecommended: Bool + let isSoldOut: Bool + let discountRate: Int? // 할인율 (예: 10, 20 등) + let originalPrice: Int? // 원래 가격 (할인이 있는 경우) + + init(name: String, price: Int, imageName: String, isBest: Bool = false, isRecommended: Bool = false, isSoldOut: Bool = false, discountRate: Int? = nil, originalPrice: Int? = nil) { + self.name = name + self.price = price + self.imageName = imageName + self.isBest = isBest + self.isRecommended = isRecommended + self.isSoldOut = isSoldOut + self.discountRate = discountRate + self.originalPrice = originalPrice + } +} + diff --git a/megabox/Sources/Order/ViewModels/OrderViewModel.swift b/megabox/Sources/Order/ViewModels/OrderViewModel.swift new file mode 100644 index 0000000..d544daa --- /dev/null +++ b/megabox/Sources/Order/ViewModels/OrderViewModel.swift @@ -0,0 +1,28 @@ +import SwiftUI + +@Observable +class OrderViewModel { + var recommendedMenus: [MenuItem] = [ + MenuItem(name: "러브 콤보", price: 10900, imageName: "love_combo"), + MenuItem(name: "더블 콤보", price: 24900, imageName: "double_combo"), + MenuItem(name: "디즈니 픽사 포스터", price: 15900, imageName: "disney_pixar") + ] + + var bestMenus: [MenuItem] = [ + MenuItem(name: "싱글 패키지", price: 8900, imageName: "single_package"), + MenuItem(name: "더블 콤보", price: 24900, imageName: "double_combo"), + MenuItem(name: "러브 콤보 패키지", price: 32000, imageName: "love_combo_package") + ] + + var allMenus: [MenuItem] = [ + MenuItem(name: "싱글 콤보", price: 10900, imageName: "single_combo", isBest: true), + MenuItem(name: "러브 콤보", price: 10900, imageName: "love_combo", isBest: true), + MenuItem(name: "더블 콤보", price: 24900, imageName: "double_combo", isBest: true), + MenuItem(name: "러브 콤보 패키지", price: 32000, imageName: "love_combo_package"), + MenuItem(name: "패밀리 콤보 패키지", price: 47000, imageName: "family_combo_package"), + MenuItem(name: "메가박스 오리지널 티켓북 시즌4 돌비", price: 10900, imageName: "ticket_book", isRecommended: true), + MenuItem(name: "디즈니 픽사 포스터", price: 15900, imageName: "disney_pixar", isSoldOut: true), + MenuItem(name: "인사이드아웃2 감정", price: 29900, imageName: "inside_out", originalPrice: 35900) + ] +} + diff --git a/megabox/Sources/Order/Views/BestMenuSectionView.swift b/megabox/Sources/Order/Views/BestMenuSectionView.swift new file mode 100644 index 0000000..df30605 --- /dev/null +++ b/megabox/Sources/Order/Views/BestMenuSectionView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct BestMenuSectionView: View { + let menus: [MenuItem] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("베스트 메뉴") + .font(.bold22) + .foregroundStyle(.black) + + Text("영화 볼때 뭐먹지 고민될 때 베스트 메뉴!") + .font(.regular12) + .foregroundStyle(Color("gray04")) + } + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 15) { + ForEach(menus) { menu in + MenuItemCardView(menuItem: menu) + } + } + } + } + } +} + +#Preview { + BestMenuSectionView(menus: [ + MenuItem(name: "싱글 패키지", price: 8900, imageName: "single_package"), + MenuItem(name: "러브 콤보 패키지", price: 19900, imageName: "love_combo_package"), + MenuItem(name: "더블 패키지", price: 22900, imageName: "double_package") + ]) + .padding() +} + diff --git a/megabox/Sources/Order/Views/DeliverySectionView.swift b/megabox/Sources/Order/Views/DeliverySectionView.swift new file mode 100644 index 0000000..426d27f --- /dev/null +++ b/megabox/Sources/Order/Views/DeliverySectionView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct DeliverySectionView: View { + var body: some View { + EmptyView() + .modifier(HorizontalLayoutStyle( + title: "어디서든 팝콘 만나기", + description: "팝콘 콜라 스낵 모든 메뉴 배달 가능!", + iconName: "moped", + iconSize: 50, + iconBackgroundColor: nil, + titleFont: .bold22 + )) + } +} + +#Preview { + DeliverySectionView() +} + diff --git a/megabox/Sources/Order/Views/DirectOrderDetailView.swift b/megabox/Sources/Order/Views/DirectOrderDetailView.swift new file mode 100644 index 0000000..ff02082 --- /dev/null +++ b/megabox/Sources/Order/Views/DirectOrderDetailView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +struct DirectOrderDetailView: View { + @EnvironmentObject var router: NavigationRouter + @State private var orderViewModel = OrderViewModel() + @State private var selectedTheater: String = "강남" + + var body: some View { + ZStack { + Color.white + .ignoresSafeArea() + + VStack(spacing: 0) { + // 상단 헤더 + headerView + + ScrollView { + VStack(spacing: 16) { + // 메뉴 그리드 + menuGridSection + .padding(.top, 16) + .padding(.bottom, 32) + } + .padding(.horizontal, 16) + } + } + } + .navigationBarBackButtonHidden(true) + } + + private var headerView: some View { + VStack(spacing: 0) { + // 상단 바 (뒤로가기, 타이틀, 장바구니) + HStack { + // 뒤로가기 버튼 + Button { + router.pop() + } label: { + Image(systemName: "chevron.left") + .foregroundStyle(.black) + .font(.system(size: 18, weight: .medium)) + } + + // 타이틀 + Text("바로주문") + .font(.semiBold18) + .foregroundStyle(.black) + + Spacer() + + // 장바구니 아이콘 + Button { + // 장바구니 액션 + } label: { + Image(systemName: "cart") + .foregroundStyle(.black) + .font(.system(size: 20)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white) + + // 극장 선택 바 (흰색 배경) + TheaterChangeBarView(selectedTheater: selectedTheater, onChangeTheater: { + // 극장 변경 액션 + }, style: .white) + + // 구분선 + Divider() + .background(Color("gray02")) + } + } + + private var menuGridSection: some View { + let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + return LazyVGrid(columns: columns, spacing: 20) { + ForEach(orderViewModel.allMenus) { menu in + MenuItemCardView(menuItem: menu) + } + } + } +} + +#Preview { + NavigationStack { + DirectOrderDetailView() + .environmentObject(NavigationRouter()) + } +} + diff --git a/megabox/Sources/Order/Views/DirectOrderSectionView.swift b/megabox/Sources/Order/Views/DirectOrderSectionView.swift new file mode 100644 index 0000000..fd8bb5e --- /dev/null +++ b/megabox/Sources/Order/Views/DirectOrderSectionView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct DirectOrderSectionView: View { + @EnvironmentObject var router: NavigationRouter + + var body: some View { + Button { + router.push(.directOrderDetail) + } label: { + EmptyView() + .modifier(TopLeftTextLayoutStyle( + title: "바로 주문", + description: "이제 줄서지 말고\n모바일로 주문하고 픽업!", + iconName: "popcorn", + iconSize: 50 + )) + } + } +} + +#Preview { + DirectOrderSectionView() + .padding() +} diff --git a/megabox/Sources/Order/Views/GiftCardView.swift b/megabox/Sources/Order/Views/GiftCardView.swift new file mode 100644 index 0000000..db7f083 --- /dev/null +++ b/megabox/Sources/Order/Views/GiftCardView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct GiftCardView: View { + var body: some View { + EmptyView() + .modifier(TopLeftTextLayoutStyle( + title: "선물하기", + description: nil, + iconName: "gift", + iconSize: 50, + titleFont: .bold22 + )) + } +} + +#Preview { + GiftCardView() + .padding() +} + diff --git a/megabox/Sources/Order/Views/MenuItemCardModifiers.swift b/megabox/Sources/Order/Views/MenuItemCardModifiers.swift new file mode 100644 index 0000000..8f0da34 --- /dev/null +++ b/megabox/Sources/Order/Views/MenuItemCardModifiers.swift @@ -0,0 +1,94 @@ +import SwiftUI + +// BEST 배지 ViewModifier +struct BestBadgeModifier: ViewModifier { + let isBest: Bool + + func body(content: Content) -> some View { + ZStack(alignment: .topTrailing) { + content + + if isBest { + Text("BEST") + .font(.medium10) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red) + .cornerRadius(4) + .padding(8) + } + } + } +} + +// 추천 배지 ViewModifier +struct RecommendedBadgeModifier: ViewModifier { + let isRecommended: Bool + + func body(content: Content) -> some View { + ZStack(alignment: .topTrailing) { + content + + if isRecommended { + Text("추천") + .font(.medium10) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue) + .cornerRadius(4) + .padding(8) + } + } + } +} + +// 품절 ViewModifier +struct SoldOutModifier: ViewModifier { + let isSoldOut: Bool + + func body(content: Content) -> some View { + ZStack { + content + + if isSoldOut { + Color.black.opacity(0.5) + .cornerRadius(10) + + Text("품절") + .font(.bold18) + .foregroundStyle(.white) + } + } + } +} + +// 할인율 ViewModifier +struct DiscountModifier: ViewModifier { + let discountRate: Int? + + func body(content: Content) -> some View { + content + } +} + +// View 확장 메서드 +extension View { + func bestBadge(_ isBest: Bool) -> some View { + self.modifier(BestBadgeModifier(isBest: isBest)) + } + + func recommendedBadge(_ isRecommended: Bool) -> some View { + self.modifier(RecommendedBadgeModifier(isRecommended: isRecommended)) + } + + func soldout(_ isSoldOut: Bool) -> some View { + self.modifier(SoldOutModifier(isSoldOut: isSoldOut)) + } + + func discount(_ discountRate: Int?) -> some View { + self.modifier(DiscountModifier(discountRate: discountRate)) + } +} + diff --git a/megabox/Sources/Order/Views/MenuItemCardView.swift b/megabox/Sources/Order/Views/MenuItemCardView.swift new file mode 100644 index 0000000..7c9f339 --- /dev/null +++ b/megabox/Sources/Order/Views/MenuItemCardView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct MenuItemCardView: View { + let menuItem: MenuItem + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // 이미지 영역 + RoundedRectangle(cornerRadius: 10) + .fill(Color("gray00")) + .frame(width: 148, height: 148) + .overlay( + Image(menuItem.imageName) + .resizable() + .aspectRatio(contentMode: .fill) + .clipped() + ) + .cornerRadius(10) + .bestBadge(menuItem.isBest) + .recommendedBadge(menuItem.isRecommended) + .soldout(menuItem.isSoldOut) + + // 텍스트 영역 + VStack(alignment: .leading, spacing: 4) { + Text(menuItem.name) + .font(.regular13) + .foregroundStyle(.black) + + // 가격 표시 + if let originalPrice = menuItem.originalPrice { + HStack(spacing: 4) { + Text("\(formatPrice(menuItem.price))원") + .font(.semiBold14) + .foregroundStyle(.black) + + Text("\(formatPrice(originalPrice))원") + .font(.regular09) + .foregroundStyle(Color("gray04")) + .strikethrough() + } + } else { + Text("\(formatPrice(menuItem.price))원") + .font(.semiBold14) + .foregroundStyle(.black) + } + } + } + .frame(width: 158) + } + + private func formatPrice(_ price: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: price)) ?? "\(price)" + } +} + +#Preview("BEST") { + MenuItemCardView(menuItem: MenuItem(name: "러브 콤보", price: 10900, imageName: "love_combo", isBest: true)) +} + +#Preview("추천") { + MenuItemCardView(menuItem: MenuItem(name: "메가박스 오리지널 티켓북", price: 10900, imageName: "ticket_book", isRecommended: true)) +} + +#Preview("품절") { + MenuItemCardView(menuItem: MenuItem(name: "디즈니 픽사 포스터", price: 15900, imageName: "disney_pixar", isSoldOut: true)) +} + +#Preview("할인") { + MenuItemCardView(menuItem: MenuItem(name: "인사이드아웃2 감정", price: 29900, imageName: "inside_out", originalPrice: 35900)) +} + diff --git a/megabox/Sources/Order/Views/OrderCardLayoutModifiers.swift b/megabox/Sources/Order/Views/OrderCardLayoutModifiers.swift new file mode 100644 index 0000000..35caf6f --- /dev/null +++ b/megabox/Sources/Order/Views/OrderCardLayoutModifiers.swift @@ -0,0 +1,130 @@ +import SwiftUI + +// 기본 카드 스타일 Modifier +struct OrderCardStyle: ViewModifier { + func body(content: Content) -> some View { + content + .background(Color.white) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color("gray02"), lineWidth: 1) + ) + } +} + +// 레이아웃 Modifier: 텍스트 왼쪽 상단, 아이콘 오른쪽 하단 (바로 주문 / 선물하기 / 스토어 교환권) +struct TopLeftTextLayoutStyle: ViewModifier { + let title: String + let description: String? + let iconName: String + let iconSize: CGFloat + let titleFont: Font + + init(title: String, description: String?, iconName: String, iconSize: CGFloat, titleFont: Font = .bold24) { + self.title = title + self.description = description + self.iconName = iconName + self.iconSize = iconSize + self.titleFont = titleFont + } + + func body(content: Content) -> some View { + ZStack(alignment: .topLeading) { + // 카드 배경 + RoundedRectangle(cornerRadius: 10) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color("gray02"), lineWidth: 1) + ) + + // 텍스트 (왼쪽 상단) + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(titleFont) + .foregroundStyle(.black) + + if let description = description { + Text(description) + .font(.regular12) + .foregroundStyle(Color("gray04")) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 12) + .padding(.top, 15) + + // 아이콘 (오른쪽 하단) + HStack { + Spacer() + VStack { + Spacer() + Image(systemName: iconName) + .font(.system(size: iconSize, weight: .light)) + .foregroundStyle(.black) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 15) + } + .modifier(OrderCardStyle()) + } +} + +// 레이아웃 Modifier: 텍스트 왼쪽, 아이콘 오른쪽 가로 배치 (어디서든 팝콘 만나기용) +struct HorizontalLayoutStyle: ViewModifier { + let title: String + let description: String? + let iconName: String + let iconSize: CGFloat + let iconBackgroundColor: Color? + let titleFont: Font + + init(title: String, description: String?, iconName: String, iconSize: CGFloat, iconBackgroundColor: Color?, titleFont: Font = .bold24) { + self.title = title + self.description = description + self.iconName = iconName + self.iconSize = iconSize + self.iconBackgroundColor = iconBackgroundColor + self.titleFont = titleFont + } + + func body(content: Content) -> some View { + ZStack { + // 카드 배경 + RoundedRectangle(cornerRadius: 10) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color("gray02"), lineWidth: 1) + ) + + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(titleFont) + .foregroundStyle(.black) + + if let description = description { + Text(description) + .font(.regular13) + .foregroundStyle(Color("gray04")) + } + } + + Spacer() + + // 아이콘 + Image(systemName: iconName) + .font(.system(size: iconSize, weight: .light)) + .foregroundStyle(.black) + } + .padding(.horizontal, 12) + .padding(.vertical, 15) + } + .modifier(OrderCardStyle()) + } +} + diff --git a/megabox/Sources/Order/Views/OrderView.swift b/megabox/Sources/Order/Views/OrderView.swift new file mode 100644 index 0000000..d3effc3 --- /dev/null +++ b/megabox/Sources/Order/Views/OrderView.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct OrderView: View { + @State private var orderViewModel = OrderViewModel() + @State private var selectedTheater: String = "강남" + + var body: some View { + ZStack { + Color.white + .ignoresSafeArea() + + VStack(spacing: 0) { + + ScrollView { + // 헤더 (극장 선택) + headerSection + .padding(.bottom, 26) + VStack(spacing: 15) { + // 바로 주문 및 카드 섹션 + HStack(alignment: .top, spacing: 12) { + // 바로 주문 카드 (남은 공간 차지) + DirectOrderSectionView() + .frame(maxWidth: .infinity) + .frame(height: 308) + + // 오른쪽 카드들 (정사각형) + VStack(spacing: 12) { + StoreVoucherCardView() + .aspectRatio(1, contentMode: .fit) + .frame(width: 148) + + GiftCardView() + .aspectRatio(1, contentMode: .fit) + .frame(width: 148) + } + } + .frame(height: 308) + + // 어디서든 팝콘 만나기 섹션 + DeliverySectionView() + .frame(height: 104) + .padding(.vertical, 5) + .padding(.bottom, 15) + + // 추천 메뉴 섹션 + RecommendedMenuSectionView(menus: orderViewModel.recommendedMenus) + .padding(.bottom, 25) + + // 베스트 메뉴 섹션 + BestMenuSectionView(menus: orderViewModel.bestMenus) + .padding(.bottom, 32) + } + .padding(.horizontal, 16) + } + } + } + } + + private var headerSection: some View { + VStack(spacing: 0) { + // MEGABOX 로고 + HStack{ + Image("homeMegaboxLogo") + .resizable() + .scaledToFit() + .frame(width: 120, height: 25) + Spacer() + } + .padding(.leading, 16) + .padding(.bottom, 17) + .background(Color.white) + + // 극장 선택 바 (보라색 배경) + TheaterChangeBarView(selectedTheater: selectedTheater) { + // 극장 변경 액션 + } + } + } +} + +#Preview { + OrderView() +} + +#Preview("iPhone 16 Pro Max") { + OrderView() +} + diff --git a/megabox/Sources/Order/Views/RecommendedMenuSectionView.swift b/megabox/Sources/Order/Views/RecommendedMenuSectionView.swift new file mode 100644 index 0000000..dc6880a --- /dev/null +++ b/megabox/Sources/Order/Views/RecommendedMenuSectionView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct RecommendedMenuSectionView: View { + let menus: [MenuItem] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("추천 메뉴") + .font(.bold22) + .foregroundStyle(.black) + + Text("영화 볼때 뭐먹지 고민될 땐 추천 메뉴!") + .font(.regular12) + .foregroundStyle(Color("gray04")) + } + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 15) { + ForEach(menus) { menu in + MenuItemCardView(menuItem: menu) + } + } + } + } + } +} + +#Preview { + RecommendedMenuSectionView(menus: [ + MenuItem(name: "러브 콤보", price: 10900, imageName: "love_combo"), + MenuItem(name: "더블 콤보", price: 24900, imageName: "double_combo"), + MenuItem(name: "디즈니 픽사 콤보", price: 15900, imageName: "disney_pixar") + ]) + .padding() +} + diff --git a/megabox/Sources/Order/Views/StoreVoucherCardView.swift b/megabox/Sources/Order/Views/StoreVoucherCardView.swift new file mode 100644 index 0000000..9068784 --- /dev/null +++ b/megabox/Sources/Order/Views/StoreVoucherCardView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct StoreVoucherCardView: View { + var body: some View { + EmptyView() + .modifier(TopLeftTextLayoutStyle( + title: "스토어 교환권", + description: nil, + iconName: "ticket", + iconSize: 50, + titleFont: .bold22 + )) + } +} + +#Preview { + StoreVoucherCardView() + .padding() +} + diff --git a/megabox/Sources/Order/Views/TheaterChangeBarView.swift b/megabox/Sources/Order/Views/TheaterChangeBarView.swift new file mode 100644 index 0000000..c10e8c6 --- /dev/null +++ b/megabox/Sources/Order/Views/TheaterChangeBarView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +enum TheaterBarStyle { + case purple // 보라색 배경, 흰색 텍스트 + case white // 흰색 배경, 검은색 텍스트, 보라색 버튼 +} + +struct TheaterChangeBarView: View { + let selectedTheater: String + let onChangeTheater: () -> Void + let style: TheaterBarStyle + + init(selectedTheater: String, onChangeTheater: @escaping () -> Void, style: TheaterBarStyle = .purple) { + self.selectedTheater = selectedTheater + self.onChangeTheater = onChangeTheater + self.style = style + } + + var body: some View { + HStack { + // 왼쪽: 위치 아이콘과 극장명 + HStack(spacing: 8) { + Image(systemName: "mappin.circle.fill") + .foregroundStyle(style == .purple ? .white : .black) + .font(.system(size: 16)) + + Text(selectedTheater) + .font(.semiBold13) + .foregroundStyle(style == .purple ? Color.white : Color.black) + } + + Spacer() + + // 오른쪽: 극장 변경 버튼 + Button { + onChangeTheater() + } label: { + Text("극장 변경") + .font(.semiBold13) + .foregroundStyle(style == .purple ? .white : Color("purple03")) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(style == .purple ? Color.white : Color("gray02"), lineWidth: 1) + ) + } + } + .padding(.horizontal, style == .purple ? 20 : 16) + .padding(.vertical, 10) + .background(style == .purple ? Color("purple03") : Color.white) + } +} + +#Preview("Purple Style") { + TheaterChangeBarView(selectedTheater: "강남", style: .purple) { + print("극장 변경") + } +} + +#Preview("White Style") { + TheaterChangeBarView(selectedTheater: "강남", style: .white) { + print("극장 변경") + } +} + diff --git a/megabox/Sources/Profile/Views/MemberInfoView.swift b/megabox/Sources/Profile/Views/MemberInfoView.swift index 544b599..5257fdf 100644 --- a/megabox/Sources/Profile/Views/MemberInfoView.swift +++ b/megabox/Sources/Profile/Views/MemberInfoView.swift @@ -32,6 +32,8 @@ struct MemberInfoView: View { } .padding(.horizontal, 16) }.onAppear { + savedId = KeychainHelper.shared.read(forKey: "savedId") ?? "" + savedName = KeychainHelper.shared.read(forKey: "savedName") ?? "" tempName = savedName } } diff --git a/megabox/Sources/Profile/Views/ProfileView.swift b/megabox/Sources/Profile/Views/ProfileView.swift index 443b9bd..8b5b3ea 100644 --- a/megabox/Sources/Profile/Views/ProfileView.swift +++ b/megabox/Sources/Profile/Views/ProfileView.swift @@ -11,6 +11,13 @@ struct ProfileView: View { @AppStorage("savedName") private var savedName: String = "" @EnvironmentObject var router: NavigationRouter // 추가 + //프로필 사진 기능 + @State private var showImagePicker = false + @State private var selectedImages: [UIImage] = [] + + private var selectedImage: UIImage? { + selectedImages.first + } var body: some View { @@ -18,10 +25,14 @@ struct ProfileView: View { Color.white .ignoresSafeArea() // 안전 영역(노치, 홈바 영역)까지 채우기 VStack { - headerView - .padding(.top, 59) // 상단 여백 - - memberShipPointView + HStack { + profileImageView + VStack { + headerView + memberShipPointView + } + } + .padding(.top, 59) // 상단 여백 clubMembershipButtonView .padding(.top, 15) @@ -33,8 +44,61 @@ struct ProfileView: View { .padding(.top, 33) Spacer() // 아래는 남는 공간 차지 + Button("테스트용 로그아웃") { + KeychainHelper.shared.delete(account: "savedId") + KeychainHelper.shared.delete(account: "savedPwd") + KeychainHelper.shared.delete(account: "savedName") + print("🗑️ Keychain 삭제 완료!") + router.reset() // 네비게이션 스택 초기화 + router.push(.login) + } + .foregroundColor(.red) + .padding(.top, 20) } .padding(.horizontal, 16) + + + } + .onAppear { + if let name = KeychainHelper.shared.read(forKey: "savedName") { + savedName = name + print("ProfileView 갱신 : \(name)") + } else { + savedName = "" + print("Keychain에서 이름 없음") + } + } + } + + private var profileImageView: some View { + ZStack { + // 업로드된 프로필 이미지 + if let selectedImage { + Image(uiImage: selectedImage) + .resizable() + .scaledToFill() + .frame(width: 55, height: 55) + .clipShape(Circle()) + } else { + // 기본 아이콘 + Image(systemName: "person.crop.circle") + .resizable() + .scaledToFit() + .frame(width: 55, height: 55) + .foregroundStyle(Color("gray04")) + } + } + .onLongPressGesture(minimumDuration: 1.0) { + showImagePicker = true + } + .sheet(isPresented: $showImagePicker) { + ImagePicker(images: $selectedImages, selectedLimit: 1) + } + .onChange(of: selectedImages) { oldValue, newValue in + // 이미지가 선택되면 첫 번째 이미지만 유지 + if newValue.count > 1 { + selectedImages = Array(newValue.prefix(1)) + } } } diff --git a/megabox/Sources/Routers/Routers.swift b/megabox/Sources/Routers/Routers.swift index a14b1ff..3686834 100644 --- a/megabox/Sources/Routers/Routers.swift +++ b/megabox/Sources/Routers/Routers.swift @@ -6,4 +6,5 @@ enum Route: Hashable { case memberInfo // 프로필 → 회원정보 관리 case home case login + case directOrderDetail // 바로 주문 상세 } diff --git a/megabox/Sources/Utils/KeychainHelper.swift b/megabox/Sources/Utils/KeychainHelper.swift new file mode 100644 index 0000000..1018c00 --- /dev/null +++ b/megabox/Sources/Utils/KeychainHelper.swift @@ -0,0 +1,65 @@ +import Security +import Foundation + +final class KeychainHelper { + static let shared = KeychainHelper() + + private init() {} + + func save(_ value: String, forKey key: String) { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + // 기존 값 삭제 후 새 값 추가 + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + func read(forKey key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + SecItemCopyMatching(query as CFDictionary, &result) + if let data = result as? Data { + return String(decoding: data, as: UTF8.self) + } + return nil + } + + /// Keychain에서 지정된 계정과 서비스에 해당하는 항목을 삭제합니다. + /// - Parameters: + /// - account: 계정 식별자 (예: 사용자 이메일) + /// - service: 서비스 이름 (예: "com.example.myapp") + /// - Returns: Keychain 삭제 작업의 상태 코드 (`errSecSuccess` 등) + @discardableResult + func delete(account: String) -> OSStatus { + // 1. 삭제할 항목을 식별할 쿼리 구성 + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, // 삭제 대상 유형 + kSecAttrAccount as String: account, // 계정 식별자 + kSecAttrService as String: "dev.tuist.megabox" // 서비스 구분자 + ] + + // 2. 항목 삭제 시도 + let status = SecItemDelete(query as CFDictionary) + + // 3. 상태 확인 및 그 출력 + if status == errSecSuccess { + print("Keychain 삭제 성공 - [\(kSecAttrService) : \(account)]") + } else if status == errSecItemNotFound { + print("Keychain 항목 없음 - [\(kSecAttrService) : \(account)]") + } else { + print("Keychain 삭제 실패 - status: \(status)") + } + + return status + } +} diff --git a/megabox/Sources/Utils/Photo/ImagePicker.swift b/megabox/Sources/Utils/Photo/ImagePicker.swift new file mode 100644 index 0000000..5b58898 --- /dev/null +++ b/megabox/Sources/Utils/Photo/ImagePicker.swift @@ -0,0 +1,46 @@ +import SwiftUI +import PhotosUI + +struct ImagePicker: UIViewControllerRepresentable { + @Environment(\.dismiss) var dismiss + @Binding var images: [UIImage] + var selectedLimit: Int + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) + config.selectionLimit = selectedLimit + config.filter = .images + + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + var parent: ImagePicker + + init(parent: ImagePicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.dismiss() + + for result in results { + result.itemProvider.loadObject(ofClass: UIImage.self) { object, error in + if let image = object as? UIImage { + DispatchQueue.main.async { + self.parent.images.append(image) + } + } + } + } + } + } +}