diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/120 1.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/120 1.png new file mode 100644 index 0000000..aa3dbf5 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/120 1.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..aa3dbf5 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..06b75dc Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..53fdbc6 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..e1a4894 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/20.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..ec18a8a Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/29.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..ca12c94 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40 1.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40 1.png new file mode 100644 index 0000000..7856fd1 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40 1.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40 2.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40 2.png new file mode 100644 index 0000000..7856fd1 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40 2.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..7856fd1 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/58 1.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/58 1.png new file mode 100644 index 0000000..d920adc Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/58 1.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/58.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..d920adc Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/60.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..ad66aa5 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..1f132b2 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/80 1.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/80 1.png new file mode 100644 index 0000000..f889408 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/80 1.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/80.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..f889408 Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/87.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..d81ad3d Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 9221b9b..fc07cee 100644 --- a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,91 +1,109 @@ { "images" : [ { + "filename" : "40.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { + "filename" : "60.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { + "filename" : "58.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { + "filename" : "87.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { + "filename" : "80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { + "filename" : "120.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { + "filename" : "120 1.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { + "filename" : "180.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { + "filename" : "20.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { + "filename" : "40 1.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { + "filename" : "29.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { + "filename" : "58 1.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { + "filename" : "40 2.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { + "filename" : "80 1.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { + "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { + "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { + "filename" : "167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { + "filename" : "appstore.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" diff --git a/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/appstore.png b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/appstore.png new file mode 100644 index 0000000..58c7d4b Binary files /dev/null and b/HotSpot/Resources/Assets.xcassets/AppIcon.appiconset/appstore.png differ diff --git a/HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift b/HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift index 5bed993..9af0c58 100644 --- a/HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift +++ b/HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift @@ -1,3 +1,4 @@ +import Foundation import ComposableArchitecture private enum ShopRepositoryKey: DependencyKey { diff --git a/HotSpot/Sources/Common/Dependency/UserDefaults+Dependency.swift b/HotSpot/Sources/Common/Dependency/UserDefaults+Dependency.swift new file mode 100644 index 0000000..176e72f --- /dev/null +++ b/HotSpot/Sources/Common/Dependency/UserDefaults+Dependency.swift @@ -0,0 +1,76 @@ +import Foundation +import ComposableArchitecture + +private enum UserDefaultsKey: DependencyKey { + static let liveValue = UserDefaults.standard +} + +extension DependencyValues { + var userDefaults: UserDefaults { + get { self[UserDefaultsKey.self] } + set { self[UserDefaultsKey.self] = newValue } + } +} + +// MARK: - Filter Keys +extension UserDefaults { + private enum FilterKey: String { + case range = "range" + case budgets = "budgets" + case genres = "genres" + case wifi = "wifi" + case privateRoom = "privateRoom" + case nonSmoking = "nonSmoking" + case parking = "parking" + case location = "location" + } + + var range: Int { + get { integer(forKey: FilterKey.range.rawValue) == 0 ? 3 : integer(forKey: FilterKey.range.rawValue) } + set { set(newValue, forKey: FilterKey.range.rawValue) } + } + + var location: MapCoordinate { + get { + let lat = double(forKey: "\(FilterKey.location.rawValue)_lat") + let lng = double(forKey: "\(FilterKey.location.rawValue)_lng") + return lat == 0 && lng == 0 + ? MapCoordinate(latitude: 34.6937, longitude: 135.5023) // Osaka coordinates + : MapCoordinate(latitude: lat, longitude: lng) + } + set { + set(newValue.latitude, forKey: "\(FilterKey.location.rawValue)_lat") + set(newValue.longitude, forKey: "\(FilterKey.location.rawValue)_lng") + } + } + + var budgets: [String] { + get { stringArray(forKey: FilterKey.budgets.rawValue) ?? [] } + set { set(newValue, forKey: FilterKey.budgets.rawValue) } + } + + var genres: [String] { + get { stringArray(forKey: FilterKey.genres.rawValue) ?? [] } + set { set(newValue, forKey: FilterKey.genres.rawValue) } + } + + var wifi: Int { + get { integer(forKey: FilterKey.wifi.rawValue) } + set { set(newValue, forKey: FilterKey.wifi.rawValue) } + } + + var privateRoom: Int { + get { integer(forKey: FilterKey.privateRoom.rawValue) } + set { set(newValue, forKey: FilterKey.privateRoom.rawValue) } + } + + var nonSmoking: Int { + get { integer(forKey: FilterKey.nonSmoking.rawValue) } + set { set(newValue, forKey: FilterKey.nonSmoking.rawValue) } + } + + var parking: Int { + get { integer(forKey: FilterKey.parking.rawValue) } + set { set(newValue, forKey: FilterKey.parking.rawValue) } + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Common/Extensions/ShopGenre.swift b/HotSpot/Sources/Common/Extensions/ShopGenre.swift deleted file mode 100644 index dd3b190..0000000 --- a/HotSpot/Sources/Common/Extensions/ShopGenre.swift +++ /dev/null @@ -1,72 +0,0 @@ -import UIKit - -public enum ShopGenre { - public static func name(for genreCode: String) -> String { - switch genreCode { - case "G001": return "居酒屋" - case "G002": return "ダイニングバー・バル" - case "G003": return "創作料理" - case "G004": return "和食" - case "G005": return "洋食" - case "G006": return "イタリアン・フレンチ" - case "G007": return "中華" - case "G008": return "焼肉・ホルモン" - case "G009": return "アジア・エスニック料理" - case "G010": return "各国料理" - case "G011": return "カラオケ・パーティ" - case "G012": return "バー・カクテル" - case "G013": return "ラーメン" - case "G014": return "カフェ・スイーツ" - case "G015": return "その他グルメ" - case "G016": return "お好み焼き・もんじゃ" - case "G017": return "韓国料理" - default: return "その他" - } - } - - public static func color(for genreCode: String) -> UIColor { - switch genreCode { - case "G001": return UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1.0) // 居酒屋 - Red - case "G002": return UIColor(red: 0.6, green: 0.4, blue: 0.8, alpha: 1.0) // ダイニングバー・バル - Purple - case "G003": return UIColor(red: 0.2, green: 0.6, blue: 0.8, alpha: 1.0) // 創作料理 - Blue - case "G004": return UIColor(red: 0.8, green: 0.6, blue: 0.2, alpha: 1.0) // 和食 - Orange - case "G005": return UIColor(red: 0.4, green: 0.8, blue: 0.4, alpha: 1.0) // 洋食 - Green - case "G006": return UIColor(red: 0.8, green: 0.4, blue: 0.6, alpha: 1.0) // イタリアン・フレンチ - Pink - case "G007": return UIColor(red: 0.8, green: 0.2, blue: 0.4, alpha: 1.0) // 中華 - Deep Pink - case "G008": return UIColor(red: 0.4, green: 0.2, blue: 0.2, alpha: 1.0) // 焼肉・ホルモン - Brown - case "G017": return UIColor(red: 0.6, green: 0.2, blue: 0.2, alpha: 1.0) // 韓国料理 - Dark Red - case "G009": return UIColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1.0) // アジア・エスニック料理 - Dark Blue - case "G010": return UIColor(red: 0.4, green: 0.6, blue: 0.2, alpha: 1.0) // 各国料理 - Olive - case "G011": return UIColor(red: 0.8, green: 0.4, blue: 0.2, alpha: 1.0) // カラオケ・パーティ - Orange - case "G012": return UIColor(red: 0.6, green: 0.2, blue: 0.6, alpha: 1.0) // バー・カクテル - Purple - case "G013": return UIColor(red: 0.2, green: 0.8, blue: 0.6, alpha: 1.0) // ラーメン - Teal - case "G016": return UIColor(red: 0.8, green: 0.6, blue: 0.4, alpha: 1.0) // お好み焼き・もんじゃ - Light Brown - case "G014": return UIColor(red: 0.6, green: 0.8, blue: 0.2, alpha: 1.0) // カフェ・スイーツ - Light Green - case "G015": return UIColor(red: 0.4, green: 0.4, blue: 0.4, alpha: 1.0) // その他グルメ - Gray - default: return .black - } - } - - public static func image(for genreCode: String) -> UIImage? { - switch genreCode { - case "G001": return UIImage(systemName: "wineglass.fill") // 居酒屋 - case "G002": return UIImage(systemName: "wineglass") // ダイニングバー・バル - case "G003": return UIImage(systemName: "fork.knife") // 創作料理 - case "G004": return UIImage(systemName: "leaf.fill") // 和食 - case "G005": return UIImage(systemName: "fork.knife.circle") // 洋食 - case "G006": return UIImage(systemName: "fork.knife.circle.fill") // イタリアン・フレンチ - case "G007": return UIImage(systemName: "bowl.fill") // 中華 - case "G008": return UIImage(systemName: "flame.fill") // 焼肉・ホルモン - case "G017": return UIImage(systemName: "bowl") // 韓国料理 - case "G009": return UIImage(systemName: "globe.asia.australia.fill") // アジア・エスニック料理 - case "G010": return UIImage(systemName: "globe") // 各国料理 - case "G011": return UIImage(systemName: "music.mic") // カラオケ・パーティ - case "G012": return UIImage(systemName: "wineglass") // バー・カクテル - case "G013": return UIImage(systemName: "bowl") // ラーメン - case "G016": return UIImage(systemName: "flame") // お好み焼き・もんじゃ - case "G014": return UIImage(systemName: "cup.and.saucer.fill") // カフェ・スイーツ - case "G015": return UIImage(systemName: "questionmark.circle.fill") // その他グルメ - default: return UIImage(systemName: "mappin.circle.fill") - } - } -} \ No newline at end of file diff --git a/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift b/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift index 6eded86..945e83a 100644 --- a/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift +++ b/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift @@ -26,7 +26,7 @@ extension UIImage { return } - let processor = DownsamplingImageProcessor(size: CGSize(width: 200, height: 200)) + let processor = DownsamplingImageProcessor(size: CGSize(width: 100, height: 100)) KingfisherManager.shared.retrieveImage( with: url, options: [ diff --git a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift index 55cd645..7f91415 100644 --- a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift +++ b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift @@ -5,36 +5,32 @@ struct ShopSearchRequestDTO { let lng: Double // Longitude let range: Int // Search range (1–5) let count: Int? // Number of results (1–100) - let keyword: String? // Keyword search - let genre: String? // Genre code - let order: Int? // Order: 1=recommend, 2=popularity + let name: String? // Name search + let genres: [String]? // Genre codes let start: Int? // Starting index for paging - let budget: String? // Budget code - let privateRoom: Bool? // Private room availability - let wifi: Bool? // Wi-Fi availability - let nonSmoking: Bool? // Non-smoking availability - let coupon: Bool? // Coupon availability - let openNow: Bool? // Currently open filter + let budgets: [String]? // Budget codes + let privateRoom: Int // Private room availability (0 or 1) + let wifi: Int // Wi-Fi availability (0 or 1) + let nonSmoking: Int // Non-smoking availability (0 or 1) + let parking: Int // Parking availability (0 or 1) /// Converts the DTO into a dictionary of parameters for Moya or URL encoding var asParameters: [String: Any] { var params: [String: Any] = [ "lat": lat, "lng": lng, - "range": range + "range": range, + "private_room": privateRoom, + "wifi": wifi, + "non_smoking": nonSmoking, + "parking": parking ] if let count = count { params["count"] = count } - if let keyword = keyword { params["keyword"] = keyword } - if let genre = genre { params["genre"] = genre } - if let order = order { params["order"] = order } + if let name = name { params["name"] = name } + if let genres = genres, !genres.isEmpty { params["genre"] = genres.joined(separator: ",") } if let start = start { params["start"] = start } - if let budget = budget { params["budget"] = budget } - if let privateRoom = privateRoom { params["private_room"] = privateRoom ? 1 : 0 } - if let wifi = wifi { params["wifi"] = wifi ? 1 : 0 } - if let nonSmoking = nonSmoking { params["non_smoking"] = nonSmoking ? 1 : 0 } - if let coupon = coupon { params["coupon"] = coupon ? 1 : 0 } - if let openNow = openNow, openNow { params["open"] = "now" } + if let budgets = budgets, !budgets.isEmpty { params["budget"] = budgets.joined(separator: ",") } return params } diff --git a/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift index f07afaa..22b891e 100644 --- a/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift +++ b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift @@ -3,22 +3,19 @@ import Foundation struct ShopDTO: Decodable { let id: String let name: String - let nameKana: String? let address: String - let stationName: String? let lat: Double let lng: Double let access: String let open: String? - let close: String? let photo: Photo let genre: Genre - let catchPhrase: String let budget: Budget? let wifi: String? let nonSmoking: String? let privateRoom: String? - let card: String? + let parking: String? + let urls: Urls struct Photo: Decodable { let pc: PcPhoto @@ -46,33 +43,42 @@ struct ShopDTO: Decodable { case name case catchPhrase = "catch" } + + static func from(code: String) -> HotSpot.Genre? { + return HotSpot.Genre(rawValue: code) + } } struct Budget: Decodable { let code: String let name: String let average: String? + + static func from(code: String) -> HotSpot.Budget? { + return HotSpot.Budget(rawValue: code) + } + } + + struct Urls: Decodable { + let pc: String } enum CodingKeys: String, CodingKey { case id case name - case nameKana = "name_kana" case address - case stationName = "station_name" case lat case lng case access case open - case close case photo case genre - case catchPhrase = "catch" case budget case wifi case nonSmoking = "non_smoking" case privateRoom = "private_room" - case card + case parking + case urls } func toDomain() -> ShopModel { @@ -84,8 +90,14 @@ struct ShopDTO: Decodable { longitude: lng, imageUrl: photo.pc.large, access: access, - openingHours: open, - genreCode: genre.code + openingHours: open ?? "営業時間情報なし", + genre: Genre.from(code: genre.code) ?? .other, + budget: budget.flatMap { Budget.from(code: $0.code) } ?? .from1501to2000, + url: urls.pc, + wifi: wifi == "あり" ? 1 : 0, + privateRoom: privateRoom == "あり" ? 1 : 0, + nonSmoking: nonSmoking == "あり" ? 1 : 0, + parking: parking == "あり" ? 1 : 0 ) } } diff --git a/HotSpot/Sources/Domain/Model/Filter/Budget.swift b/HotSpot/Sources/Domain/Model/Filter/Budget.swift new file mode 100644 index 0000000..8f859e3 --- /dev/null +++ b/HotSpot/Sources/Domain/Model/Filter/Budget.swift @@ -0,0 +1,75 @@ +import UIKit + +public enum Budget: String, CaseIterable { + case under500 = "B009" // ~500円 + case from501to1000 = "B010" // 501~1000円 + case from1001to1500 = "B011" // 1001~1500円 + case from1501to2000 = "B001" // 1501~2000円 + case from2001to3000 = "B002" // 2001~3000円 + case from3001to4000 = "B003" // 3001~4000円 + case from4001to5000 = "B008" // 4001~5000円 + case from5001to7000 = "B004" // 5001~7000円 + case from7001to10000 = "B005" // 7001~10000円 + case from10001to15000 = "B006" // 10001~15000円 + case from15001to20000 = "B012" // 15001~20000円 + case from20001to30000 = "B013" // 20001~30000円 + case over30001 = "B014" // 30001円~ + + public var name: String { + switch self { + case .under500: return "~500円" + case .from501to1000: return "501~1000円" + case .from1001to1500: return "1001~1500円" + case .from1501to2000: return "1501~2000円" + case .from2001to3000: return "2001~3000円" + case .from3001to4000: return "3001~4000円" + case .from4001to5000: return "4001~5000円" + case .from5001to7000: return "5001~7000円" + case .from7001to10000: return "7001~10000円" + case .from10001to15000: return "10001~15000円" + case .from15001to20000: return "15001~20000円" + case .from20001to30000: return "20001~30000円" + case .over30001: return "30001円~" + } + } + + public var minPrice: Int { + switch self { + case .under500: return 0 + case .from501to1000: return 501 + case .from1001to1500: return 1001 + case .from1501to2000: return 1501 + case .from2001to3000: return 2001 + case .from3001to4000: return 3001 + case .from4001to5000: return 4001 + case .from5001to7000: return 5001 + case .from7001to10000: return 7001 + case .from10001to15000: return 10001 + case .from15001to20000: return 15001 + case .from20001to30000: return 20001 + case .over30001: return 30001 + } + } + + public var maxPrice: Int? { + switch self { + case .under500: return 500 + case .from501to1000: return 1000 + case .from1001to1500: return 1500 + case .from1501to2000: return 2000 + case .from2001to3000: return 3000 + case .from3001to4000: return 4000 + case .from4001to5000: return 5000 + case .from5001to7000: return 7000 + case .from7001to10000: return 10000 + case .from10001to15000: return 15000 + case .from15001to20000: return 20000 + case .from20001to30000: return 30000 + case .over30001: return nil + } + } + + public static func from(code: String) -> Budget? { + return Budget(rawValue: code) + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Model/Filter/Feature.swift b/HotSpot/Sources/Domain/Model/Filter/Feature.swift new file mode 100644 index 0000000..c2917ce --- /dev/null +++ b/HotSpot/Sources/Domain/Model/Filter/Feature.swift @@ -0,0 +1,12 @@ +import Foundation + +enum Feature: String, CaseIterable { + case wifi = "Wi-Fiあり" + case privateRoom = "個室あり" + case nonSmoking = "禁煙席あり" + case parking = "駐車場あり" + + var name: String { + return self.rawValue + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Model/Filter/Genre.swift b/HotSpot/Sources/Domain/Model/Filter/Genre.swift new file mode 100644 index 0000000..6250a90 --- /dev/null +++ b/HotSpot/Sources/Domain/Model/Filter/Genre.swift @@ -0,0 +1,108 @@ +import UIKit + +public enum Genre: String, CaseIterable { + case izakaya = "G001" // 居酒屋 + case diningBar = "G002" // ダイニングバー・バル + case creative = "G003" // 創作料理 + case japanese = "G004" // 和食 + case western = "G005" // 洋食 + case italianFrench = "G006" // イタリアン・フレンチ + case chinese = "G007" // 中華 + case yakiniku = "G008" // 焼肉・ホルモン + case asian = "G009" // アジア・エスニック料理 + case international = "G010" // 各国料理 + case karaoke = "G011" // カラオケ・パーティ + case bar = "G012" // バー・カクテル + case ramen = "G013" // ラーメン + case cafe = "G014" // カフェ・スイーツ + case okonomiyaki = "G016" // お好み焼き・もんじゃ + case korean = "G017" // 韓国料理 + case other = "G015" // その他グルメ + + public var name: String { + switch self { + case .izakaya: return "居酒屋" + case .diningBar: return "ダイニングバー・バル" + case .creative: return "創作料理" + case .japanese: return "和食" + case .western: return "洋食" + case .italianFrench: return "イタリアン・フレンチ" + case .chinese: return "中華" + case .yakiniku: return "焼肉・ホルモン" + case .asian: return "アジア・エスニック料理" + case .international: return "各国料理" + case .karaoke: return "カラオケ・パーティ" + case .bar: return "バー・カクテル" + case .ramen: return "ラーメン" + case .cafe: return "カフェ・スイーツ" + case .okonomiyaki: return "お好み焼き・もんじゃ" + case .korean: return "韓国料理" + case .other: return "その他グルメ" + } + } + + public var color: UIColor { + switch self { + case .izakaya: return UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1.0) + case .diningBar: return UIColor(red: 0.6, green: 0.4, blue: 0.8, alpha: 1.0) + case .creative: return UIColor(red: 0.2, green: 0.6, blue: 0.8, alpha: 1.0) + case .japanese: return UIColor(red: 0.8, green: 0.6, blue: 0.2, alpha: 1.0) + case .western: return UIColor(red: 0.4, green: 0.8, blue: 0.4, alpha: 1.0) + case .italianFrench: return UIColor(red: 0.8, green: 0.4, blue: 0.6, alpha: 1.0) + case .chinese: return UIColor(red: 0.8, green: 0.2, blue: 0.4, alpha: 1.0) + case .yakiniku: return UIColor(red: 0.4, green: 0.2, blue: 0.2, alpha: 1.0) + case .asian: return UIColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1.0) + case .international: return UIColor(red: 0.4, green: 0.6, blue: 0.2, alpha: 1.0) + case .karaoke: return UIColor(red: 0.8, green: 0.4, blue: 0.2, alpha: 1.0) + case .bar: return UIColor(red: 0.6, green: 0.2, blue: 0.6, alpha: 1.0) + case .ramen: return UIColor(red: 0.2, green: 0.8, blue: 0.6, alpha: 1.0) + case .cafe: return UIColor(red: 0.6, green: 0.8, blue: 0.2, alpha: 1.0) + case .okonomiyaki: return UIColor(red: 0.8, green: 0.6, blue: 0.4, alpha: 1.0) + case .korean: return UIColor(red: 0.6, green: 0.2, blue: 0.2, alpha: 1.0) + case .other: return UIColor(red: 0.4, green: 0.4, blue: 0.4, alpha: 1.0) + } + } + + public var image: UIImage? { + switch self { + case .izakaya: + return UIImage(systemName: "wineglass.fill") + case .diningBar: + return UIImage(systemName: "fork.knife") + case .creative: + return UIImage(systemName: "sparkles") + case .japanese: + return UIImage(systemName: "leaf.fill") + case .western: + return UIImage(systemName: "fork.knife.circle") + case .italianFrench: + return UIImage(systemName: "fork.knife.circle.fill") + case .chinese: + return UIImage(systemName: "takeoutbag.and.cup.and.straw.fill") + case .yakiniku: + return UIImage(systemName: "flame.fill") + case .asian: + return UIImage(systemName: "globe.asia.australia") + case .international: + return UIImage(systemName: "globe") + case .karaoke: + return UIImage(systemName: "music.mic") + case .bar: + return UIImage(systemName: "wineglass") + case .ramen: + return UIImage(systemName: "leaf") + case .cafe: + return UIImage(systemName: "cup.and.saucer") + case .okonomiyaki: + return UIImage(systemName: "flame") + case .korean: + return UIImage(systemName: "circle.grid.cross") + case .other: + return UIImage(systemName: "questionmark.circle") + } + } + + public static func from(code: String) -> Genre? { + return Genre(rawValue: code) + } +} diff --git a/HotSpot/Sources/Domain/Model/Filter/Range.swift b/HotSpot/Sources/Domain/Model/Filter/Range.swift new file mode 100644 index 0000000..f59cf64 --- /dev/null +++ b/HotSpot/Sources/Domain/Model/Filter/Range.swift @@ -0,0 +1,19 @@ +import Foundation + +enum Range: Int, CaseIterable { + case threeHundredMeters = 1 + case fiveHundredMeters = 2 + case oneKilometer = 3 + case twoKilometers = 4 + case threeKilometers = 5 + + var name: String { + switch self { + case .threeHundredMeters: return "300m" + case .fiveHundredMeters: return "500m" + case .oneKilometer: return "1km" + case .twoKilometers: return "2km" + case .threeKilometers: return "3km" + } + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Model/MapCoordinate.swift b/HotSpot/Sources/Domain/Model/MapCoordinate.swift new file mode 100644 index 0000000..21a790e --- /dev/null +++ b/HotSpot/Sources/Domain/Model/MapCoordinate.swift @@ -0,0 +1,63 @@ +import Foundation +import CoreLocation +import MapKit + +struct MapCoordinate: Equatable { + let latitude: Double + let longitude: Double + + init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + init(coordinate: CLLocationCoordinate2D) { + self.latitude = coordinate.latitude + self.longitude = coordinate.longitude + } + + var clLocationCoordinate2D: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} + +struct MapRegion: Equatable { + let center: MapCoordinate + let span: MapSpan + + init(center: MapCoordinate, span: MapSpan) { + self.center = center + self.span = span + } + + init(region: MKCoordinateRegion) { + self.center = MapCoordinate(coordinate: region.center) + self.span = MapSpan(span: region.span) + } + + var mkCoordinateRegion: MKCoordinateRegion { + MKCoordinateRegion( + center: center.clLocationCoordinate2D, + span: span.mkCoordinateSpan + ) + } +} + +struct MapSpan: Equatable { + let latitudeDelta: Double + let longitudeDelta: Double + + init(latitudeDelta: Double, longitudeDelta: Double) { + self.latitudeDelta = latitudeDelta + self.longitudeDelta = longitudeDelta + } + + init(span: MKCoordinateSpan) { + self.latitudeDelta = span.latitudeDelta + self.longitudeDelta = span.longitudeDelta + } + + var mkCoordinateSpan: MKCoordinateSpan { + MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta) + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Model/PaginationState.swift b/HotSpot/Sources/Domain/Model/PaginationState.swift deleted file mode 100644 index 63ffbc4..0000000 --- a/HotSpot/Sources/Domain/Model/PaginationState.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -struct PaginationState: Equatable { - var currentPage: Int - var isLastPage: Bool - var isLoading: Bool - - init(currentPage: Int = 1, isLastPage: Bool = false, isLoading: Bool = false) { - self.currentPage = currentPage - self.isLastPage = isLastPage - self.isLoading = isLoading - } - - mutating func reset() { - currentPage = 1 - isLastPage = false - isLoading = false - } - - mutating func update(isLastPage: Bool) { - self.isLastPage = isLastPage - } - - mutating func startLoading() { - isLoading = true - } - - mutating func finishLoading() { - isLoading = false - } - - mutating func incrementPage() { - currentPage += 1 - } -} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Model/ShopModel.swift b/HotSpot/Sources/Domain/Model/ShopModel.swift index 082085f..da18180 100644 --- a/HotSpot/Sources/Domain/Model/ShopModel.swift +++ b/HotSpot/Sources/Domain/Model/ShopModel.swift @@ -8,6 +8,30 @@ struct ShopModel: Identifiable, Equatable { let longitude: Double let imageUrl: String let access: String - let openingHours: String? - let genreCode: String + let openingHours: String + let genre: Genre + let budget: Budget + let url: String + let wifi: Int + let privateRoom: Int + let nonSmoking: Int + let parking: Int + + var coordinate: MapCoordinate { + MapCoordinate(latitude: latitude, longitude: longitude) + } + + static func filterVisibleShops(_ shops: [ShopModel], in region: MapRegion) -> [ShopModel] { + shops.filter { shop in + let latMin = region.center.latitude - region.span.latitudeDelta / 2 + let latMax = region.center.latitude + region.span.latitudeDelta / 2 + let lonMin = region.center.longitude - region.span.longitudeDelta / 2 + let lonMax = region.center.longitude + region.span.longitudeDelta / 2 + + return shop.latitude >= latMin && + shop.latitude <= latMax && + shop.longitude >= lonMin && + shop.longitude <= lonMax + } + } } diff --git a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift index 7fb3bc1..498907a 100644 --- a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift +++ b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift @@ -22,16 +22,14 @@ struct InfiniteScrollSearchUseCase { lng: request.lng, range: request.range, count: pageSize, - keyword: request.keyword, - genre: request.genre, - order: request.order, + name: request.name, + genres: request.genres, start: start, - budget: request.budget, + budgets: request.budgets, privateRoom: request.privateRoom, wifi: request.wifi, nonSmoking: request.nonSmoking, - coupon: request.coupon, - openNow: request.openNow + parking: request.parking, ) let response = try await repository.searchShops(request: paginatedRequest) diff --git a/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift b/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift index dfddd83..7ffc2b9 100644 --- a/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift +++ b/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift @@ -13,16 +13,14 @@ struct ShopsUseCase { lng: lng, range: 5, count: nil, - keyword: nil, - genre: nil, - order: nil, + name: nil, + genres: nil, start: nil, - budget: nil, - privateRoom: nil, - wifi: nil, - nonSmoking: nil, - coupon: nil, - openNow: nil + budgets: nil, + privateRoom: 0, + wifi: 0, + nonSmoking: 0, + parking: 0 ) let response = try await repository.searchShops(request: request) diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift index a6ec5ea..332d314 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -5,6 +5,8 @@ import ComposableArchitecture final class AppCoordinator { private let window: UIWindow private let navigationController: UINavigationController + private var errorAlertController: UIAlertController? + private var messageAlertController: UIAlertController? init(window: UIWindow) { self.window = window @@ -27,6 +29,44 @@ final class AppCoordinator { window.makeKeyAndVisible() } + func showError(_ error: ShopError) { + let errorMessage = ShopErrorMessageMapper.message(for: error) + showAlert(title: "エラー", message: errorMessage) + } + + func showMessage(title: String, message: String) { + showAlert(title: title, message: message) + } + + private func showAlert(title: String, message: String) { + // 이미 표시된 알림이 있다면 제거 + errorAlertController?.dismiss(animated: false) + messageAlertController?.dismiss(animated: false) + + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction( + title: "OK", + style: .default, + handler: { [weak self] _ in + self?.errorAlertController = nil + self?.messageAlertController = nil + } + )) + + if title == "エラー" { + errorAlertController = alert + } else { + messageAlertController = alert + } + + navigationController.present(alert, animated: true) + } + func showSearch() { let searchView = SearchView( store: Store( diff --git a/HotSpot/Sources/Presentation/Map/Component/CarouselScrollViewRepresentable.swift b/HotSpot/Sources/Presentation/Map/Component/CarouselScrollViewRepresentable.swift new file mode 100644 index 0000000..c60b51b --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/Component/CarouselScrollViewRepresentable.swift @@ -0,0 +1,124 @@ +import SwiftUI +import UIKit + +struct CarouselScrollViewRepresentable: UIViewRepresentable { + var items: [Item] + var itemWidth: CGFloat + var spacing: CGFloat + @Binding var currentIndex: Int + var content: (Item) -> Content + + func makeCoordinator() -> Coordinator { + Coordinator(items: items, itemWidth: itemWidth, spacing: spacing, currentIndex: $currentIndex, content: content) + } + + func makeUIView(context: Context) -> UICollectionView { + context.coordinator.collectionView + } + + func updateUIView(_ uiView: UICollectionView, context: Context) { + context.coordinator.update(items: items) + + if context.coordinator.currentVisibleIndex != currentIndex { + let indexPath = IndexPath(item: currentIndex, section: 0) + context.coordinator.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + context.coordinator.currentVisibleIndex = currentIndex + } + } + + class Coordinator: NSObject, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource, UIScrollViewDelegate { + var items: [Item] + let itemWidth: CGFloat + let spacing: CGFloat + let content: (Item) -> Content + var currentVisibleIndex: Int + var currentIndex: Binding + + var collectionView: UICollectionView + + init(items: [Item], itemWidth: CGFloat, spacing: CGFloat, currentIndex: Binding, content: @escaping (Item) -> Content) { + self.items = items + self.itemWidth = itemWidth + self.spacing = spacing + self.content = content + self.currentIndex = currentIndex + self.currentVisibleIndex = currentIndex.wrappedValue + + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = spacing + layout.sectionInset = UIEdgeInsets( + top: 0, + left: (UIScreen.main.bounds.width - itemWidth) / 2, + bottom: 0, + right: (UIScreen.main.bounds.width - itemWidth) / 2 + ) + + self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + self.collectionView.showsHorizontalScrollIndicator = false + self.collectionView.decelerationRate = .fast + self.collectionView.backgroundColor = .clear + + super.init() + + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register(CarouselCell.self, forCellWithReuseIdentifier: "CarouselCell") + } + + func update(items: [Item]) { + self.items = items + collectionView.reloadData() + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + items.count + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + CGSize(width: itemWidth, height: collectionView.frame.height) + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CarouselCell", for: indexPath) as? CarouselCell else { + return UICollectionViewCell() + } + + let hostingController = UIHostingController(rootView: content(items[indexPath.item])) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.view.backgroundColor = .clear + + cell.hostingController = hostingController + return cell + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + let cellWidthIncludingSpacing = itemWidth + spacing + + let offsetX = targetContentOffset.pointee.x + scrollView.contentInset.left + let index = Int(round(offsetX / cellWidthIncludingSpacing)) + let newOffsetX = CGFloat(index) * cellWidthIncludingSpacing - scrollView.contentInset.left + + targetContentOffset.pointee = CGPoint(x: newOffsetX, y: 0) + currentIndex.wrappedValue = index + currentVisibleIndex = index + } + } + + class CarouselCell: UICollectionViewCell { + var hostingController: UIHostingController? { + didSet { + oldValue?.view.removeFromSuperview() + if let vc = hostingController { + contentView.addSubview(vc.view) + NSLayoutConstraint.activate([ + vc.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + vc.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + vc.view.topAnchor.constraint(equalTo: contentView.topAnchor), + vc.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + } + } + } +} diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index fc2d635..6957216 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -1,20 +1,65 @@ import SwiftUI import MapKit +import CoreLocation struct MapRepresentableView: UIViewRepresentable { var shops: [ShopModel] - var region: Binding + var region: Binding + var onMarkerSelected: ((Int) -> Void)? - class Coordinator: NSObject, MKMapViewDelegate { + class Coordinator: NSObject, MKMapViewDelegate, CLLocationManagerDelegate { var parent: MapRepresentableView + private let locationManager = CLLocationManager() + private var isFirstLocationUpdate = true init(parent: MapRepresentableView) { self.parent = parent + super.init() + locationManager.delegate = self + locationManager.requestWhenInUseAuthorization() + locationManager.startUpdatingLocation() + } + + func updateShops(_ newShops: [ShopModel]) { + parent.shops = newShops + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last, isFirstLocationUpdate else { return } + + isFirstLocationUpdate = false + let region = MapRegion( + center: MapCoordinate(coordinate: location.coordinate), + span: MapSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + + DispatchQueue.main.async { + self.parent.region.wrappedValue = region + UserDefaults.standard.location = MapCoordinate(coordinate: location.coordinate) + } + + locationManager.stopUpdatingLocation() + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + isFirstLocationUpdate = false + let osakaCoordinate = MapCoordinate(latitude: 34.6937, longitude: 135.5023) + let region = MapRegion( + center: osakaCoordinate, + span: MapSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + + DispatchQueue.main.async { + self.parent.region.wrappedValue = region + UserDefaults.standard.location = osakaCoordinate + } + + locationManager.stopUpdatingLocation() } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { DispatchQueue.main.async { - self.parent.region.wrappedValue = mapView.region + self.parent.region.wrappedValue = MapRegion(region: mapView.region) } } @@ -29,6 +74,15 @@ struct MapRepresentableView: UIViewRepresentable { return nil } + + func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) { + if let shopAnnotation = view.annotation as? ShopAnnotation { + if let index = parent.shops.firstIndex(where: { $0.id == shopAnnotation.shopId }) { + parent.onMarkerSelected?(index) + } + } + mapView.deselectAnnotation(view.annotation, animated: false) + } } func makeCoordinator() -> Coordinator { @@ -39,22 +93,31 @@ struct MapRepresentableView: UIViewRepresentable { let mapView = MKMapView() mapView.delegate = context.coordinator mapView.showsUserLocation = true + mapView.setRegion(region.wrappedValue.mkCoordinateRegion, animated: false) mapView.register(ShopAnnotationView.self, forAnnotationViewWithReuseIdentifier: ShopAnnotationView.reuseIdentifier) return mapView } func updateUIView(_ uiView: MKMapView, context: Context) { + context.coordinator.updateShops(shops) + uiView.removeAnnotations(uiView.annotations) let annotations = shops.map { ShopAnnotation( - coordinate: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude), + coordinate: $0.coordinate.clLocationCoordinate2D, title: $0.name, shopId: $0.id, - genreCode: $0.genreCode + genreCode: $0.genre.rawValue ) } uiView.addAnnotations(annotations) + + // Update map region if it has changed + if uiView.region.center.latitude != region.wrappedValue.center.latitude || + uiView.region.center.longitude != region.wrappedValue.center.longitude { + uiView.setRegion(region.wrappedValue.mkCoordinateRegion, animated: true) + } } } diff --git a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift index 02ce185..4fa9586 100644 --- a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift @@ -9,9 +9,14 @@ class ShopAnnotationView: MKMarkerAnnotationView { guard let shopAnnotation = newValue as? ShopAnnotation else { return } clusteringIdentifier = "Shop" canShowCallout = false - isEnabled = false - markerTintColor = ShopGenre.color(for: shopAnnotation.genreCode) - glyphImage = ShopGenre.image(for: shopAnnotation.genreCode) + isEnabled = true + + if let genre = Genre.from(code: shopAnnotation.genreCode) { + markerTintColor = genre.color + if let originalImage = genre.image { + glyphImage = originalImage.withTintColor(.white, renderingMode: .alwaysTemplate) + } + } } } @@ -29,4 +34,8 @@ class ShopAnnotationView: MKMarkerAnnotationView { frame = CGRect(x: 0, y: 0, width: 40, height: 40) centerOffset = CGPoint(x: 0, y: -frame.size.height / 2) } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(false, animated: false) + } } diff --git a/HotSpot/Sources/Presentation/Map/Component/SnappingScrollView.swift b/HotSpot/Sources/Presentation/Map/Component/SnappingScrollView.swift deleted file mode 100644 index 9fd318e..0000000 --- a/HotSpot/Sources/Presentation/Map/Component/SnappingScrollView.swift +++ /dev/null @@ -1,68 +0,0 @@ -import SwiftUI -import CobyDS - -struct SnappingScrollView: View { - let items: [Item] - let itemWidth: CGFloat - let spacing: CGFloat - let content: (Item) -> Content - - @State private var scrollOffset: CGFloat = 0 - - init( - items: [Item], - itemWidth: CGFloat, - spacing: CGFloat = 8, - @ViewBuilder content: @escaping (Item) -> Content - ) { - self.items = items - self.itemWidth = itemWidth - self.spacing = spacing - self.content = content - } - - var body: some View { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: spacing) { - ForEach(items) { item in - content(item) - .id(item.id) - } - } - .padding(.horizontal, BaseSize.horizantalPadding) - .background( - GeometryReader { geometry in - Color.clear.preference( - key: ScrollOffsetPreferenceKey.self, - value: geometry.frame(in: .named("scroll")).minX - ) - } - ) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in - scrollOffset = offset - } - .gesture( - DragGesture() - .onEnded { _ in - let totalWidth = itemWidth + spacing - let currentIndex = Int(round(scrollOffset / totalWidth)) - let adjustedIndex = max(0, min(currentIndex, items.count - 1)) - - withAnimation(.easeOut(duration: 0.2)) { - proxy.scrollTo(items[adjustedIndex].id, anchor: .center) - } - } - ) - } - } -} - -struct ScrollOffsetPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() - } -} diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 1f0734b..7ecc8aa 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -3,107 +3,70 @@ import CoreLocation import ComposableArchitecture import MapKit -@Reducer -struct MapStore { +struct MapStore: Reducer { @Dependency(\.shopRepository) var shopRepository struct State: Equatable { var shops: [ShopModel] = [] var visibleShops: [ShopModel] = [] - var region: MKCoordinateRegion = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503), - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + var region: MapRegion = MapRegion( + center: MapCoordinate(latitude: 35.6762, longitude: 139.6503), + span: MapSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) ) - var error: String? = nil - var lastFetchedLocation: CLLocationCoordinate2D? = nil + var lastFetchedLocation: MapCoordinate? = nil + var error: ShopError? = nil } - enum Action { - case updateRegion(MKCoordinateRegion) + enum Action: Equatable { + case updateRegion(MapRegion) case fetchShops case updateShops([ShopModel]) - case handleError(Error) + case handleError(ShopError) + case clearError } + enum CancelID { case fetchShops } + var body: some ReducerOf { Reduce { state, action in switch action { + case let .updateRegion(region): state.region = region - - if shouldFetchNewData(state: state, newRegion: region) { - state.lastFetchedLocation = region.center - return .send(.fetchShops) - } - - state.visibleShops = filterVisibleShops(state.shops, in: region) - return .none + state.visibleShops = ShopModel.filterVisibleShops(state.shops, in: region) + state.lastFetchedLocation = region.center + return .send(.fetchShops) case .fetchShops: - return .run { [region = state.region] send in + let lat = state.region.center.latitude + let lng = state.region.center.longitude + + return .run { send in do { let useCase = ShopsUseCase(repository: shopRepository) - let shops = try await useCase.execute( - lat: region.center.latitude, - lng: region.center.longitude - ) + let shops = try await useCase.execute(lat: lat, lng: lng) await send(.updateShops(shops)) - } catch { + } catch let error as ShopError { await send(.handleError(error)) + } catch { + await send(.handleError(.server(message: error.localizedDescription))) } } + .cancellable(id: CancelID.fetchShops, cancelInFlight: true) case let .updateShops(shops): state.shops = shops - state.visibleShops = filterVisibleShops(shops, in: state.region) + state.visibleShops = ShopModel.filterVisibleShops(shops, in: state.region) return .none case let .handleError(error): - state.error = error.localizedDescription + state.error = error return .none - } - } - } - - // MARK: - Helpers - func shouldFetchNewData(state: State, newRegion: MKCoordinateRegion) -> Bool { - guard let lastLocation = state.lastFetchedLocation else { - return true - } - - let distance = CLLocation(latitude: lastLocation.latitude, longitude: lastLocation.longitude) - .distance(from: CLLocation(latitude: newRegion.center.latitude, longitude: newRegion.center.longitude)) - - return distance > 100 - } - func filterVisibleShops(_ shops: [ShopModel], in region: MKCoordinateRegion) -> [ShopModel] { - shops.filter { shop in - let coordinate = CLLocationCoordinate2D(latitude: shop.latitude, longitude: shop.longitude) - let latMin = region.center.latitude - region.span.latitudeDelta / 2 - let latMax = region.center.latitude + region.span.latitudeDelta / 2 - let lonMin = region.center.longitude - region.span.longitudeDelta / 2 - let lonMax = region.center.longitude + region.span.longitudeDelta / 2 - - return coordinate.latitude >= latMin && - coordinate.latitude <= latMax && - coordinate.longitude >= lonMin && - coordinate.longitude <= lonMax + case .clearError: + state.error = nil + return .none + } } } } - -// MARK: - Equatable -extension MapStore.State { - static func == (lhs: MapStore.State, rhs: MapStore.State) -> Bool { - lhs.shops == rhs.shops && - lhs.visibleShops == rhs.visibleShops && - lhs.region.center.latitude == rhs.region.center.latitude && - lhs.region.center.longitude == rhs.region.center.longitude && - lhs.region.span.latitudeDelta == rhs.region.span.latitudeDelta && - lhs.region.span.longitudeDelta == rhs.region.span.longitudeDelta && - lhs.error == rhs.error && - lhs.lastFetchedLocation?.latitude == rhs.lastFetchedLocation?.latitude && - lhs.lastFetchedLocation?.longitude == rhs.lastFetchedLocation?.longitude - } -} diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index 893e5db..bad31f2 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -7,6 +7,7 @@ import Kingfisher struct MapView: View { let store: StoreOf @State private var shopImages: [String: UIImage] = [:] + @State private var currentIndex: Int = 0 @Environment(\.coordinator) private var coordinator var body: some View { @@ -28,33 +29,56 @@ struct MapView: View { region: viewStore.binding( get: { $0.region }, send: { .updateRegion($0) } - ) + ), + onMarkerSelected: { index in + currentIndex = index + } ) .ignoresSafeArea(.all, edges: .bottom) - // Bottom card scroll view - SnappingScrollView( - items: viewStore.visibleShops, - itemWidth: BaseSize.fullWidth - ) { shop in - ThumbnailTileView( - image: shopImages[shop.id], - title: shop.name, - subTitle: nil, - description: shop.access, - subDescription: nil - ) - .frame(width: BaseSize.fullWidth) - .onTapGesture { - coordinator?.showShopDetail(shop) - } - .onAppear { - loadImage(for: shop) + if !viewStore.visibleShops.isEmpty { + CarouselScrollViewRepresentable( + items: viewStore.visibleShops, + itemWidth: BaseSize.fullWidth, + spacing: 8, + currentIndex: $currentIndex + ) { shop in + ThumbnailTileView( + image: $shopImages[shop.id], + title: shop.name, + subTitle: nil, + description: shop.access, + subDescription: nil + ) + .onTapGesture { + coordinator?.showShopDetail(shop) + } + .onAppear { + loadImage(for: shop) + } } + .frame(maxWidth: .infinity) + .frame(height: 120) + .padding(.bottom, 30) } - .padding(.bottom, 30) } } + .onChange(of: viewStore.error) { error in + if let error = error { + coordinator?.showError(error) + viewStore.send(.clearError) + } + } + .onChange(of: viewStore.visibleShops) { shops in + if shops.isEmpty { + coordinator?.showMessage( + title: "お店が見つかりません", + message: "ズームインして再度お試しください" + ) + } + + currentIndex = 0 + } } } diff --git a/HotSpot/Sources/Presentation/Search/Component/EmptyResults.swift b/HotSpot/Sources/Presentation/Search/Component/EmptyResults.swift index 252a1be..90c1541 100644 --- a/HotSpot/Sources/Presentation/Search/Component/EmptyResults.swift +++ b/HotSpot/Sources/Presentation/Search/Component/EmptyResults.swift @@ -1,17 +1,20 @@ import SwiftUI +import CobyDS struct EmptyResults: View { let searchText: String var body: some View { VStack(spacing: 16) { - Image(systemName: "magnifyingglass") + Image(systemName: "exclamationmark.circle") .font(.system(size: 48)) - .foregroundColor(.gray) - Text(searchText.isEmpty ? "검색어를 입력해주세요" : "검색 결과가 없습니다") - .font(.headline) - .foregroundColor(.gray) + .foregroundColor(Color.labelAssistive) + + Text(searchText.isEmpty ? "店舗名で検索してください" : "検索結果が見つかりません") + .font(.pretendard(size: 16, weight: .medium)) + .foregroundColor(Color.labelAssistive) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.backgroundNormalNormal) } -} \ No newline at end of file +} diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift index 9cbbaa0..dfe9095 100644 --- a/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift +++ b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift @@ -1,4 +1,5 @@ import SwiftUI +import CobyDS struct SearchBar: View { let searchText: String @@ -8,24 +9,39 @@ struct SearchBar: View { var body: some View { HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - TextField("검색어를 입력하세요", text: .init( + Image(uiImage: UIImage.icSearch) + .foregroundColor(Color.labelAssistive) + + TextField("店舗名で検索", text: .init( get: { searchText }, set: { onSearch($0) } )) .textFieldStyle(.plain) .focused($isFocused) + + if isSearchFocused { + Button { + isFocused = false + } label: { + Text("キャンセル") + .font(.pretendard(size: 14, weight: .medium)) + .foregroundColor(Color.labelNeutral) + } + .transition(.opacity) + } } .padding(8) - .background(Color(.systemGray6)) + .background(Color.fillNormal) .cornerRadius(8) - .padding(.horizontal) .onChange(of: isFocused) { newValue in - isSearchFocused = newValue + withAnimation(.easeInOut(duration: 0.3)) { + isSearchFocused = newValue + } } .onChange(of: isSearchFocused) { newValue in - isFocused = newValue + withAnimation(.easeInOut(duration: 0.3)) { + isFocused = newValue + } } } } diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift index 9526797..156b04a 100644 --- a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift +++ b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift @@ -3,7 +3,7 @@ import CobyDS import Kingfisher struct SearchResults: View { - let error: String? + let error: ShopError? let searchText: String let shops: [ShopModel] let onSelectShop: (ShopModel) -> Void @@ -13,18 +13,16 @@ struct SearchResults: View { var body: some View { Group { if let error = error { - Text(error) + Text(ShopErrorMessageMapper.message(for: error)) .foregroundColor(.red) - } else if searchText.isEmpty { - EmptyResults(searchText: searchText) } else if shops.isEmpty { EmptyResults(searchText: searchText) } else { ScrollView { - LazyVStack(spacing: 16) { + LazyVStack(spacing: BaseSize.cellVerticalSpacing) { ForEach(shops) { shop in ThumbnailTileView( - image: shopImages[shop.id], + image: $shopImages[shop.id], title: shop.name, subTitle: nil, description: shop.access, @@ -42,17 +40,19 @@ struct SearchResults: View { } } } - .padding() + .padding(.horizontal, BaseSize.horizantalPadding) + .padding(.top, 8) + .padding(.bottom, BaseSize.verticalPadding) } - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - } - ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + ) } private func loadImage(for shop: ShopModel) { diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index bb1e0d2..630752f 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -5,85 +5,71 @@ import ComposableArchitecture @Reducer struct SearchStore { @Dependency(\.shopRepository) var shopRepository - @Dependency(\.locationManager) var locationManager + @Dependency(\.userDefaults) var userDefaults struct State: Equatable { var shops: [ShopModel] = [] var searchText: String = "" - var error: String? = nil - var currentLocation: CLLocationCoordinate2D? - var paginationState: PaginationState = .init() - - var filterState: SearchFilterStore.State = .init() - - static func == (lhs: State, rhs: State) -> Bool { - lhs.shops == rhs.shops && - lhs.searchText == rhs.searchText && - lhs.error == rhs.error && - lhs.currentLocation?.latitude == rhs.currentLocation?.latitude && - lhs.currentLocation?.longitude == rhs.currentLocation?.longitude && - lhs.paginationState == rhs.paginationState && - lhs.filterState == rhs.filterState - } + var error: ShopError? = nil + var currentPage: Int = 1 + var isLastPage: Bool = false } enum Action { - case onAppear - case search(String) - case updateLocation(CLLocationCoordinate2D) + case search + case updateSearchText(String) case updateShops([ShopModel]) case handleError(Error) + case clearError case loadMore - case updatePaginationState(PaginationState) - case updateFilterState(SearchFilterStore.State) + case updatePage(Int, Bool) } var body: some ReducerOf { Reduce { state, action in switch action { - case .onAppear: - return .run { send in - if let location = await locationManager.requestLocation() { - await send(.updateLocation(location.coordinate)) - } - } - - case let .updateLocation(location): - state.currentLocation = location - return .none - case let .updateShops(shops): state.shops = shops return .none case let .handleError(error): - state.error = error.localizedDescription + switch error { + case is URLError: + state.error = .network + case is DecodingError: + state.error = .decoding + default: + state.error = .server(message: error.localizedDescription) + } return .none - - case let .search(text): - guard text != state.searchText else { return .none } + case .clearError: + state.error = nil + return .none + + case let .updateSearchText(text): state.searchText = text - state.paginationState.reset() + return .none + + case .search: + state.currentPage = 1 + state.isLastPage = false return .run { [state] send in do { - let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 34.6937, longitude: 135.5023) let request = ShopSearchRequestDTO( - lat: location.latitude, - lng: location.longitude, - range: state.filterState.selectedDistance, + lat: userDefaults.location.latitude, + lng: userDefaults.location.longitude, + range: userDefaults.range, count: nil, - keyword: text, - genre: state.filterState.selectedCuisine > 0 ? String(state.filterState.selectedCuisine) : nil, - order: nil, + name: state.searchText.isEmpty ? nil : state.searchText, + genres: !userDefaults.genres.isEmpty ? userDefaults.genres : nil, start: nil, - budget: state.filterState.selectedBudget > 0 ? String(state.filterState.selectedBudget) : nil, - privateRoom: state.filterState.hasPrivateRoom ? true : nil, - wifi: state.filterState.hasWiFi ? true : nil, - nonSmoking: state.filterState.isNonSmoking ? true : nil, - coupon: nil, - openNow: nil + budgets: !userDefaults.budgets.isEmpty ? userDefaults.budgets : nil, + privateRoom: userDefaults.privateRoom, + wifi: userDefaults.wifi, + nonSmoking: userDefaults.nonSmoking, + parking: userDefaults.parking ) let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) @@ -93,41 +79,37 @@ struct SearchStore { ) await send(.updateShops(result.shops)) - await send(.updatePaginationState(PaginationState( - currentPage: result.currentPage, - isLastPage: !result.hasMore, - isLoading: false - ))) + await send(.updatePage(result.currentPage, !result.hasMore)) } catch { await send(.handleError(error)) } } case .loadMore: + if state.isLastPage { return .none } + return .run { [state] send in do { - let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 34.6937, longitude: 135.5023) + let location = userDefaults.location let request = ShopSearchRequestDTO( lat: location.latitude, lng: location.longitude, - range: state.filterState.selectedDistance, + range: userDefaults.range, count: nil, - keyword: state.searchText, - genre: state.filterState.selectedCuisine > 0 ? String(state.filterState.selectedCuisine) : nil, - order: nil, + name: state.searchText.isEmpty ? nil : state.searchText, + genres: !userDefaults.genres.isEmpty ? userDefaults.genres : nil, start: nil, - budget: state.filterState.selectedBudget > 0 ? String(state.filterState.selectedBudget) : nil, - privateRoom: state.filterState.hasPrivateRoom ? true : nil, - wifi: state.filterState.hasWiFi ? true : nil, - nonSmoking: state.filterState.isNonSmoking ? true : nil, - coupon: nil, - openNow: nil + budgets: !userDefaults.budgets.isEmpty ? userDefaults.budgets : nil, + privateRoom: userDefaults.privateRoom, + wifi: userDefaults.wifi, + nonSmoking: userDefaults.nonSmoking, + parking: userDefaults.parking ) let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) let result = try await useCase.execute( request: request, - currentPage: state.paginationState.currentPage, + currentPage: state.currentPage, isLoadMore: true ) @@ -135,25 +117,16 @@ struct SearchStore { let newShops = result.shops.filter { !existingShopIds.contains($0.id) } await send(.updateShops(state.shops + newShops)) - await send(.updatePaginationState(PaginationState( - currentPage: result.currentPage, - isLastPage: !result.hasMore, - isLoading: false - ))) + await send(.updatePage(result.currentPage, !result.hasMore)) } catch { await send(.handleError(error)) } } - case let .updatePaginationState(paginationState): - state.paginationState = paginationState + case let .updatePage(page, isLastPage): + state.currentPage = page + state.isLastPage = isLastPage return .none - - case let .updateFilterState(filterState): - state.filterState = filterState - return .run { [state] send in - await send(.search(state.searchText)) - } } } } diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index 5123534..e798140 100644 --- a/HotSpot/Sources/Presentation/Search/SearchView.swift +++ b/HotSpot/Sources/Presentation/Search/SearchView.swift @@ -1,5 +1,4 @@ import SwiftUI -import CoreLocation import CobyDS import ComposableArchitecture @@ -10,7 +9,7 @@ struct SearchView: View { var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - VStack(spacing: 0) { + VStack(spacing: 8) { if !isSearchFocused { TopBarView( leftSide: .left, @@ -18,7 +17,7 @@ struct SearchView: View { coordinator?.pop() }, rightSide: .icon, - rightIcon: UIImage.icMore, + rightIcon: UIImage.icFilter, rightAction: { coordinator?.showSearchFilter() } @@ -27,9 +26,14 @@ struct SearchView: View { SearchBar( searchText: viewStore.searchText, - onSearch: { viewStore.send(.search($0)) }, + onSearch: { text in + viewStore.send(.updateSearchText(text)) + viewStore.send(.search) + }, isSearchFocused: $isSearchFocused ) + .padding(.horizontal, BaseSize.horizantalPadding) + .padding(.top, isSearchFocused ? BaseSize.verticalPadding : 0) SearchResults( error: viewStore.error, @@ -37,17 +41,21 @@ struct SearchView: View { shops: viewStore.shops, onSelectShop: { coordinator?.showShopDetail($0) }, onLoadMore: { - if !viewStore.paginationState.isLastPage { + if !viewStore.isLastPage { viewStore.send(.loadMore) } } ) } + .background(Color.backgroundNormalNormal) .onAppear { - viewStore.send(.onAppear) + viewStore.send(.search) } - .onTapGesture { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + .onChange(of: viewStore.error) { error in + if let error = error { + coordinator?.showError(error) + viewStore.send(.clearError) + } } } } diff --git a/HotSpot/Sources/Presentation/SearchFilter/Component/BudgetSection.swift b/HotSpot/Sources/Presentation/SearchFilter/Component/BudgetSection.swift new file mode 100644 index 0000000..0ce29e9 --- /dev/null +++ b/HotSpot/Sources/Presentation/SearchFilter/Component/BudgetSection.swift @@ -0,0 +1,29 @@ +import SwiftUI +import CobyDS + +struct BudgetSection: View { + let selectedBudgets: [String] + let onBudgetSelected: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("予算") + .font(.pretendard(size: 16, weight: .semibold)) + .foregroundColor(Color.labelNormal) + .padding(.horizontal, BaseSize.horizantalPadding) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Budget.allCases, id: \.self) { budget in + FilterButton( + title: budget.name, + isSelected: selectedBudgets.contains(budget.rawValue), + action: { onBudgetSelected(budget.rawValue) } + ) + } + } + .padding(.horizontal, BaseSize.horizantalPadding) + } + } + } +} diff --git a/HotSpot/Sources/Presentation/SearchFilter/Component/FeaturesSection.swift b/HotSpot/Sources/Presentation/SearchFilter/Component/FeaturesSection.swift new file mode 100644 index 0000000..0a1346d --- /dev/null +++ b/HotSpot/Sources/Presentation/SearchFilter/Component/FeaturesSection.swift @@ -0,0 +1,36 @@ +import SwiftUI +import CobyDS + +struct FeaturesSection: View { + let selectedFeatures: Set + let onFeatureSelected: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("設備") + .font(.pretendard(size: 16, weight: .semibold)) + .foregroundColor(Color.labelNormal) + .padding(.horizontal, BaseSize.horizantalPadding) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Feature.allCases, id: \.self) { feature in + FilterButton( + title: feature.name, + isSelected: selectedFeatures.contains(feature.rawValue), + action: { onFeatureSelected(feature.rawValue) } + ) + } + } + .padding(.horizontal, BaseSize.horizantalPadding) + } + } + } +} + +#Preview { + FeaturesSection( + selectedFeatures: ["Wi-Fiあり", "個室あり", "禁煙席あり", "駐車場あり"], + onFeatureSelected: { _ in } + ) +} diff --git a/HotSpot/Sources/Presentation/SearchFilter/Component/FilterButton.swift b/HotSpot/Sources/Presentation/SearchFilter/Component/FilterButton.swift new file mode 100644 index 0000000..78c6e08 --- /dev/null +++ b/HotSpot/Sources/Presentation/SearchFilter/Component/FilterButton.swift @@ -0,0 +1,28 @@ +import SwiftUI +import CobyDS + +struct FilterButton: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.pretendard(size: 14, weight: .medium)) + .foregroundColor(isSelected ? Color.inverseLabel : Color.labelNormal) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color.labelNormal : Color.fillNormal) + .cornerRadius(16) + } + } +} + +#Preview { + HStack(spacing: 8) { + FilterButton(title: "選択済み", isSelected: true, action: {}) + FilterButton(title: "未選択", isSelected: false, action: {}) + } + .padding() +} diff --git a/HotSpot/Sources/Presentation/SearchFilter/Component/GenreSection.swift b/HotSpot/Sources/Presentation/SearchFilter/Component/GenreSection.swift new file mode 100644 index 0000000..8af19c7 --- /dev/null +++ b/HotSpot/Sources/Presentation/SearchFilter/Component/GenreSection.swift @@ -0,0 +1,29 @@ +import SwiftUI +import CobyDS + +struct GenreSection: View { + let selectedGenres: [String] + let onGenreSelected: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("ジャンル") + .font(.pretendard(size: 16, weight: .semibold)) + .foregroundColor(Color.labelNormal) + .padding(.horizontal, BaseSize.horizantalPadding) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Genre.allCases, id: \.self) { genre in + FilterButton( + title: genre.name, + isSelected: selectedGenres.contains(genre.rawValue), + action: { onGenreSelected(genre.rawValue) } + ) + } + } + .padding(.horizontal, BaseSize.horizantalPadding) + } + } + } +} diff --git a/HotSpot/Sources/Presentation/SearchFilter/Component/RangeSection.swift b/HotSpot/Sources/Presentation/SearchFilter/Component/RangeSection.swift new file mode 100644 index 0000000..e426a9d --- /dev/null +++ b/HotSpot/Sources/Presentation/SearchFilter/Component/RangeSection.swift @@ -0,0 +1,29 @@ +import SwiftUI +import CobyDS + +struct RangeSection: View { + let selectedRange: Int + let onRangeSelected: (Int) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("距離") + .font(.pretendard(size: 16, weight: .semibold)) + .foregroundColor(Color.labelNormal) + .padding(.horizontal, BaseSize.horizantalPadding) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Range.allCases, id: \.self) { range in + FilterButton( + title: range.name, + isSelected: selectedRange == range.rawValue, + action: { onRangeSelected(range.rawValue) } + ) + } + } + .padding(.horizontal, BaseSize.horizantalPadding) + } + } + } +} diff --git a/HotSpot/Sources/Presentation/SearchFilter/SearchFilterStore.swift b/HotSpot/Sources/Presentation/SearchFilter/SearchFilterStore.swift index 01820f8..ac153d2 100644 --- a/HotSpot/Sources/Presentation/SearchFilter/SearchFilterStore.swift +++ b/HotSpot/Sources/Presentation/SearchFilter/SearchFilterStore.swift @@ -3,107 +3,72 @@ import ComposableArchitecture @Reducer struct SearchFilterStore { - private enum UserDefaultsKey { - static let budget = "shop_filter_budget" - static let hasWiFi = "shop_filter_has_wifi" - static let hasPrivateRoom = "shop_filter_has_private_room" - static let isNonSmoking = "shop_filter_is_non_smoking" - static let hasParking = "shop_filter_has_parking" - static let cuisine = "shop_filter_cuisine" - static let distance = "shop_filter_distance" - } - + @Dependency(\.userDefaults) var userDefaults + struct State: Equatable { - var selectedBudget: Int - var hasWiFi: Bool - var hasPrivateRoom: Bool - var isNonSmoking: Bool - var hasParking: Bool - var selectedCuisine: Int - var selectedDistance: Int + var selectedRange: Range + var selectedBudgets: [String] + var selectedGenres: [String] + var selectedFeatures: Set - init() { - let defaults = UserDefaults.standard - self.selectedBudget = defaults.integer(forKey: UserDefaultsKey.budget) - self.hasWiFi = defaults.bool(forKey: UserDefaultsKey.hasWiFi) - self.hasPrivateRoom = defaults.bool(forKey: UserDefaultsKey.hasPrivateRoom) - self.isNonSmoking = defaults.bool(forKey: UserDefaultsKey.isNonSmoking) - self.hasParking = defaults.bool(forKey: UserDefaultsKey.hasParking) - self.selectedCuisine = defaults.integer(forKey: UserDefaultsKey.cuisine) - self.selectedDistance = defaults.integer(forKey: UserDefaultsKey.distance) + init( + selectedRange: Range = Range(rawValue: UserDefaults.standard.range) ?? .oneKilometer, + selectedBudgets: [String] = UserDefaults.standard.budgets, + selectedGenres: [String] = UserDefaults.standard.genres, + selectedFeatures: Set = [] + ) { + self.selectedRange = selectedRange + self.selectedBudgets = selectedBudgets + self.selectedGenres = selectedGenres + self.selectedFeatures = selectedFeatures } } - enum Action { - case updateBudget(Int) - case toggleWiFi - case togglePrivateRoom - case toggleNonSmoking - case toggleParking - case updateCuisine(Int) - case updateDistance(Int) - case resetFilters + enum Action: Equatable { + case updateRange(Range) + case updateBudgets([String]) + case updateGenres([String]) + case updateFeatures(Set) case applyFilters + case resetFilters } + init() {} + var body: some ReducerOf { Reduce { state, action in switch action { - case let .updateBudget(budget): - state.selectedBudget = budget - UserDefaults.standard.set(budget, forKey: UserDefaultsKey.budget) - return .none - - case .toggleWiFi: - state.hasWiFi.toggle() - UserDefaults.standard.set(state.hasWiFi, forKey: UserDefaultsKey.hasWiFi) + case let .updateRange(range): + state.selectedRange = range return .none - case .togglePrivateRoom: - state.hasPrivateRoom.toggle() - UserDefaults.standard.set(state.hasPrivateRoom, forKey: UserDefaultsKey.hasPrivateRoom) + case let .updateBudgets(budgets): + state.selectedBudgets = budgets return .none - case .toggleNonSmoking: - state.isNonSmoking.toggle() - UserDefaults.standard.set(state.isNonSmoking, forKey: UserDefaultsKey.isNonSmoking) + case let .updateGenres(genres): + state.selectedGenres = genres return .none - case .toggleParking: - state.hasParking.toggle() - UserDefaults.standard.set(state.hasParking, forKey: UserDefaultsKey.hasParking) + case let .updateFeatures(features): + state.selectedFeatures = features return .none - case let .updateCuisine(cuisine): - state.selectedCuisine = cuisine - UserDefaults.standard.set(cuisine, forKey: UserDefaultsKey.cuisine) - return .none - - case let .updateDistance(distance): - state.selectedDistance = distance - UserDefaults.standard.set(distance, forKey: UserDefaultsKey.distance) + case .applyFilters: + UserDefaults.standard.range = state.selectedRange.rawValue + UserDefaults.standard.budgets = state.selectedBudgets + UserDefaults.standard.genres = state.selectedGenres + UserDefaults.standard.wifi = state.selectedFeatures.contains("Wi-Fiあり") ? 1 : 0 + UserDefaults.standard.privateRoom = state.selectedFeatures.contains("個室あり") ? 1 : 0 + UserDefaults.standard.nonSmoking = state.selectedFeatures.contains("禁煙席あり") ? 1 : 0 + UserDefaults.standard.parking = state.selectedFeatures.contains("駐車場あり") ? 1 : 0 return .none case .resetFilters: - state.selectedBudget = 0 - state.hasWiFi = false - state.hasPrivateRoom = false - state.isNonSmoking = false - state.hasParking = false - state.selectedCuisine = 0 - state.selectedDistance = 3 - - UserDefaults.standard.removeObject(forKey: UserDefaultsKey.budget) - UserDefaults.standard.removeObject(forKey: UserDefaultsKey.hasWiFi) - UserDefaults.standard.removeObject(forKey: UserDefaultsKey.hasPrivateRoom) - UserDefaults.standard.removeObject(forKey: UserDefaultsKey.isNonSmoking) - UserDefaults.standard.removeObject(forKey: UserDefaultsKey.hasParking) - UserDefaults.standard.removeObject(forKey: UserDefaultsKey.cuisine) - UserDefaults.standard.removeObject(forKey: UserDefaultsKey.distance) - - return .none - - case .applyFilters: + state.selectedRange = .oneKilometer + state.selectedBudgets = [] + state.selectedGenres = [] + state.selectedFeatures = [] return .none } } diff --git a/HotSpot/Sources/Presentation/SearchFilter/SearchFilterView.swift b/HotSpot/Sources/Presentation/SearchFilter/SearchFilterView.swift index 5c356c3..6bded3d 100644 --- a/HotSpot/Sources/Presentation/SearchFilter/SearchFilterView.swift +++ b/HotSpot/Sources/Presentation/SearchFilter/SearchFilterView.swift @@ -1,126 +1,94 @@ import SwiftUI import ComposableArchitecture +import CobyDS struct SearchFilterView: View { let store: StoreOf - @Environment(\.dismiss) private var dismiss + @Environment(\.coordinator) private var coordinator var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - NavigationView { - FilterForm(viewStore: viewStore) - } - } - } -} - -private struct FilterForm: View { - let viewStore: ViewStore - @Environment(\.dismiss) private var dismiss - - var body: some View { - Form { - BudgetSection(viewStore: viewStore) - FeaturesSection(viewStore: viewStore) - CuisineSection(viewStore: viewStore) - DistanceSection(viewStore: viewStore) - } - .navigationTitle("Filter") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Reset") { - viewStore.send(.resetFilters) + VStack(spacing: 0) { + TopBarView( + leftSide: .left, + leftAction: { + coordinator?.pop() + }, + rightSide: .icon, + rightIcon: UIImage.icRefresh, + rightAction: { + viewStore.send(.resetFilters) + } + ) + + ScrollView { + VStack(spacing: 24) { + RangeSection( + selectedRange: viewStore.selectedRange.rawValue, + onRangeSelected: { range in + if let range = Range(rawValue: range) { + viewStore.send(.updateRange(range)) + } + } + ) + + BudgetSection( + selectedBudgets: viewStore.selectedBudgets, + onBudgetSelected: { budgetCode in + var updatedBudgets = viewStore.selectedBudgets + if updatedBudgets.contains(budgetCode) { + updatedBudgets.removeAll { $0 == budgetCode } + } else if updatedBudgets.count < 2 { + updatedBudgets.append(budgetCode) + } + viewStore.send(.updateBudgets(updatedBudgets)) + } + ) + + GenreSection( + selectedGenres: viewStore.selectedGenres, + onGenreSelected: { genreCode in + var updatedGenres = viewStore.selectedGenres + if updatedGenres.contains(genreCode) { + updatedGenres.removeAll { $0 == genreCode } + } else { + updatedGenres.append(genreCode) + } + viewStore.send(.updateGenres(updatedGenres)) + } + ) + + FeaturesSection( + selectedFeatures: viewStore.selectedFeatures, + onFeatureSelected: { feature in + var updatedFeatures = viewStore.selectedFeatures + if updatedFeatures.contains(feature) { + updatedFeatures.remove(feature) + } else { + updatedFeatures.insert(feature) + } + viewStore.send(.updateFeatures(updatedFeatures)) + } + ) + } + .padding(.vertical, 16) } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Apply") { + + Button { viewStore.send(.applyFilters) - dismiss() + coordinator?.pop() + } label: { + Text("フィルターを適用") } + .buttonStyle( + CBButtonStyle( + buttonColor: Color.limeNormal + ) + ) + .padding(.horizontal, BaseSize.horizantalPadding) + .padding(.bottom, BaseSize.verticalPadding) } - } - } -} - -private struct BudgetSection: View { - let viewStore: ViewStore - - var body: some View { - Section(header: Text("Budget")) { - Picker("Budget", selection: viewStore.binding( - get: \.selectedBudget, - send: SearchFilterStore.Action.updateBudget - )) { - Text("Any").tag(0) - Text("¥1,000~").tag(1) - Text("¥3,000~").tag(2) - Text("¥5,000~").tag(3) - Text("¥10,000~").tag(4) - } - } - } -} - -private struct FeaturesSection: View { - let viewStore: ViewStore - - var body: some View { - Section(header: Text("Features")) { - Toggle("WiFi Available", isOn: viewStore.binding( - get: \.hasWiFi, - send: SearchFilterStore.Action.toggleWiFi - )) - Toggle("Private Room", isOn: viewStore.binding( - get: \.hasPrivateRoom, - send: SearchFilterStore.Action.togglePrivateRoom - )) - Toggle("Non-Smoking", isOn: viewStore.binding( - get: \.isNonSmoking, - send: SearchFilterStore.Action.toggleNonSmoking - )) - Toggle("Parking Available", isOn: viewStore.binding( - get: \.hasParking, - send: SearchFilterStore.Action.toggleParking - )) - } - } -} - -private struct CuisineSection: View { - let viewStore: ViewStore - - var body: some View { - Section(header: Text("Cuisine")) { - Picker("Cuisine", selection: viewStore.binding( - get: \.selectedCuisine, - send: SearchFilterStore.Action.updateCuisine - )) { - Text("Any").tag(0) - Text("Japanese").tag(1) - Text("Italian").tag(2) - Text("French").tag(3) - Text("Chinese").tag(4) - } - } - } -} - -private struct DistanceSection: View { - let viewStore: ViewStore - - var body: some View { - Section(header: Text("Distance")) { - Picker("Distance", selection: viewStore.binding( - get: \.selectedDistance, - send: SearchFilterStore.Action.updateDistance - )) { - Text("300m").tag(1) - Text("500m").tag(2) - Text("1km").tag(3) - Text("2km").tag(4) - Text("3km").tag(5) - } + .background(Color.backgroundNormalNormal) } } } @@ -132,4 +100,4 @@ private struct DistanceSection: View { reducer: { SearchFilterStore() } ) ) -} +} diff --git a/HotSpot/Sources/Presentation/ShopDetail/Component/FacilityIcon.swift b/HotSpot/Sources/Presentation/ShopDetail/Component/FacilityIcon.swift new file mode 100644 index 0000000..908dbc6 --- /dev/null +++ b/HotSpot/Sources/Presentation/ShopDetail/Component/FacilityIcon.swift @@ -0,0 +1,58 @@ +import SwiftUI +import CobyDS + +struct FacilityIcon: View { + let systemName: String + let title: String + let isAvailable: Bool + + var body: some View { + VStack(spacing: 8) { + ZStack { + Circle() + .fill(isAvailable ? Color.labelNormal.opacity(0.1) : Color.labelAlternative.opacity(0.1)) + .frame(width: 56, height: 56) + + if isAvailable { + Image(systemName: systemName) + .font(.system(size: 28)) + .foregroundColor(Color.labelNormal) + } else { + ZStack { + Image(systemName: systemName) + .font(.system(size: 28)) + .foregroundColor(Color.labelAlternative) + + Rectangle() + .fill(Color.labelAlternative) + .frame(width: 36, height: 2) + .rotationEffect(.degrees(45)) + } + } + } + + Text(title) + .font(.pretendard(size: 12, weight: .regular)) + .foregroundColor(isAvailable ? Color.labelNormal : Color.labelAlternative) + } + .padding(.horizontal, 4) + } +} + +#Preview { + HStack(spacing: 0) { + FacilityIcon( + systemName: "wifi", + title: "Wi-Fi", + isAvailable: true + ) + .frame(maxWidth: .infinity) + + FacilityIcon( + systemName: "door.left.hand.open", + title: "個室", + isAvailable: false + ) + .frame(maxWidth: .infinity) + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/ShopDetail/Component/InfoRow.swift b/HotSpot/Sources/Presentation/ShopDetail/Component/InfoRow.swift new file mode 100644 index 0000000..ea83daf --- /dev/null +++ b/HotSpot/Sources/Presentation/ShopDetail/Component/InfoRow.swift @@ -0,0 +1,26 @@ +import SwiftUI +import CobyDS + +struct InfoRow: View { + let title: String + let content: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.pretendard(size: 16, weight: .semibold)) + .foregroundColor(Color.labelNormal) + + Text(content) + .font(.pretendard(size: 16, weight: .regular)) + .foregroundColor(Color.labelNeutral) + } + } +} + +#Preview { + InfoRow( + title: "予算", + content: "¥1,501〜¥2,000" + ) +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/ShopDetail/Component/ShopDetailSection.swift b/HotSpot/Sources/Presentation/ShopDetail/Component/ShopDetailSection.swift new file mode 100644 index 0000000..1b3d6bd --- /dev/null +++ b/HotSpot/Sources/Presentation/ShopDetail/Component/ShopDetailSection.swift @@ -0,0 +1,131 @@ +import SwiftUI +import CobyDS + +struct ShopDetailSection: View { + let shop: ShopModel + + var body: some View { + VStack(alignment: .leading, spacing: 32) { + // Basic Info + VStack(alignment: .leading, spacing: 12) { + Text(shop.name) + .font(.pretendard(size: 24, weight: .bold)) + .foregroundColor(Color.labelNormal) + + Text(shop.genre.name) + .font(.pretendard(size: 14, weight: .regular)) + .foregroundColor(Color.labelAlternative) + } + + // Facilities Grid + VStack(alignment: .leading, spacing: 16) { + Text("設備") + .font(.pretendard(size: 16, weight: .semibold)) + .foregroundColor(Color.labelNormal) + + HStack(spacing: 0) { + FacilityIcon( + systemName: "wifi", + title: "Wi-Fi", + isAvailable: shop.wifi == 1 + ) + .frame(maxWidth: .infinity) + + FacilityIcon( + systemName: "door.left.hand.open", + title: "個室", + isAvailable: shop.privateRoom == 1 + ) + .frame(maxWidth: .infinity) + + FacilityIcon( + systemName: "chair", + title: "禁煙席", + isAvailable: shop.nonSmoking == 1 + ) + .frame(maxWidth: .infinity) + + FacilityIcon( + systemName: "car", + title: "駐車場", + isAvailable: shop.parking == 1 + ) + .frame(maxWidth: .infinity) + } + } + + // Business Info + VStack(alignment: .leading, spacing: 20) { + // Budget + InfoRow(title: "予算", content: shop.budget.name) + + // Open Hours + InfoRow(title: "営業時間", content: shop.openingHours) + + // Access + InfoRow(title: "アクセス", content: shop.address) + + // Address + InfoRow(title: "住所", content: shop.access) + } + + // URL Button + if let url = URL(string: shop.url) { + Button { + UIApplication.shared.open(url) + } label: { + HStack(spacing: 8) { + Image(systemName: "link") + + Text("ウェブサイトを見る") + } + } + .buttonStyle( + CBButtonStyle( + buttonType: .outlined, + buttonSize: .medium + ) + ) + } + + // Location Map + VStack(alignment: .leading, spacing: 12) { + Text("位置情報") + .font(.pretendard(size: 16, weight: .semibold)) + .foregroundColor(Color.labelNormal) + + ShopLocationMapView(shop: shop) + .onTapGesture { + let urlString = "http://maps.apple.com/?ll=\(shop.latitude),\(shop.longitude)&q=\(shop.name)" + if let url = URL(string: urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") { + UIApplication.shared.open(url) + } + } + } + } + .padding(.horizontal, BaseSize.horizantalPadding) + .padding(.vertical, BaseSize.verticalPadding) + } +} + +#Preview { + ShopDetailSection( + shop: ShopModel( + id: "test", + name: "テスト店舗", + address: "東京都渋谷区", + latitude: 35.6762, + longitude: 139.6503, + imageUrl: "https://example.com/image.jpg", + access: "渋谷駅から徒歩5分", + openingHours: "11:00-23:00", + genre: .izakaya, + budget: .from1501to2000, + url: "https://example.com", + wifi: 1, + privateRoom: 1, + nonSmoking: 1, + parking: 1 + ) + ) +} diff --git a/HotSpot/Sources/Presentation/ShopDetail/Component/ShopInfoSection.swift b/HotSpot/Sources/Presentation/ShopDetail/Component/ShopInfoSection.swift deleted file mode 100644 index 0f4806b..0000000 --- a/HotSpot/Sources/Presentation/ShopDetail/Component/ShopInfoSection.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SwiftUI -import CobyDS - -struct ShopInfoSection: View { - let shop: ShopModel - - var body: some View { - Group { - VStack(alignment: .leading, spacing: 24) { - // Name and Genre - VStack(alignment: .leading, spacing: 8) { - Text(shop.name) - .font(.title) - .fontWeight(.bold) - - Text(ShopGenre.name(for: shop.genreCode)) - .font(.subheadline) - .foregroundColor(.gray) - } - - // Address - VStack(alignment: .leading, spacing: 4) { - Text("住所") - .font(.headline) - Text(shop.address) - .font(.body) - } - - // Access - VStack(alignment: .leading, spacing: 4) { - Text("アクセス") - .font(.headline) - Text(shop.access) - .font(.body) - } - - // Open Hours - if let openingHours = shop.openingHours { - VStack(alignment: .leading, spacing: 4) { - Text("営業時間") - .font(.headline) - Text(openingHours) - .font(.body) - } - } - - // Location - VStack(alignment: .leading, spacing: 4) { - Text("位置情報") - .font(.headline) - - ShopLocationMapView(shop: shop) - } - } - .padding(.horizontal, BaseSize.horizantalPadding) - .padding(.vertical, BaseSize.verticalPadding) - } - } -} - -#Preview { - ShopInfoSection( - shop: ShopModel( - id: "test", - name: "テスト店舗", - address: "東京都渋谷区", - latitude: 35.6762, - longitude: 139.6503, - imageUrl: "https://example.com/image.jpg", - access: "渋谷駅から徒歩5分", - openingHours: "11:00-23:00", - genreCode: "G001" - ) - ) -} diff --git a/HotSpot/Sources/Presentation/ShopDetail/Component/ShopLocationMapView.swift b/HotSpot/Sources/Presentation/ShopDetail/Component/ShopLocationMapView.swift index eb020c5..5469c15 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/Component/ShopLocationMapView.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/Component/ShopLocationMapView.swift @@ -3,57 +3,52 @@ import MapKit struct ShopLocationMapView: View { let shop: ShopModel - + var body: some View { - GeometryReader { geometry in - let coordinate = CLLocationCoordinate2D( - latitude: shop.latitude, - longitude: shop.longitude - ) - - let region = MKCoordinateRegion( - center: coordinate, - span: MKCoordinateSpan( - latitudeDelta: 0.01, - longitudeDelta: 0.01 - ) - ) - - Map( - coordinateRegion: .constant(region), - interactionModes: [] - ) - .frame(height: 200) - .cornerRadius(8) - .overlay( - Image(systemName: "mappin.circle.fill") - .resizable() - .frame(width: 32, height: 32) - .foregroundColor(Color(uiColor: ShopGenre.color(for: shop.genreCode))) - ) - .onTapGesture { - let placemark = MKPlacemark(coordinate: coordinate) - let mapItem = MKMapItem(placemark: placemark) - mapItem.name = shop.name - mapItem.openInMaps() + let coordinate = CLLocationCoordinate2D( + latitude: shop.latitude, + longitude: shop.longitude + ) + + let region = MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + + Map(coordinateRegion: .constant(region), annotationItems: [ShopPin(shop: shop)]) { pin in + MapAnnotation(coordinate: pin.coordinate) { + ZStack { + Circle() + .fill(Color(uiColor: pin.genre.color)) + .frame(width: 40, height: 40) + + Image(uiImage: pin.genre.image ?? UIImage()) + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .scaledToFit() + .frame(width: 24, height: 24) + } } } - .frame(height: 200) + .frame(height: 300) + .cornerRadius(8) + .onTapGesture { + let placemark = MKPlacemark(coordinate: coordinate) + let mapItem = MKMapItem(placemark: placemark) + mapItem.name = shop.name + mapItem.openInMaps() + } } } -#Preview { - ShopLocationMapView( - shop: ShopModel( - id: "test", - name: "テスト店舗", - address: "東京都渋谷区", - latitude: 35.6762, - longitude: 139.6503, - imageUrl: "https://example.com/image.jpg", - access: "渋谷駅から徒歩5分", - openingHours: "11:00-23:00", - genreCode: "G001" - ) - ) -} \ No newline at end of file +private struct ShopPin: Identifiable { + let id = UUID() + let coordinate: CLLocationCoordinate2D + let genre: Genre + + init(shop: ShopModel) { + self.coordinate = CLLocationCoordinate2D(latitude: shop.latitude, longitude: shop.longitude) + self.genre = shop.genre + } +} diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift index 10a5946..e99d77b 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift @@ -9,24 +9,24 @@ struct ShopDetailView: View { var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - Group { - VStack(spacing: 0) { - TopBarView( - leftSide: .left, - leftAction: { - coordinator?.pop() - } - ) - - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { - ShopImageSection(imageUrl: viewStore.shop.imageUrl) - - ShopInfoSection(shop: viewStore.shop) - } + VStack(spacing: 0) { + TopBarView( + leftSide: .left, + leftAction: { + coordinator?.pop() } + ) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + ShopImageSection(imageUrl: viewStore.shop.imageUrl) + + ShopDetailSection(shop: viewStore.shop) + } + .padding(.bottom, BaseSize.verticalPadding) } } + .background(Color.backgroundNormalNormal) } } } @@ -44,7 +44,13 @@ struct ShopDetailView: View { imageUrl: "https://example.com/image.jpg", access: "渋谷駅から徒歩5分", openingHours: "11:00-23:00", - genreCode: "G001" + genre: .izakaya, + budget: .from1501to2000, + url: "https://example.com", + wifi: 1, + privateRoom: 1, + nonSmoking: 1, + parking: 1 ) ), reducer: { ShopDetailStore() } diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 14271fe..082f03e 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CobyLibrary/CobyDS.git", "state" : { - "revision" : "4dc0668cd4efc2719e4c1e51d288b55b3f0ca2c6", - "version" : "1.7.8" + "revision" : "d889250752085dc9eb8d3770c3a6935cb0518971", + "version" : "1.8.1" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 853f809..7021af9 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -26,7 +26,7 @@ let package = Package( .iOS(.v15) ], dependencies: [ - .package(url: "https://github.com/CobyLibrary/CobyDS.git", from: "1.7.8"), + .package(url: "https://github.com/CobyLibrary/CobyDS.git", from: "1.8.1"), .package(url: "https://github.com/Moya/Moya.git", from: "15.0.3"), .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "1.19.1"), .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.3.2")