diff --git a/Sources/Formatters.swift b/Sources/Formatters.swift new file mode 100644 index 0000000..c1b40d3 --- /dev/null +++ b/Sources/Formatters.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Int { + var formattedWithComma: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: self)) ?? "\(self)" + } +} + diff --git a/Sources/HomeView.swift b/Sources/HomeView.swift new file mode 100644 index 0000000..e2ca09c --- /dev/null +++ b/Sources/HomeView.swift @@ -0,0 +1,209 @@ +import SwiftUI + +struct Movie: Identifiable { + let id = UUID() + let title: String + let moviegoer: String + let imageName: String + let imageName2: String +} + +struct MovieChartView: View { + let movies: [Movie] = [ + Movie(title: "어쩔수가 없다", moviegoer: "누적관객수 20만", imageName: "어쩔수가 없다", imageName2: "f1 2"), + Movie(title: "극장판 귀멸의 ...", moviegoer: "누적관객수 10만", imageName: "무한성", imageName2: "f1 2"), + Movie(title: "F1 더 무비", moviegoer: "누적관객수 10만", imageName: "f1", imageName2: "f1 2"), + Movie(title: "얼굴", moviegoer: "누적관객수 10만", imageName: "얼굴", imageName2: "f1 2"), + Movie(title: "모노노케 히메", moviegoer: "누적관객수 10만", imageName: "모노노케 히메", imageName2: "f1 2") + ] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(movies) { movie in + + NavigationLink(destination: Text("\(movie.title) 상세 페이지")) { + + VStack(alignment: .leading, spacing: 8) { + Image(movie.imageName) + .resizable().scaledToFit().frame(width: 140, height: 210) + + // MARK: - UI를 새 데이터에 맞게 수정 + Text(movie.title) + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.black) + .lineLimit(1) + + Text(movie.moviegoer) + .font(.system(size: 13)) + .foregroundStyle(.gray) + } + .frame(width: 140) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal) + } + } + +} + +struct ComingSoonView: View { + var body: some View { + VStack { + Spacer() + Text("상영예정 영화 목록입니다.").font(.headline) + Spacer() + } + } +} + +// MARK: - '홈' 탭이 선택되었을 때 보여줄 콘텐츠 뷰 +struct HomeContentView: View { + @State private var selectedMenu: String = "무비차트" + let menus = ["무비차트", "상영예정"] + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 20) { + ForEach(menus, id: \.self) { menu in + Button(action: { + self.selectedMenu = menu + }) { + Text(menu) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(selectedMenu == menu ? .black : .gray.opacity(0.5)) + } + } + } + .padding(.horizontal) + + if selectedMenu == "무비차트" { + MovieChartView() // (가로 스크롤 뷰가 여기에 포함되어 있습니다) + } else { + ComingSoonView() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + + } +} + + +struct Home: View { + @State private var selectedTab = "홈" + let tabs = ["홈", "이벤트", "스토어", "선호극장"] + + var body: some View { + + NavigationStack { + VStack(spacing: 0) { + + HStack { + Image("megaboxLogo2") + .resizable().scaledToFit().frame(height: 30) + .padding(.leading, 16) + Spacer() + } + .padding(.vertical, 8) + + HStack(spacing: 25) { + ForEach(tabs, id: \.self) { tab in + Text(tab) + .font(.system(size: 17)) + .fontWeight(selectedTab == tab ? .bold : .regular) + .foregroundStyle(selectedTab == tab ? .black : .gray) + .onTapGesture { + withAnimation(.easeIn(duration: 0.2)) { + self.selectedTab = tab + } + } + } + Spacer() + } + .padding(.bottom, 15) + .padding(.leading, 16) + + ZStack { + switch selectedTab { + + case "홈": + ScrollView(.vertical, showsIndicators: false) { + + VStack(spacing: 0) { + + HomeContentView() + + VStack { + HStack { + Text("알고보면 더 재밌는 무비피드") + .padding(.leading, 16) + .padding(.trailing, 109) + + Button(action: { + print("오른쪽 화살표 버튼 클릭됨") + }) { + Image(systemName: "arrow.right") + .font(.system(size: 20, weight: .bold)) + .padding() + .foregroundStyle(.black) + } + Spacer() + } + + Image("무비피드") + .resizable() + .scaledToFit() + .frame(width: 408, height: 221) + } + .padding(.bottom, 44) + + VStack { + HStack { + Image("모노노케 히메 2") + .padding(.leading, 16) + VStack { + Text("9월, 메가박스의 영화들(1) - 명작들의 재개봉’") + Text("<모노노케 히메>,<퍼펙트 블루>") + } + Spacer() + } + .padding(.bottom, 39) + + HStack { + Image("얼굴 2") + .padding(.leading, 16) + VStack { + Text("메가박스 오리지널 티켓 Re.37 <얼굴>") + Text("영화 속 양극적인 감정의 대비") + } + Spacer() + } + } + .padding(.bottom, 50) + } + } + + case "이벤트": + Text("이벤트 목록").font(.largeTitle) + case "스토어": + Text("스토어 상품들").font(.largeTitle) + case "선호극장": + Text("선호극장 정보").font(.largeTitle) + default: + EmptyView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + + } + .navigationBarHidden(true) + } + } +} + +#Preview { + Home() +} diff --git a/Sources/KeyChainHelper.swift b/Sources/KeyChainHelper.swift new file mode 100644 index 0000000..4c00d9c --- /dev/null +++ b/Sources/KeyChainHelper.swift @@ -0,0 +1,77 @@ +// KeychainHelper.swift + +import Foundation +import Security + +struct KeychainHelper { + + private static let service = "com.myapp.credentials" + + // MARK: - Save + + static func save(string: String, account: String) -> Bool { + guard let data = string.data(using: .utf8) else { return false } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data + ] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + if status == errSecItemNotFound { + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked // 접근성 설정 + ] + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + return addStatus == errSecSuccess + } + + return status == errSecSuccess + } + + // MARK: - Read + + static func read(account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: kCFBooleanTrue!, // 데이터 반환 요청 + kSecMatchLimit as String: kSecMatchLimitOne // 하나의 항목만 + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + if status == errSecSuccess { + if let data = dataTypeRef as? Data, + let string = String(data: data, encoding: .utf8) { + return string + } + } + return nil + } + + // MARK: - Delete + + static func delete(account: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/Sources/LoginModel.swift b/Sources/LoginModel.swift new file mode 100644 index 0000000..0956933 --- /dev/null +++ b/Sources/LoginModel.swift @@ -0,0 +1,14 @@ +// +// LoginModel.swift +// megabox +// +// Created by 문인성 on 9/25/25. +// + +import Foundation + +struct LoginModel { + var id: String = "" + var pwd: String = "" +} + diff --git a/Sources/LoginView.swift b/Sources/LoginView.swift new file mode 100644 index 0000000..a3de0d8 --- /dev/null +++ b/Sources/LoginView.swift @@ -0,0 +1,99 @@ +// LoginView.swift + +import SwiftUI + +struct LoginView: View { + + @StateObject private var viewModel: LoginViewModel + + init(viewModel: LoginViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + @State private var showAlert: Bool = false + + var body: some View { + VStack { + HStack { + Text("로그인") + .font(.semiBold24) + .padding(.top, 44) + } + + Spacer() + + VStack { + TextField("아이디", text: $viewModel.loginInfo.id) + .padding(.horizontal, 16) + Divider() + .padding(.horizontal, 16) + + SecureField("비밀번호", text: $viewModel.loginInfo.pwd) + .padding(.horizontal, 16) + Divider() + .padding(.horizontal, 16) + .padding(.bottom, 74.98) + + + Button(action: { + + viewModel.handleLogin() + + if !viewModel.isLoggedIn { + showAlert = true + } + + }) { + Text("로그인") + .font(.headline) + .fontWeight(.bold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background(Color.purple) // Color("purple03") + .cornerRadius(10) + } + .padding(.horizontal, 16.5) + .padding(.bottom, 17) + + Text("회원가입") + .font(.medium13) + .foregroundStyle(Color.gray) // Color("gray04") + } + + } + + .alert("로그인 실패", isPresented: $showAlert) { + Button("확인", role: .cancel) {} + } message: { + Text("아이디 또는 비밀번호를 확인해주세요.") + } + + HStack { + Image("naverLoginBtn") + Spacer() + Image("kakaoLoginBtn") + Spacer() + Image("appleLoginBtn") + } + .frame(maxWidth: .infinity) + .padding(.top, 35) + .padding(.horizontal, 87) + .padding(.bottom, 39) + + HStack { + Image("umc") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + } + + Spacer() + + } +} + +#Preview { + LoginView(viewModel: LoginViewModel()) +} diff --git a/Sources/LoginViewModel.swift b/Sources/LoginViewModel.swift new file mode 100644 index 0000000..e122bf3 --- /dev/null +++ b/Sources/LoginViewModel.swift @@ -0,0 +1,66 @@ +// LoginViewModel.swift +// (이 코드로 파일 전체를 덮어쓰세요) + +import Foundation + +class LoginViewModel: ObservableObject { + + @Published var loginInfo = LoginModel() + @Published var isLoggedIn: Bool = false + @Published var userName: String = "" // 마이페이지 표시용 + + + private let idAccount = "userID" + private let pwdAccount = "userPassword" + + init() { + checkAutoLogin() + } + + func checkAutoLogin() { + guard let savedId = KeychainHelper.read(account: idAccount), + let savedPwd = KeychainHelper.read(account: pwdAccount) else { + print("No credentials found in Keychain.") + return + } + + print("Auto login successful.") + self.userName = savedId // 마이페이지용 이름 설정 (체크리스트 3번) + self.isLoggedIn = true // 로그인 상태를 true로 변경 + } + + func handleLogin() { + let correctId = "swift" + let correctPwd = "1234" + + if loginInfo.id == correctId && loginInfo.pwd == correctPwd { + let idSaved = KeychainHelper.save(string: loginInfo.id, account: idAccount) + let pwdSaved = KeychainHelper.save(string: loginInfo.pwd, account: pwdAccount) + + if idSaved && pwdSaved { + print("Credentials saved to Keychain.") + self.userName = loginInfo.id + self.isLoggedIn = true + } else { + print("Failed to save credentials to Keychain.") + } + + } else { + print("Login failed: Incorrect ID or PWD.") + } + } + + func handleLogout() { + let idDeleted = KeychainHelper.delete(account: idAccount) + let pwdDeleted = KeychainHelper.delete(account: pwdAccount) + + if idDeleted && pwdDeleted { + print("Credentials deleted from Keychain.") + } else { + print("Could not delete credentials from Keychain.") + } + + self.isLoggedIn = false + self.userName = "" // 저장된 사용자 이름도 지웁니다. + } +} diff --git a/Sources/MainTabView.swift b/Sources/MainTabView.swift new file mode 100644 index 0000000..0f2c837 --- /dev/null +++ b/Sources/MainTabView.swift @@ -0,0 +1,35 @@ +// +// TabView.swift +// megabox +// +// Created by 문인성 on 10/8/25. +// + +import SwiftUI + +struct MainTabView: View { + var body: some View { + TabView { + Tab("홈" , systemImage: "house.fill") { + Home() + } + + Tab("바로 예매" , systemImage: "play.laptopcomputer") { + LoginView(viewModel: LoginViewModel()) + } + + Tab("모바일 오더" , systemImage: "popcorn") { + MobileOrderView() + } + + Tab("마이 페이지" , systemImage: "person") { + UserInformation() + + } + } + } +} + +#Preview { + MainTabView() +} diff --git a/Sources/MegaboxApp.swift b/Sources/MegaboxApp.swift new file mode 100644 index 0000000..236b323 --- /dev/null +++ b/Sources/MegaboxApp.swift @@ -0,0 +1,28 @@ +import SwiftUI + +@main +struct YourApp: App { + + @StateObject private var viewModel = LoginViewModel() + + @State private var isSplashFinished: Bool = false + + var body: some Scene { + WindowGroup { + + if !isSplashFinished { + SplashView(isSplashFinished: $isSplashFinished) + + } else { + + if viewModel.isLoggedIn { + MainTabView() + .environmentObject(viewModel) + } else { + // (checkAutoLogin()이 실패했다면) + LoginView(viewModel: viewModel) + } + } + } + } +} diff --git a/Sources/MenuCard.swift b/Sources/MenuCard.swift new file mode 100644 index 0000000..56e551f --- /dev/null +++ b/Sources/MenuCard.swift @@ -0,0 +1,208 @@ +import SwiftUI + +struct MenuCard: View { + let item: MenuItemModel + + @Environment(\.menuCardAction) private var action + + var body: some View { + VStack(spacing: 24) { + HStack(alignment: .top, spacing: 20) { + VStack(alignment: .leading, spacing: 12) { + categoryLabel + + VStack(alignment: .leading, spacing: 6) { + Text(item.name) + .font(.title3.bold()) + + Text(item.summary) + .font(.callout) + .foregroundStyle(.secondary) + } + + infoRow + + if !item.highlights.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(highlightRows.indices, id: \.self) { index in + HStack(spacing: 8) { + ForEach(highlightRows[index]) { highlight in + HighlightTagView(highlight: highlight, accentColor: item.category.accentColor) + } + Spacer(minLength: 0) + } + } + } + } + } + + MenuItemImageView(imageName: item.heroImageName, accentColor: item.category.accentColor) + } + + Divider() + + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text("\(item.formattedPrice)원") + .font(.title3.bold()) + .foregroundStyle(Color.primary) + + Text(item.memberBenefit) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 16) + + Button { + action(item) + } label: { + Text(item.isSoldOut ? "품절" : "바로 주문") + .font(.headline) + .padding(.horizontal, 28) + .padding(.vertical, 14) + .frame(minWidth: 140) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(item.isSoldOut ? Color.gray.opacity(0.3) : Color.black) + ) + .foregroundStyle(item.isSoldOut ? Color.white.opacity(0.8) : Color.white) + } + .disabled(item.isSoldOut) + .opacity(item.isSoldOut ? 0.7 : 1) + .animation(.easeInOut(duration: 0.2), value: item.isSoldOut) + } + } + .padding(24) + .background( + RoundedRectangle(cornerRadius: 36, style: .continuous) + .fill(Color(.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 36, style: .continuous) + .stroke(Color.black.opacity(0.04), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.08), radius: 22, x: 0, y: 12) + .bestBadge(item.isBest) + .recommendedBadge(item.isRecommended) + .discountBadge(item.discountRate) + .soldOutOverlay(item.isSoldOut) + } + + private var infoRow: some View { + HStack(spacing: 12) { + Label(item.waitingTimeText, systemImage: "clock") + Label(item.pickupInfoText, systemImage: "mappin.and.ellipse") + + if let calories = item.calories { + Label("\(calories) kcal", systemImage: "flame") + } + } + .font(.caption) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + } + + private var highlightRows: [[MenuItemModel.Highlight]] { + let chunks = stride(from: 0, to: item.highlights.count, by: 2).map { index -> [MenuItemModel.Highlight] in + let upperBound = min(index + 2, item.highlights.count) + return Array(item.highlights[index.. Void = { _ in } +} + +extension EnvironmentValues { + var menuCardAction: (MenuItemModel) -> Void { + get { self[MenuCardActionKey.self] } + set { self[MenuCardActionKey.self] = newValue } + } +} + +struct MenuCardActionModifier: ViewModifier { + var action: (MenuItemModel) -> Void + + func body(content: Content) -> some View { + content.environment(\.menuCardAction, action) + } +} + +extension View { + func onMenuCardOrder(_ action: @escaping (MenuItemModel) -> Void) -> some View { + modifier(MenuCardActionModifier(action: action)) + } +} + diff --git a/Sources/MenuCardModifiers.swift b/Sources/MenuCardModifiers.swift new file mode 100644 index 0000000..037d6bb --- /dev/null +++ b/Sources/MenuCardModifiers.swift @@ -0,0 +1,128 @@ +import SwiftUI + +private struct BestBadgeModifier: ViewModifier { + let isBest: Bool + + func body(content: Content) -> some View { + content.overlay(alignment: .topLeading) { + if isBest { + BadgeLabel(text: "BEST", background: Color.orange) + .padding(12) + } + } + } +} + +private struct RecommendationBadgeModifier: ViewModifier { + let isRecommended: Bool + + func body(content: Content) -> some View { + content.overlay(alignment: .bottomLeading) { + if isRecommended { + BadgeLabel(text: "추천", background: Color.purple.opacity(0.8)) + .padding([.leading, .bottom], 12) + } + } + } +} + +private struct DiscountBadgeModifier: ViewModifier { + let discountRate: Int? + + func body(content: Content) -> some View { + content.overlay(alignment: .topTrailing) { + if let discountRate { + BadgeLabel(text: "-\(discountRate)%", background: Color.red) + .padding(12) + } + } + } +} + +private struct SoldOutOverlayModifier: ViewModifier { + let isSoldOut: Bool + + func body(content: Content) -> some View { + content.overlay { + if isSoldOut { + ZStack { + Color.black.opacity(0.4) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + Text("현재 품절") + .font(.headline) + .foregroundStyle(.white) + } + } + } + } +} + +private struct BadgeLabel: View { + let text: String + let background: Color + + var body: some View { + Text(text) + .font(.caption.bold()) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .foregroundStyle(Color.white) + .background( + Capsule(style: .continuous) + .fill(background) + ) + } +} + +private struct InfoCardModifier: ViewModifier { + var background: Color + var strokeColor: Color + var cornerRadius: CGFloat + + init(background: Color = Color.white, strokeColor: Color = Color.black.opacity(0.1), cornerRadius: CGFloat = 28) { + self.background = background + self.strokeColor = strokeColor + self.cornerRadius = cornerRadius + } + + func body(content: Content) -> some View { + content + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(background) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(strokeColor.opacity(0.12), lineWidth: 1) + ) + } +} + +extension View { + func bestBadge(_ isBest: Bool) -> some View { + modifier(BestBadgeModifier(isBest: isBest)) + } + + func recommendedBadge(_ isRecommended: Bool) -> some View { + modifier(RecommendationBadgeModifier(isRecommended: isRecommended)) + } + + func discountBadge(_ discountRate: Int?) -> some View { + modifier(DiscountBadgeModifier(discountRate: discountRate)) + } + + func soldOutOverlay(_ isSoldOut: Bool) -> some View { + modifier(SoldOutOverlayModifier(isSoldOut: isSoldOut)) + } + + func infoCard( + background: Color = Color.white, + strokeColor: Color = Color.black.opacity(0.1), + cornerRadius: CGFloat = 28 + ) -> some View { + modifier(InfoCardModifier(background: background, strokeColor: strokeColor, cornerRadius: cornerRadius)) + } +} + diff --git a/Sources/MenuItemModel.swift b/Sources/MenuItemModel.swift new file mode 100644 index 0000000..c82ff5e --- /dev/null +++ b/Sources/MenuItemModel.swift @@ -0,0 +1,306 @@ +import SwiftUI + +struct MenuItemModel: Identifiable, Hashable { + struct Highlight: Identifiable, Hashable { + let id = UUID() + let icon: String + let text: String + } + + enum MenuCategory: String, CaseIterable, Identifiable { + case combo = "콤보" + case popcorn = "팝콘" + case drink = "음료" + case dessert = "디저트" + case snack = "스낵" + + var id: String { rawValue } + + var emoji: String { + switch self { + case .combo: return "🥡" + case .popcorn: return "🍿" + case .drink: return "🥤" + case .dessert: return "🧁" + case .snack: return "🍪" + } + } + + var accentColor: Color { + switch self { + case .combo: return Color(red: 0.38, green: 0.27, blue: 0.92) + case .popcorn: return Color(red: 0.96, green: 0.61, blue: 0.2) + case .drink: return Color(red: 0.22, green: 0.62, blue: 0.94) + case .dessert: return Color(red: 0.91, green: 0.36, blue: 0.58) + case .snack: return Color(red: 0.26, green: 0.75, blue: 0.54) + } + } + } + + let id = UUID() + let category: MenuCategory + let name: String + let summary: String + let detailDescription: String + let price: Int + let originalPrice: Int? + let memberBenefit: String + let heroImageName: String? + let calories: Int? + let sugar: Int? + let caffeine: Int? + let waitingTimeText: String + let pickupInfoText: String + let highlights: [Highlight] + let isBest: Bool + let isRecommended: Bool + let discountRate: Int? + let isSoldOut: Bool + + var formattedPrice: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: price)) ?? "\(price)" + } +} + +extension MenuItemModel { + static let sampleItems: [MenuItemModel] = [ + + MenuItemModel( + category: .popcorn, + name: "프리미엄 나쵸 세트", + summary: "나쵸 + 치즈 + 탄산", + detailDescription: "바삭한 나쵸와 더블 치즈 소스, 그리고 시원한 탄산을 한 번에. 든든하게 즐길 수 있는 조합이에요.", + price: 9800, + originalPrice: 11500, + memberBenefit: "치즈 소스 리필 무료", + heroImageName: nil, + calories: 680, + sugar: 12, + caffeine: nil, + waitingTimeText: "혼잡 시간대 약 8분", + pickupInfoText: "테이크아웃 카운터", + highlights: [ + Highlight(icon: "percent", text: "오늘만 15%"), + Highlight(icon: "bolt", text: "단품도 가능") + ], + isBest: false, + isRecommended: false, + discountRate: 15, + isSoldOut: true + ), + MenuItemModel( + category: .drink, + name: "시그니처 아이스티", + summary: "히비스커스 & 베리 블렌딩", + detailDescription: "메가박스 티소믈리에가 블렌딩한 산뜻한 아이스티. 은은한 히비스커스에 베리향을 더했습니다.", + price: 6300, + originalPrice: nil, + memberBenefit: "2잔 구매 시 1천원 할인", + heroImageName: nil, + calories: 120, + sugar: 18, + caffeine: 15, + waitingTimeText: "바로 제조되어요", + pickupInfoText: "음료 스테이션", + highlights: [ + Highlight(icon: "drop", text: "리필 가능"), + Highlight(icon: "snowflake", text: "얼음 가득") + ], + isBest: false, + isRecommended: true, + discountRate: nil, + isSoldOut: false + ), + MenuItemModel( + category: .dessert, + name: "미니 티라미수 컵", + summary: "마스카포네 크림 층층이", + detailDescription: "부드러운 마스카포네와 에스프레소 시럽을 겹겹이 쌓아 상영 전 달콤한 휴식을 선사합니다.", + price: 7200, + originalPrice: nil, + memberBenefit: "디저트 2개 구매 시 10% 할인", + heroImageName: nil, + calories: 430, + sugar: 32, + caffeine: 25, + waitingTimeText: "냉장 진열 즉시 수령", + pickupInfoText: "디저트 냉장 쇼케이스", + highlights: [ + Highlight(icon: "cup.and.saucer", text: "커피와 페어링"), + Highlight(icon: "gift", text: "기프트 포장") + ], + isBest: false, + isRecommended: false, + discountRate: nil, + isSoldOut: false + ) + ] +} + +extension MenuItemModel { + static let detailPageItems: [MenuItemModel] = [ + MenuItemModel( + category: .combo, + name: "싱글 콤보", + summary: "팝콘 + 탄산", + detailDescription: "", + price: 10900, + originalPrice: nil, + memberBenefit: "", + heroImageName: "싱글 콤보", + calories: nil, + sugar: nil, + caffeine: nil, + waitingTimeText: "", + pickupInfoText: "", + highlights: [], + isBest: true, + isRecommended: false, + discountRate: nil, + isSoldOut: false + ), + MenuItemModel( + category: .combo, + name: "러브 콤보", + summary: "팝콘 + 탄산", + detailDescription: "", + price: 10900, + originalPrice: nil, + memberBenefit: "", + heroImageName: "러브 콤보", + calories: nil, + sugar: nil, + caffeine: nil, + waitingTimeText: "", + pickupInfoText: "", + highlights: [], + isBest: true, + isRecommended: false, + discountRate: nil, + isSoldOut: false + ), + MenuItemModel( + category: .combo, + name: "더블 콤보", + summary: "팝콘 2 + 탄산", + detailDescription: "", + price: 24900, + originalPrice: nil, + memberBenefit: "", + heroImageName: "더블 콤보", + calories: nil, + sugar: nil, + caffeine: nil, + waitingTimeText: "", + pickupInfoText: "", + highlights: [], + isBest: true, + isRecommended: false, + discountRate: nil, + isSoldOut: false + ), + MenuItemModel( + category: .combo, + name: "러브 콤보 패키지", + summary: "스페셜 패키지", + detailDescription: "", + price: 32000, + originalPrice: nil, + memberBenefit: "", + heroImageName: "러브 콤보 패키지", + calories: nil, + sugar: nil, + caffeine: nil, + waitingTimeText: "", + pickupInfoText: "", + highlights: [], + isBest: true, + isRecommended: false, + discountRate: nil, + isSoldOut: false + ), + MenuItemModel( + category: .combo, + name: "패밀리 콤보 패키지", + summary: "패밀리 세트", + detailDescription: "", + price: 47000, + originalPrice: nil, + memberBenefit: "", + heroImageName: "패밀리 콤보 패키지", + calories: nil, + sugar: nil, + caffeine: nil, + waitingTimeText: "", + pickupInfoText: "", + highlights: [], + isBest: false, + isRecommended: false, + discountRate: nil, + isSoldOut: false + ), + MenuItemModel( + category: .snack, + name: "메가박스 오리지널 티켓북 시즌4 돌비시네마 에디션 단품", + summary: "시즌4 돌비 에디션", + detailDescription: "", + price: 10900, + originalPrice: nil, + memberBenefit: "", + heroImageName: "메가박스 오리지널 티켓북 시즌4 돌비시네마 에디션 단품", + calories: nil, + sugar: nil, + caffeine: nil, + waitingTimeText: "", + pickupInfoText: "", + highlights: [], + isBest: false, + isRecommended: true, + discountRate: nil, + isSoldOut: false + ), + MenuItemModel( + category: .snack, + name: "디즈니 픽사 포스터", + summary: "컬렉션 굿즈", + detailDescription: "", + price: 15900, + originalPrice: nil, + memberBenefit: "", + heroImageName: "디즈니 픽사 포스터", + calories: nil, + sugar: nil, + caffeine: nil, + waitingTimeText: "", + pickupInfoText: "", + highlights: [], + isBest: false, + isRecommended: false, + discountRate: nil, + isSoldOut: true + ), + MenuItemModel( + category: .snack, + name: "인사이드아웃2 감정", + summary: "리미티드 굿즈", + detailDescription: "", + price: 29900, + originalPrice: 35900, + memberBenefit: "", + heroImageName: "인사이드아웃2 감정", + calories: nil, + sugar: nil, + caffeine: nil, + waitingTimeText: "", + pickupInfoText: "", + highlights: [], + isBest: false, + isRecommended: false, + discountRate: 20, + isSoldOut: false + ) + ] +} + diff --git a/Sources/MobileOrderDetailView.swift b/Sources/MobileOrderDetailView.swift new file mode 100644 index 0000000..6db6004 --- /dev/null +++ b/Sources/MobileOrderDetailView.swift @@ -0,0 +1,196 @@ +import SwiftUI + +struct MobileOrderDetailView: View { + let item: MenuItemModel + + @Environment(\.dismiss) private var dismiss + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 16), count: 2) + + private var displayItems: [MenuItemModel] { + var list = MenuItemModel.detailPageItems + if let matched = list.first(where: { $0.name == item.name }), + let index = list.firstIndex(of: matched) { + list.remove(at: index) + list.insert(matched, at: 0) + } + return list + } + + var body: some View { + VStack(spacing: 0) { + topBar + + Divider() + .background(Color(.systemGray5)) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + TheaterChangeBar( + currentTheater: "강남", + subTitle: "", + actionTitle: "극장 변경", + action: { } + ) + .theaterChangeBarTheme(.outline) + + LazyVGrid(columns: columns, spacing: 20) { + ForEach(displayItems) { gridItem in + MenuGridCard(item: gridItem) + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 28) + } + } + .background(Color.white.ignoresSafeArea()) + .navigationBarBackButtonHidden(true) + } + + private var topBar: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.title3.bold()) + .foregroundStyle(.black) + .padding(10) + } + + Spacer() + + Text("바로주문") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color.black) + + Spacer() + + Button { + // cart action placeholder + } label: { + Image(systemName: "cart") + .font(.title3) + .foregroundStyle(.black) + .padding(10) + } + } + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 8) + .background(Color.white) + } +} + +private struct MenuGridCard: View { + let item: MenuItemModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ZStack(alignment: .topLeading) { + MenuItemPreviewImage( + imageName: item.heroImageName, + cornerRadius: 16, + height: 190 + ) + + statusBadge(for: item) + .padding(10) + + if item.isSoldOut { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.55)) + .overlay( + Text("품절") + .font(.headline) + .foregroundStyle(.white) + ) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(.system(size: 13)) + .foregroundStyle(.black) + .lineLimit(2) + + HStack(spacing: 6) { + Text("\(item.formattedPrice)원") + .font(.system(size: 14, weight: .semibold)) + + if let origin = item.originalPrice { + Text("\(origin.formattedWithComma)원") + .font(.system(size: 11)) + .foregroundStyle(Color.gray) + .strikethrough() + } + } + } + } + } + +} + +@ViewBuilder +private func statusBadge(for item: MenuItemModel) -> some View { + if item.isBest { + badgeLabel(text: "BEST", color: Color(red: 0.93, green: 0.3, blue: 0.34)) + } else if item.isRecommended { + badgeLabel(text: "추천", color: Color(red: 0.14, green: 0.48, blue: 0.78)) + } else { + EmptyView() + } +} + +private func badgeLabel(text: String, color: Color) -> some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) +} + +private struct MenuItemPreviewImage: View { + let imageName: String? + let cornerRadius: CGFloat + let height: CGFloat + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color(red: 0.97, green: 0.97, blue: 0.97)) + if let imageName { + Image(imageName) + .resizable() + .scaledToFit() + .padding(8) + } else { + ImagePlaceholder() + } + } + .frame(height: height) + } +} + +private struct ImagePlaceholder: View { + var body: some View { + VStack(spacing: 6) { + Image(systemName: "photo") + .font(.title2) + Text("이미지를\n추가해주세요") + .font(.caption) + .multilineTextAlignment(.center) + } + .foregroundStyle(Color.gray) + } +} + +#Preview { + NavigationStack { + MobileOrderDetailView(item: MenuItemModel.sampleItems.first!) + } +} + diff --git a/Sources/MobileOrderView.swift b/Sources/MobileOrderView.swift new file mode 100644 index 0000000..59bd8ac --- /dev/null +++ b/Sources/MobileOrderView.swift @@ -0,0 +1,365 @@ +import SwiftUI + +struct MobileOrderView: View { + + @State private var selectedTheater: String = "강남" + @State private var selectedItem: MenuItemModel? + + private let promoCards: [PromoCardModel] = [ + .primary(title: "바로 주문", subtitle: "이제 줄서지 말고\n모바일로 주문하고 픽업!", icon: "popcorn"), + .secondary(title: "스토어 교환권", icon: "ticket.fill"), + .secondary(title: "선물하기", icon: "gift.fill") + ] + + private let deliveryCard = DeliveryCardModel( + title: "어디서든 팝콘 만나기", + subtitle: "팝콘 콜라 스낵 모든 메뉴 배달 가능!", + icon: "figure.walk.motion" + ) + + private let recommendedMenuOrder = ["싱글 콤보", "러브 콤보", "디즈니 픽사 포스터"] + private let bestMenuOrder = [ + "더블 콤보", + "러브 콤보 패키지", + "패밀리 콤보 패키지", + ] + + private var recommendedMenus: [MenuItemModel] { + orderedDetailItems(by: recommendedMenuOrder) + } + + private var bestMenus: [MenuItemModel] { + orderedDetailItems(by: bestMenuOrder) + } + + private func orderedDetailItems(by order: [String]) -> [MenuItemModel] { + let lookup = Dictionary(uniqueKeysWithValues: MenuItemModel.detailPageItems.map { ($0.name, $0) }) + return order.compactMap { lookup[$0] } + } + + var body: some View { + NavigationStack { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 28) { + logoHeader + + TheaterChangeBar( + currentTheater: selectedTheater, + subTitle: "", + actionTitle: "극장 변경", + action: { } + ) + .theaterChangeBarTheme(.highlight) + + promoSection + + deliverySection + + menuSection( + title: "추천 메뉴", + subtitle: "영화 볼때 뭐먹지 고민될 땐 추천 메뉴!", + items: recommendedMenus + ) + + menuSection( + title: "베스트 메뉴", + subtitle: "영화 볼때 뭐먹지 고민될 때 베스트 메뉴!", + items: bestMenus + ) + } + .padding(.horizontal, 20) + .padding(.vertical, 24) + } + .background(Color.white.ignoresSafeArea()) + .navigationDestination(item: $selectedItem) { item in + MobileOrderDetailView(item: item) + } + .navigationTitle("") + .navigationBarHidden(true) + } + } + + private var logoHeader: some View { + HStack { + Image("megaboxLogo2") + Spacer() + } + } + + private var promoSection: some View { + GeometryReader { proxy in + let totalWidth = proxy.size.width + let primaryWidth = totalWidth * 0.58 + let secondaryWidth = totalWidth - primaryWidth - 12 + + HStack(alignment: .top, spacing: 12) { + if let first = promoCards.first { + PrimaryPromoCard(card: first) + .frame(width: primaryWidth) + .onTapGesture { + navigateToDefaultDetailItem() + } + } + + VStack(spacing: 12) { + ForEach(promoCards.dropFirst()) { card in + SecondaryPromoCard(card: card) + .frame(width: secondaryWidth, height: 145) + } + } + } + } + .frame(height: 320) + } + + private var deliverySection: some View { + DeliveryInfoCard(card: deliveryCard) + } + + private func menuSection(title: String, subtitle: String, items: [MenuItemModel]) -> some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.title3.bold()) + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(items) { item in + MenuThumbnailCard(item: item) + .onTapGesture { + selectedItem = item + } + } + } + .padding(.vertical, 4) + } + } + } + + private func navigateToDefaultDetailItem() { + if let target = MenuItemModel.detailPageItems.first { + selectedItem = target + } else if let fallback = MenuItemModel.sampleItems.first { + selectedItem = fallback + } + } +} + +// MARK: - Models + +private struct PromoCardModel: Identifiable { + enum CardType { + case primary + case secondary + } + + let id = UUID() + let type: CardType + let title: String + let subtitle: String + let icon: String + + static func primary(title: String, subtitle: String, icon: String) -> PromoCardModel { + PromoCardModel(type: .primary, title: title, subtitle: subtitle, icon: icon) + } + + static func secondary(title: String, icon: String) -> PromoCardModel { + PromoCardModel(type: .secondary, title: title, subtitle: "", icon: icon) + } +} + +private struct DeliveryCardModel { + let title: String + let subtitle: String + let icon: String +} + +// MARK: - Promo Cards + +private struct PrimaryPromoCard: View { + let card: PromoCardModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(card.title) + .font(.system(size: 24, weight: .bold)) + Text(card.subtitle) + .font(.footnote) + .foregroundStyle(Color.gray) + } + + Spacer() + + Image(systemName: card.icon) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .foregroundStyle(Color.black.opacity(0.8)) + } + .padding(16) + .frame(maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color(red: 0.79, green: 0.77, blue: 0.77), lineWidth: 1) + ) + } +} + +private struct SecondaryPromoCard: View { + let card: PromoCardModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(card.title) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.black) + + Spacer() + + Image(systemName: card.icon) + .resizable() + .scaledToFit() + .frame(width: 46, height: 46) + .foregroundStyle(.black) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color(red: 0.79, green: 0.77, blue: 0.77), lineWidth: 1) + ) + } +} + +private struct DeliveryInfoCard: View { + let card: DeliveryCardModel + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(card.title) + .font(.system(size: 22, weight: .bold)) + Text(card.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: card.icon) + .resizable() + .scaledToFit() + .frame(width: 50, height: 50) + .foregroundStyle(.black.opacity(0.7)) + } + .padding(18) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color(red: 0.79, green: 0.77, blue: 0.77), lineWidth: 1) + ) + } +} + +// MARK: - Menu Thumbnails + +private struct MenuThumbnailCard: View { + let item: MenuItemModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .topLeading) { + MenuItemPreviewImage( + imageName: item.heroImageName, + cornerRadius: 12, + height: 160 + ) + + } + + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(.subheadline) + .foregroundStyle(.black) + .lineLimit(1) + HStack(spacing: 6) { + Text("\(item.formattedPrice)원") + .font(.callout.bold()) + .foregroundStyle(.black) + if let origin = item.originalPrice { + Text("\(origin.formattedWithComma)원") + .font(.caption2) + .foregroundStyle(.gray) + .strikethrough() + } + } + } + } + .frame(width: 160, alignment: .leading) + } +} + +private struct MenuItemPreviewImage: View { + let imageName: String? + let cornerRadius: CGFloat + let height: CGFloat + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color(red: 0.97, green: 0.97, blue: 0.97)) + if let imageName { + Image(imageName) + .resizable() + .scaledToFit() + .padding(8) + } else { + ImagePlaceholder() + } + } + .frame(height: height) + } +} + +@ViewBuilder +private func statusBadge(for item: MenuItemModel) -> some View { + if item.isBest { + badgeLabel(text: "BEST", color: Color(red: 0.93, green: 0.3, blue: 0.34)) + } else if item.isRecommended { + badgeLabel(text: "추천", color: Color(red: 0.14, green: 0.48, blue: 0.78)) + } else { + EmptyView() + } +} + +private func badgeLabel(text: String, color: Color) -> some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) +} + +private struct ImagePlaceholder: View { + var body: some View { + VStack(spacing: 6) { + Image(systemName: "photo") + .font(.title2) + Text("이미지를\n추가해주세요") + .font(.caption) + .multilineTextAlignment(.center) + } + .foregroundStyle(Color.gray) + } +} + +#Preview { + MobileOrderView() +} + diff --git a/Sources/Movie.swift b/Sources/Movie.swift new file mode 100644 index 0000000..24638d4 --- /dev/null +++ b/Sources/Movie.swift @@ -0,0 +1,15 @@ +// +// Movie.swift +// megabox +// +// Created by 문인성 on 10/9/25. +// + +import Foundation + +struct movie: Identifiable { + let id = UUID() + let title: String + let moviegoer: String + let imageNmae2: String +} diff --git a/Sources/MovieDetailView.swift b/Sources/MovieDetailView.swift new file mode 100644 index 0000000..c691185 --- /dev/null +++ b/Sources/MovieDetailView.swift @@ -0,0 +1,50 @@ +// +// MovieDeatailView.swift +// megabox +// +// Created by 문인성 on 10/9/25. +// + +import SwiftUI + +struct MovieDetailView: View { + let movie: Movie + + var body: some View { + ScrollView { + VStack(spacing: 20) { + Image(movie.imageName2) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + + Text("F1 더 무비") + Text("F1: The Movie") + Text(""" + 최고가 되지 못한 전설 VS 최고가 되고 싶은 루키 + 한때 주목받는 유망주였지만 끔찍한 사고로 F1에서 우승하지 못하고 + 한순간에 추락한 드라이버 ‘손; 헤이스'(브래드 피트). + 그의 오랜 동료인 ‘루벤 세르반테스'(하비에르 바르뎀)에게 + 레이싱 복귀를 제안받으며 최하위 팀인 APGXP에 합류한다. + """) + Spacer() + } + + } + .navigationTitle(movie.title) + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + let sampleMovie = Movie( + title: "F1 더 무비", + moviegoer: "누적관객수 10만", + imageName: "f1", + imageName2: "f1 2" + ) + + NavigationStack { + MovieDetailView(movie: sampleMovie) + } +} diff --git a/Sources/Reservation.swift b/Sources/Reservation.swift new file mode 100644 index 0000000..8f58f60 --- /dev/null +++ b/Sources/Reservation.swift @@ -0,0 +1,103 @@ +// +// Untitled.swift +// megabox +// +// Created by 문인성 on 10/9/25. +// + +import SwiftUI + +struct Reservation: View { + private let imageNames: [String] = [ + "poster1", + "poster2", + "poster3", + "poster4", + "poster5", + "poster6", + "poster7", + "poster8" + ] + + var body: some View { + ZStack { + Rectangle().fill(Color("purple04")) + .frame(height: 125) + Text("영화별 예매") + .foregroundStyle(Color.white) + } + + HStack { + ZStack { + + RoundedRectangle(cornerRadius: 20) + .fill(Color.orange) + .frame(width: 50, height: 50) + + Text("15") + .font(.bold24) + .multilineTextAlignment(.center) + .foregroundColor(.white) + } + .padding(.leading, 16) + + HStack { + Text("어쩔 수가 없다") + .font(.bold18) + .padding(.leading, 37) + Spacer() + Text("전체 영화") + .padding(10) + .background(.white) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .inset(by: 0.5) + .stroke(Color(red: 0.84, green: 0.84, blue: 0.84), lineWidth: 1) + + ) + } + .padding(.trailing, 16) + + } + Spacer() + + VStack(alignment: .leading, spacing: 10) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 15) { + ForEach(0.. Void + + @Environment(\.theaterChangeBarTheme) private var theme + + var body: some View { + HStack(spacing: 16) { + if theme.isCompact { + HStack(spacing: 10) { + if let icon = theme.iconSystemName { + Image(systemName: icon) + .font(.headline) + .foregroundStyle(theme.primaryText) + } + Text(currentTheater) + .font(.headline) + .foregroundStyle(theme.primaryText) + } + } else { + VStack(alignment: .leading, spacing: 4) { + Text("현재 선택한 극장") + .font(.caption) + .foregroundStyle(theme.secondaryText) + Text(currentTheater) + .font(.title3.bold()) + .foregroundStyle(theme.primaryText) + if !subTitle.isEmpty { + Text(subTitle) + .font(.footnote) + .foregroundStyle(theme.secondaryText) + } + } + } + + Spacer() + + Button(actionTitle) { + action() + } + .font(.headline) + .padding(.horizontal, theme.isCompact ? 12 : 16) + .padding(.vertical, theme.isCompact ? 8 : 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(theme.buttonBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(theme.isCompact ? theme.buttonForeground : .clear, lineWidth: theme.isCompact ? 1 : 0) + ) + .foregroundStyle(theme.buttonForeground) + } + .padding(theme.isCompact ? 16 : 20) + .background( + RoundedRectangle(cornerRadius: theme.isCompact ? 12 : 28, style: .continuous) + .fill(theme.background) + ) + .overlay( + RoundedRectangle(cornerRadius: theme.isCompact ? 12 : 28, style: .continuous) + .stroke(theme.border.opacity(0.12), lineWidth: 1) + ) + } +} + +// MARK: - Theme + +struct TheaterChangeBarTheme { + let background: Color + let primaryText: Color + let secondaryText: Color + let buttonBackground: Color + let buttonForeground: Color + let border: Color + let isCompact: Bool + let iconSystemName: String? + + static let mobileOrder = TheaterChangeBarTheme( + background: Color.white, + primaryText: Color.black, + secondaryText: Color.gray, + buttonBackground: Color.black, + buttonForeground: Color.white, + border: Color.black, + isCompact: false, + iconSystemName: nil + ) + + static let detail = TheaterChangeBarTheme( + background: Color(red: 0.11, green: 0.12, blue: 0.15), + primaryText: Color.white, + secondaryText: Color.white.opacity(0.8), + buttonBackground: Color.white, + buttonForeground: Color.black, + border: Color.white, + isCompact: false, + iconSystemName: nil + ) + + static let highlight = TheaterChangeBarTheme( + background: Color(red: 0.4, green: 0.05, blue: 0.82), + primaryText: Color.white, + secondaryText: Color.white.opacity(0.8), + buttonBackground: Color.clear, + buttonForeground: Color.white, + border: Color.white, + isCompact: true, + iconSystemName: "mappin.and.ellipse" + ) + + static let outline = TheaterChangeBarTheme( + background: Color.white, + primaryText: Color.black, + secondaryText: Color.gray, + buttonBackground: Color.clear, + buttonForeground: Color(red: 0.4, green: 0.05, blue: 0.82), + border: Color(red: 0.4, green: 0.05, blue: 0.82), + isCompact: true, + iconSystemName: "mappin.and.ellipse" + ) +} + +private struct TheaterChangeBarThemeKey: EnvironmentKey { + static let defaultValue: TheaterChangeBarTheme = .mobileOrder +} + +extension EnvironmentValues { + var theaterChangeBarTheme: TheaterChangeBarTheme { + get { self[TheaterChangeBarThemeKey.self] } + set { self[TheaterChangeBarThemeKey.self] = newValue } + } +} + +struct TheaterChangeBarThemeModifier: ViewModifier { + var theme: TheaterChangeBarTheme + + func body(content: Content) -> some View { + content.environment(\.theaterChangeBarTheme, theme) + } +} + +extension View { + func theaterChangeBarTheme(_ theme: TheaterChangeBarTheme) -> some View { + modifier(TheaterChangeBarThemeModifier(theme: theme)) + } +} + diff --git a/Sources/UserInformation.swift b/Sources/UserInformation.swift new file mode 100644 index 0000000..c7c50d0 --- /dev/null +++ b/Sources/UserInformation.swift @@ -0,0 +1,202 @@ +import SwiftUI + +struct UserInformation: View { + + @EnvironmentObject var viewModel: LoginViewModel + + var body: some View { + NavigationStack { + ScrollView { + VStack { + HStack { + Text("\(viewModel.userName)" + "님") + .font(.bold24) + .padding(.leading, 16) + .padding(.trailing, 5) + + Spacer() + + Text("Welcome") + .font(.medium14) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color("tag")) + .cornerRadius(6) + + Spacer() + .frame(minWidth: 135) + + NavigationLink(destination: Text("회원정보 관리 뷰")) { + Text("회원정보") + .font(.semiBold14) + .foregroundStyle(.white) + .frame(width: 64, height: 20) + .padding(.vertical, 4) + .padding(.horizontal, 4) + .background(Color("gray07")) + .cornerRadius(16) + .padding(.trailing, 16) + } + .padding(.top, 59) + + } + HStack { + Text("멤버쉽 포인트") + .font(.semiBold14) + .foregroundStyle(Color("gray04")) + .padding(.leading, 16) + + Text("500P") + .padding(.leading, 9) + Spacer() + } + + Button(action: { + + }) { + HStack { + Text("클럽 멤버쉽") + .font(.semiBold16) + + Image("arrow") + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 8) + .padding(.vertical, 12) + .background( + LinearGradient ( + gradient: Gradient(colors: [ + Color(hex: "#AB8BFF"), + Color(hex: "#8EAEF3"), + Color(hex: "#5DCCEC") + ]), + startPoint: .leading, + endPoint: .trailing) + ) + .cornerRadius(8) + } + .padding(.top, 15) + .padding(.horizontal, 15) + .padding(.bottom, 33) + + HStack { + VStack { + Text("쿠폰") + .font(.semiBold16) + .foregroundStyle(Color("gray02")) + Text("2") + .font(.semiBold18) + } + + Rectangle() + .foregroundColor(Color("gray02")) + .frame(width: 1, height: 31) + .padding(.horizontal, 20) + + VStack { + Text("스토어 교환권") + .font(.semiBold16) + .foregroundStyle(Color("gray02")) + Text("0") + .font(.semiBold18) + } + + Rectangle() + .foregroundColor(Color("gray02")) + .frame(width: 1, height: 31) + .padding(.horizontal, 20) + + VStack { + Text("모바일 티켓") + .font(.semiBold16) + .foregroundStyle(Color("gray02")) + Text("0") + .font(.semiBold18) + } + } + .frame(width: 362, height: 52) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(Color("gray02")), lineWidth: 1) + ) + .padding(.bottom, 33) + + HStack { + VStack { + Image("film_reel") + Text("영화별 예매") + .font(.medium16) + } + .padding(.leading, 16) + Spacer() + + VStack { + Image("pin_map") + Text("극장별 예매") + .font(.medium16) + } + + Spacer() + + VStack { + Image("sofa") + Text("특별관 예매") + .font(.medium16) + } + + Spacer() + + VStack { + Image("popcorn") + Text("모바일오더") + .font(.medium16) + } + .padding(.trailing, 16) + } + + // [수정] 로그아웃 버튼 추가 + Button(action: { + // 뷰모델의 로그아웃 함수를 호출합니다. + viewModel.handleLogout() + }) { + Text("로그아웃") + .font(.semiBold16) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.red) // 로그아웃 버튼 + .cornerRadius(8) + } + .padding(.horizontal, 15) + .padding(.top, 33) // 위 요소와의 간격 + .padding(.bottom, 50) // 하단 여백 + } + } + } + } +} + + +extension Color { + init(hex: String) { + let scanner = Scanner(string: hex) + _ = scanner.scanString("#") // # 문자 제거 + + var rgb: UInt64 = 0 + scanner.scanHexInt64(&rgb) // 16진수를 정수로 변환 + + let r = Double((rgb >> 16) & 0xFF) / 255.0 + let g = Double((rgb >> 8) & 0xFF) / 255.0 + let b = Double((rgb >> 0) & 0xFF) / 255.0 + + self.init(red: r, green: g, blue: b) + } +} + + +#Preview { + UserInformation() + .environmentObject(LoginViewModel()) +} diff --git a/Sources/UserInformationManagement.swift b/Sources/UserInformationManagement.swift new file mode 100644 index 0000000..a88af3e --- /dev/null +++ b/Sources/UserInformationManagement.swift @@ -0,0 +1,87 @@ +// +// UserInformationMangement.swift +// megabox +// +// Created by 문인성 on 9/28/25. +// + +import SwiftUI + +struct UserInformationManagement: View { + + @AppStorage("saved_id") private var savedId: String = "" + @AppStorage("saved_name") private var savedName: String = "" + @State private var currentName: String = "" + + var body: some View { + VStack { + ZStack { + HStack { + Image(systemName: "arrow.left") + .resizable() + .scaledToFit() + .frame(width: 26, height: 22) + + Spacer() + } + + Text("회원정보 관리") + .font(.medium16) + } + .padding(.horizontal, 18) + .padding(.top, 44) + .padding(.bottom, 53) + + + + Text("기본정보") + .font(.bold18) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 16) + .padding(.bottom, 26) + + + VStack(alignment: .leading) { + HStack { + Text(savedId) + .font(.medium18) + .padding(.horizontal, 16) + } + + + Divider() + + HStack { + TextField(savedName, text: $currentName) + .foregroundStyle(.black) + .padding(.horizontal, 16) + + Button(action: { + savedName = currentName + }) { + Text("변경") + .foregroundStyle(Color("gray03")) + .frame(width: 38, height: 20) + .padding(.horizontal, 11) + .padding(.vertical, 5) + .background(.white) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(Color("gray02")), lineWidth: 1) + ) + } + } + Divider() + Spacer() + } + } + + + } +} + + +#Preview { + UserInformationManagement() +}