diff --git a/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj b/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj index 4b5589a..d85fd05 100644 --- a/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj +++ b/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj @@ -179,9 +179,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = WhereAreYou/WhereAreYou.entitlements; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2ZF894QLGK; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 2ZF894QLGK; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = WhereAreYou/Core/Info.plist; INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "약속이 진행되는 동안 상대방에게 실시간 위치를 공유하기 위해 위치 정보가 필요해요."; @@ -198,6 +200,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = kr.syl.WhereAreYou; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = whereareyou; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -220,9 +224,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = WhereAreYou/WhereAreYou.entitlements; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2ZF894QLGK; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 2ZF894QLGK; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = WhereAreYou/Core/Info.plist; INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "약속이 진행되는 동안 상대방에게 실시간 위치를 공유하기 위해 위치 정보가 필요해요."; @@ -239,6 +245,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = kr.syl.WhereAreYou; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = whereareyou; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; diff --git a/WhereAreYou/WhereAreYou.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/WhereAreYou/WhereAreYou.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/WhereAreYou/WhereAreYou.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift index 96ee880..ad4420d 100644 --- a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift @@ -39,7 +39,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { firestoreSettings.cacheSettings = MemoryCacheSettings() Firestore.firestore().settings = firestoreSettings Database.database().useEmulator(withHost: host, port: 9000) - Storage.storage().useEmulator(withHost: host, port: 9199) #endif } diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift index 474d391..c76ccee 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift @@ -22,6 +22,9 @@ extension DIContainer { } )) + // 프로필 이미지 + register(ProfileImageRepository.self, instance: StorageProfileImageRepository()) + // 위치 let coreLocationRepository = CoreLocationRepository() register(LocationRepository.self, instance: coreLocationRepository) diff --git a/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift b/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift index f014b9a..0389ed9 100644 --- a/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift @@ -33,7 +33,6 @@ final class FirestoreUserRepository: UserRepository { let userData: [String: Any] = [ "nickname": nickname, - // TODO: Firebase Storage 전환 시 실제 URL로 교체 "profileImage": "basic", "defaultTransportMode": "TRANSIT", "locationSharingScope": "ONLY_DURING_APPOINTMENT", @@ -47,8 +46,7 @@ final class FirestoreUserRepository: UserRepository { return User( id: userID, nickname: nickname, - // TODO: Firebase Storage 전환 시 실제 URL로 교체 - profileImage: URL(string: "basic")!, + profileImage: "basic", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, diff --git a/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift b/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift index ca1297c..c8af7eb 100644 --- a/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift @@ -36,10 +36,6 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { private static let currentUserID = "user_me" private static let mockCode = "MOCK1234" - private static func profileImageURL(_ assetName: String) -> URL { - URL(string: "asset://\(assetName)")! - } - private static func makeMockAppointment(appointmentID: String) -> Appointment { let place = Place( id: "place_starbucks_gangnam", @@ -55,7 +51,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { User( id: currentUserID, nickname: "나", - profileImage: profileImageURL("shark"), + profileImage: "shark", defaultTransportMode: .car, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -64,7 +60,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { User( id: "user_minji", nickname: "김민지", - profileImage: profileImageURL("turtle"), + profileImage: "turtle", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -73,7 +69,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { User( id: "user_seoyeon", nickname: "이서연", - profileImage: profileImageURL("shark"), + profileImage: "shark", defaultTransportMode: .car, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -82,7 +78,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { User( id: "user_junwoo", nickname: "박준우", - profileImage: profileImageURL("turtle"), + profileImage: "turtle", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -91,7 +87,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { User( id: "user_gildong", nickname: "최길동", - profileImage: profileImageURL("shark"), + profileImage: "shark", defaultTransportMode: .walk, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -100,7 +96,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { User( id: "user_subin", nickname: "정수빈", - profileImage: profileImageURL("turtle"), + profileImage: "turtle", defaultTransportMode: .car, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, diff --git a/WhereAreYou/WhereAreYou/Data/MockAppointmentInfoRepository.swift b/WhereAreYou/WhereAreYou/Data/MockAppointmentInfoRepository.swift index aae377c..5f0c98d 100644 --- a/WhereAreYou/WhereAreYou/Data/MockAppointmentInfoRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockAppointmentInfoRepository.swift @@ -56,7 +56,7 @@ final class MockAppointmentInfoRepository: AppointmentInfoRepository { private static let currentUser = User( id: "me", nickname: "나", - profileImage: URL(string: "otter")!, + profileImage: "otter", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -66,7 +66,7 @@ final class MockAppointmentInfoRepository: AppointmentInfoRepository { private static let user2 = User( id: "user2", nickname: "김길동", - profileImage: URL(string: "shark")!, + profileImage: "shark", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -76,7 +76,7 @@ final class MockAppointmentInfoRepository: AppointmentInfoRepository { private static let user3 = User( id: "user3", nickname: "홍길동", - profileImage: URL(string: "turtle")!, + profileImage: "turtle", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, diff --git a/WhereAreYou/WhereAreYou/Data/MockChatRepository.swift b/WhereAreYou/WhereAreYou/Data/MockChatRepository.swift index 31418c4..f320dac 100644 --- a/WhereAreYou/WhereAreYou/Data/MockChatRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockChatRepository.swift @@ -66,7 +66,7 @@ final class MockChatRepository: ChatRepository { private static let currentUser = User( id: currentUserID, nickname: "나", - profileImage: URL(string: "https://placeholder")!, + profileImage: "otter", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -76,7 +76,7 @@ final class MockChatRepository: ChatRepository { private static let user2 = User( id: "user2", nickname: "김길동", - profileImage: URL(string: "shark")!, + profileImage: "shark", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -86,7 +86,7 @@ final class MockChatRepository: ChatRepository { private static let user3 = User( id: "user3", nickname: "홍길동", - profileImage: URL(string: "turtle")!, + profileImage: "turtle", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, diff --git a/WhereAreYou/WhereAreYou/Data/MockSharedPlaceRepository.swift b/WhereAreYou/WhereAreYou/Data/MockSharedPlaceRepository.swift index a1b8e78..7a5e52c 100644 --- a/WhereAreYou/WhereAreYou/Data/MockSharedPlaceRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockSharedPlaceRepository.swift @@ -17,7 +17,7 @@ final class MockSharedPlaceRepository: SharedPlaceRepository { private static let currentUser = User( id: currentUserID, nickname: "나", - profileImage: URL(string: "https://placeholder")!, + profileImage: "otter", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -27,7 +27,7 @@ final class MockSharedPlaceRepository: SharedPlaceRepository { private static let user2 = User( id: "user2", nickname: "김길동", - profileImage: URL(string: "shark")!, + profileImage: "shark", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, @@ -37,7 +37,7 @@ final class MockSharedPlaceRepository: SharedPlaceRepository { private static let user3 = User( id: "user3", nickname: "홍길동", - profileImage: URL(string: "turtle")!, + profileImage: "turtle", defaultTransportMode: .transit, locationSharingScope: .onlyDuringAppointment, isNotificationEnabled: true, diff --git a/WhereAreYou/WhereAreYou/Data/StorageProfileImageRepository.swift b/WhereAreYou/WhereAreYou/Data/StorageProfileImageRepository.swift new file mode 100644 index 0000000..8f59d62 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/StorageProfileImageRepository.swift @@ -0,0 +1,62 @@ +// +// StorageProfileImageRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-14. +// + +import Foundation +import FirebaseStorage + +/// Firebase Storage 기반 프로필 이미지 저장소 +actor StorageProfileImageRepository: ProfileImageRepository { + + private let storage = Storage.storage() + private let maxImageSize: Int64 = 1 * 1024 * 1024 + + private var cachedNames: [String]? + private var namesFetchTask: Task<[String], Error>? + private let dataCache = NSCache() + + func fetchAvailableImageNames() async throws -> [String] { + if let cached = cachedNames { + return cached + } + + if let existing = namesFetchTask { + return try await existing.value + } + + let storage = self.storage + let task = Task<[String], Error> { + let result = try await storage.reference().child("avatars").listAll() + return result.items + .map { ($0.name as NSString).deletingPathExtension } + .sorted() + } + namesFetchTask = task + + do { + let names = try await task.value + cachedNames = names + namesFetchTask = nil + return names + } catch { + namesFetchTask = nil + throw error + } + } + + func downloadImageData(identifier: String) async throws -> Data { + let key = identifier as NSString + if let cached = dataCache.object(forKey: key) { + return cached as Data + } + + let ref = storage.reference().child("avatars/\(identifier).png") + let data = try await ref.data(maxSize: maxImageSize) + dataCache.setObject(data as NSData, forKey: key) + return data + } + +} diff --git a/WhereAreYou/WhereAreYou/Data/UserDTO.swift b/WhereAreYou/WhereAreYou/Data/UserDTO.swift index 73d2cda..f4a3bf2 100644 --- a/WhereAreYou/WhereAreYou/Data/UserDTO.swift +++ b/WhereAreYou/WhereAreYou/Data/UserDTO.swift @@ -50,8 +50,7 @@ struct UserDTO { return User( id: id, nickname: nickname, - // TODO: Firebase Storage 전환 시 실제 URL로 교체 - profileImage: URL(string: profileImage)!, + profileImage: profileImage, defaultTransportMode: transportMode, locationSharingScope: sharingScope, isNotificationEnabled: isNotificationEnabled, diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/User.swift b/WhereAreYou/WhereAreYou/Domain/Entity/User.swift index abf6fd9..f23079a 100644 --- a/WhereAreYou/WhereAreYou/Domain/Entity/User.swift +++ b/WhereAreYou/WhereAreYou/Domain/Entity/User.swift @@ -11,7 +11,7 @@ struct User: Hashable { let id: String let nickname: String - let profileImage: URL + let profileImage: String let defaultTransportMode: TransportType let locationSharingScope: LocationSharingScope let isNotificationEnabled: Bool diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/ProfileImageRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/ProfileImageRepository.swift new file mode 100644 index 0000000..5be9256 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Repository/ProfileImageRepository.swift @@ -0,0 +1,17 @@ +// +// ProfileImageRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-14. +// + +import Foundation + +/// 프로필 이미지 저장소 — 사용 가능한 이미지 목록 조회 및 이미지 데이터 다운로드 +protocol ProfileImageRepository { + + func fetchAvailableImageNames() async throws -> [String] + + func downloadImageData(identifier: String) async throws -> Data + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/AppointmentInfo/ParticipantBox.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/AppointmentInfo/ParticipantBox.swift index fe710b5..3e7607a 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/AppointmentInfo/ParticipantBox.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/AppointmentInfo/ParticipantBox.swift @@ -16,7 +16,8 @@ final class ParticipantBox: UIView { init(member: Participant) { super.init(frame: .zero) - let avatar = UIImageView(image: UIImage(named: member.profileImage)) + let avatar = UIImageView() + avatar.setProfileImage(member.profileImage) avatar.backgroundColor = .pointBackground avatar.layer.cornerRadius = Self.avatarSize / 2 avatar.clipsToBounds = true diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift index ba99d68..88410b0 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift @@ -162,7 +162,7 @@ private extension ChatViewModel { id: message.id, senderID: message.sender.id, senderNickname: message.sender.nickname, - senderProfileImage: message.sender.profileImageName, + senderProfileImage: message.sender.profileImage, content: bubbleContent, timeText: timeText, sentAt: message.sentAt, diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/SharedPlace/SharedPlaceRow.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/SharedPlace/SharedPlaceRow.swift index 975bb41..e51e8a7 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/SharedPlace/SharedPlaceRow.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/SharedPlace/SharedPlaceRow.swift @@ -73,7 +73,8 @@ final class SharedPlaceRow: UIView { private func setUpVoterImages(_ images: [String]) { for imageName in images { - let imageView = UIImageView(image: UIImage(named: imageName)) + let imageView = UIImageView() + imageView.setProfileImage(imageName) imageView.contentMode = .scaleAspectFit imageView.layer.cornerRadius = Self.profileSize / 2 imageView.clipsToBounds = true diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/OtherChatBubble.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/OtherChatBubble.swift index 90ae3b5..78236fe 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/OtherChatBubble.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/OtherChatBubble.swift @@ -83,7 +83,8 @@ final class OtherChatBubble: ChatBubbleBase { } private func makeAvatarView(imageName: String) -> UIImageView { - let imageView = UIImageView(image: UIImage(named: imageName)) + let imageView = UIImageView() + imageView.setProfileImage(imageName) imageView.contentMode = .scaleAspectFit imageView.layer.cornerRadius = Self.avatarSize / 2 imageView.clipsToBounds = true diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/ProfileImageLoader.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/ProfileImageLoader.swift new file mode 100644 index 0000000..c085f58 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Extension/ProfileImageLoader.swift @@ -0,0 +1,43 @@ +// +// ProfileImageLoader.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-13. +// + +import UIKit + +/// 프로필 이미지를 로드해 UIImage로 변환하는 싱글턴 로더 +final class ProfileImageLoader { + + static let shared = ProfileImageLoader() + + private let repository: ProfileImageRepository + + private init() { + repository = DIContainer.shared.resolve(ProfileImageRepository.self) + } + + func fetchAvailableImageNames() async throws -> [String] { + try await repository.fetchAvailableImageNames() + } + + func load(identifier: String) async -> UIImage { + do { + let data = try await repository.downloadImageData(identifier: identifier) + return UIImage(data: data) ?? Self.failureImage + } catch { + return Self.failureImage + } + } + + // MARK: - 실패 이미지 + + static let failureImage: UIImage = { + let config = UIImage.SymbolConfiguration(pointSize: 40, weight: .light) + let symbol = UIImage(systemName: "questionmark.circle", withConfiguration: config) + return symbol?.withTintColor(.secondaryLabel, renderingMode: .alwaysOriginal) + ?? UIImage() + }() + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/UIImageView+ProfileImage.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/UIImageView+ProfileImage.swift new file mode 100644 index 0000000..c50b659 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Extension/UIImageView+ProfileImage.swift @@ -0,0 +1,60 @@ +// +// UIImageView+ProfileImage.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-13. +// + +import UIKit +import ObjectiveC + +/// UIImageView에서 프로필 아바타 식별자로 Storage 이미지를 비동기 로딩하는 extension +extension UIImageView { + + func setProfileImage(_ identifier: String) { + profileLoadTask?.cancel() + indicatorDelayTask?.cancel() + currentProfileIdentifier = identifier + image = nil + hideLoadingIndicator() + + indicatorDelayTask = Task { [weak self] in + try await Task.sleep(for: .milliseconds(150)) + self?.showLoadingIndicator() + } + + let showTask = indicatorDelayTask + profileLoadTask = Task { [weak self] in + let loaded = await ProfileImageLoader.shared.load(identifier: identifier) + showTask?.cancel() + guard !Task.isCancelled else { return } + guard self?.currentProfileIdentifier == identifier else { return } + self?.hideLoadingIndicator() + self?.image = loaded + } + } + +} + +private var profileIdentifierKey: UInt8 = 0 +private var profileLoadTaskKey: UInt8 = 0 +private var indicatorDelayTaskKey: UInt8 = 0 + +private extension UIImageView { + + var currentProfileIdentifier: String? { + get { objc_getAssociatedObject(self, &profileIdentifierKey) as? String } + set { objc_setAssociatedObject(self, &profileIdentifierKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var profileLoadTask: Task? { + get { objc_getAssociatedObject(self, &profileLoadTaskKey) as? Task } + set { objc_setAssociatedObject(self, &profileLoadTaskKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var indicatorDelayTask: Task? { + get { objc_getAssociatedObject(self, &indicatorDelayTaskKey) as? Task } + set { objc_setAssociatedObject(self, &indicatorDelayTaskKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/UIView+LoadingIndicator.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/UIView+LoadingIndicator.swift new file mode 100644 index 0000000..0c48160 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Extension/UIView+LoadingIndicator.swift @@ -0,0 +1,49 @@ +// +// UIView+LoadingIndicator.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-14. +// + +import UIKit +import ObjectiveC + +/// UIView에 로딩 인디케이터를 표시·제거하는 extension +extension UIView { + + func showLoadingIndicator(style: UIActivityIndicatorView.Style = .medium, color: UIColor? = nil) { + guard loadingIndicator == nil else { return } + + let indicator = UIActivityIndicatorView(style: style) + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.hidesWhenStopped = true + if let color { indicator.color = color } + addSubview(indicator) + + NSLayoutConstraint.activate([ + indicator.centerXAnchor.constraint(equalTo: centerXAnchor), + indicator.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + indicator.startAnimating() + loadingIndicator = indicator + } + + func hideLoadingIndicator() { + loadingIndicator?.stopAnimating() + loadingIndicator?.removeFromSuperview() + loadingIndicator = nil + } + +} + +private var loadingIndicatorKey: UInt8 = 0 + +private extension UIView { + + var loadingIndicator: UIActivityIndicatorView? { + get { objc_getAssociatedObject(self, &loadingIndicatorKey) as? UIActivityIndicatorView } + set { objc_setAssociatedObject(self, &loadingIndicatorKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/User+ProfileImage.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/User+ProfileImage.swift deleted file mode 100644 index 3ea6cae..0000000 --- a/WhereAreYou/WhereAreYou/Presentation/Extension/User+ProfileImage.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// User+ProfileImage.swift -// WhereAreYou -// -// Created by 이상유 on 2026-08-17. -// - -import Foundation - -extension User { - - /// 프로필 이미지 URL에서 화면에 사용할 에셋 이름만 추출 - /// - 서버 연동 후 이미지 표현이 바뀌면 이 프로퍼티 한 곳만 수정하면 된다. - var profileImageName: String { - profileImage.lastPathComponent - } - -} diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteMapView.swift b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteMapView.swift index 466a89e..a9ce85f 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteMapView.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteMapView.swift @@ -13,6 +13,7 @@ final class AppointmentRouteMapView: NMFNaverMapView { private var placeMarker: NMFMarker? private var participantMarkers: [NMFMarker] = [] private var participantPolylines: [NMFPolylineOverlay] = [] + private var markerLoadTask: Task? private var hasMovedToInitialPosition = false override init(frame: CGRect) { @@ -57,12 +58,21 @@ final class AppointmentRouteMapView: NMFNaverMapView { } func setParticipants(_ participants: [AppointmentRouteParticipant]) { + markerLoadTask?.cancel() clearParticipantOverlays() let colors = UIColor.participantColors(count: participants.count) for (index, participant) in participants.enumerated() { addPolyline(for: participant, color: colors[index]) - addMarker(for: participant, color: colors[index]) + } + + markerLoadTask = Task { [weak self] in + let images = await self?.loadProfileImages(for: participants) ?? [] + guard !Task.isCancelled, !images.isEmpty else { return } + + for (index, participant) in participants.enumerated() { + self?.addMarker(for: participant, color: colors[index], profileImage: images[index]) + } } } @@ -97,13 +107,27 @@ private extension AppointmentRouteMapView { participantPolylines.append(overlay) } - func addMarker(for participant: AppointmentRouteParticipant, color: UIColor) { + func loadProfileImages(for participants: [AppointmentRouteParticipant]) async -> [UIImage] { + await withTaskGroup(of: (Int, UIImage).self) { group in + for (index, participant) in participants.enumerated() { + group.addTask { + let image = await ProfileImageLoader.shared.load(identifier: participant.profileImage) + return (index, image) + } + } + + var images = [UIImage](repeating: ProfileImageLoader.failureImage, count: participants.count) + for await (index, image) in group { + images[index] = image + } + return images + } + } + + func addMarker(for participant: AppointmentRouteParticipant, color: UIColor, profileImage: UIImage) { guard let position = participant.path.first else { return } - let kind = MapMarker.Kind.participant( - profileImage: UIImage(named: participant.profileImageURL.host ?? ""), - tintColor: color - ) + let kind = MapMarker.Kind.participant(profileImage: profileImage, tintColor: color) let markerImage = MapMarker.renderImage(kind: kind, name: participant.nickname) let marker = NMFMarker(position: NMGLatLng(lat: position.latitude, lng: position.longitude)) diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ParticipantRouteRow.swift b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ParticipantRouteRow.swift index ff767b1..b789455 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ParticipantRouteRow.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ParticipantRouteRow.swift @@ -32,7 +32,8 @@ final class ParticipantRouteRow: UIView { avatarContainer.heightAnchor.constraint(equalToConstant: Self.avatarDiameter).isActive = true let avatarInset = Self.avatarDiameter * 0.15 - let avatar = UIImageView(image: UIImage(named: participant.profileImageURL.host ?? "")) + let avatar = UIImageView() + avatar.setProfileImage(participant.profileImage) avatar.contentMode = .scaleAspectFit avatar.translatesAutoresizingMaskIntoConstraints = false avatarContainer.addSubview(avatar) diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/Detail/ProfileEditViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/Detail/ProfileEditViewController.swift index a9a64c7..336c801 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/Detail/ProfileEditViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/Detail/ProfileEditViewController.swift @@ -14,16 +14,7 @@ final class ProfileEditViewController: UIViewController { private static let profileImageCellSize: CGFloat = 90 private static let previewImageSize: CGFloat = 96 - private static let availableProfileImageNames: [String] = { - let assetImageNames = ["shark", "turtle"] - let symbolImageNames = [ - "pawprint.fill", "tortoise.fill", "hare.fill", "bird.fill", "fish.fill", - "ladybug.fill", "ant.fill", "lizard.fill", "cat.fill", "dog.fill", - "bee.fill", "butterfly.fill", "leaf.fill", "pawprint", "tortoise", - "hare", "bird", "fish", "ladybug", "ant" - ] - return assetImageNames + symbolImageNames - }() + private var availableImageNames: [String] = [] private var selectedImageName: String { didSet { updatePreviewImage() } @@ -43,7 +34,6 @@ final class ProfileEditViewController: UIViewController { let imageView = UIImageView() imageView.contentMode = .scaleAspectFit imageView.backgroundColor = .pointBackground - imageView.tintColor = .blue2 imageView.clipsToBounds = true return imageView }() @@ -61,6 +51,8 @@ final class ProfileEditViewController: UIViewController { field.layer.shadowOffset = CGSize(width: 0, height: 2) field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) field.leftViewMode = .always + field.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) + field.rightViewMode = .always field.returnKeyType = .done field.enablesReturnKeyAutomatically = true return field @@ -106,8 +98,8 @@ final class ProfileEditViewController: UIViewController { setUpLayout() setUpDismissKeyboardGesture() setUpActions() - applySnapshot() updatePreviewImage() + loadAvailableImages() } override func viewWillAppear(_ animated: Bool) { @@ -184,13 +176,23 @@ final class ProfileEditViewController: UIViewController { } } + // MARK: - Data + + private func loadAvailableImages() { + Task { [weak self] in + let names = (try? await ProfileImageLoader.shared.fetchAvailableImageNames()) ?? [] + self?.availableImageNames = names + self?.applySnapshot() + } + } + private func applySnapshot() { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) - snapshot.appendItems(Self.availableProfileImageNames, toSection: 0) + snapshot.appendItems(availableImageNames, toSection: 0) dataSource.apply(snapshot, animatingDifferences: false) - if let selectedIndex = Self.availableProfileImageNames.firstIndex(of: selectedImageName) { + if let selectedIndex = availableImageNames.firstIndex(of: selectedImageName) { collectionView.selectItem( at: IndexPath(item: selectedIndex, section: 0), animated: false, @@ -200,7 +202,7 @@ final class ProfileEditViewController: UIViewController { } private func updatePreviewImage() { - previewImageView.image = UIImage(named: selectedImageName) ?? UIImage(systemName: selectedImageName) + previewImageView.setProfileImage(selectedImageName) previewImageView.layer.cornerRadius = Self.previewImageSize / 2 } @@ -233,7 +235,7 @@ final class ProfileEditViewController: UIViewController { extension ProfileEditViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - selectedImageName = Self.availableProfileImageNames[indexPath.item] + selectedImageName = availableImageNames[indexPath.item] } } diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/Detail/ProfileImageCell.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/Detail/ProfileImageCell.swift index ddac51f..33ca4a7 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/Detail/ProfileImageCell.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/Detail/ProfileImageCell.swift @@ -39,8 +39,7 @@ final class ProfileImageCell: UICollectionViewCell { } func configure(imageName: String) { - imageView.image = UIImage(named: imageName) ?? UIImage(systemName: imageName) - imageView.tintColor = .blue2 + imageView.setProfileImage(imageName) } private func setUp() { diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageRow.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageRow.swift index 80900a9..3dc37de 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageRow.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageRow.swift @@ -84,6 +84,10 @@ final class MyPageRow: UIControl { iconView.image = newIcon } + func setProfileIcon(_ identifier: String) { + iconView.setProfileImage(identifier) + } + private func setUp(accessoryView: UIView?, accessoryHasOwnInteraction: Bool) { let iconSize: CGFloat = 22 iconView.widthAnchor.constraint(equalToConstant: iconSize).isActive = true diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift index 1cfb83a..5dcfeee 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift @@ -58,12 +58,16 @@ final class MyPageViewController: UIViewController { imageView.widthAnchor.constraint(equalToConstant: 16).isActive = true return imageView }() - private lazy var profileRow = MyPageRow( - icon: UIImage(named: viewModel.profile.profileImageName) ?? UIImage(systemName: viewModel.profile.profileImageName), - title: "닉네임", - isCircularImage: true, - accessoryView: profileEditIcon - ) + private lazy var profileRow: MyPageRow = { + let row = MyPageRow( + icon: nil, + title: "닉네임", + isCircularImage: true, + accessoryView: profileEditIcon + ) + row.setProfileIcon(viewModel.profile.profileImageName) + return row + }() // MARK: - 위치 카드 @@ -200,7 +204,7 @@ final class MyPageViewController: UIViewController { let profile = viewModel.profile profileRow.value = profile.nickname - profileRow.setIcon(UIImage(named: profile.profileImageName) ?? UIImage(systemName: profile.profileImageName)) + profileRow.setProfileIcon(profile.profileImageName) locationSharingRow.value = profile.locationSharingOption.title locationPermissionRow.value = viewModel.locationPermissionState.title notificationSwitch.isOn = profile.isNotificationEnabled diff --git a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/AppointmentRouteParticipant.swift b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/AppointmentRouteParticipant.swift index 97dc3ac..d7cbac9 100644 --- a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/AppointmentRouteParticipant.swift +++ b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/AppointmentRouteParticipant.swift @@ -11,7 +11,7 @@ struct AppointmentRouteParticipant { let id: String let nickname: String - let profileImageURL: URL + let profileImage: String let isMe: Bool let path: [Coordinate] let transportName: String @@ -28,7 +28,7 @@ struct AppointmentRouteParticipant { let transport = route.step.last?.transportType ?? user.defaultTransportMode id = user.id nickname = user.nickname - profileImageURL = user.profileImage + profileImage = user.profileImage isMe = user.id == currentUserID path = route.step.flatMap(\.path) transportName = transport.name diff --git a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/Participant.swift b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/Participant.swift index fc4b103..6dfea38 100644 --- a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/Participant.swift +++ b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/Participant.swift @@ -14,7 +14,7 @@ struct Participant { init(user: User) { id = user.id nickname = user.nickname - profileImage = user.profileImageName + self.profileImage = user.profileImage } init(id: String, nickname: String, profileImage: String) { diff --git a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/SharedPlaceItem.swift b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/SharedPlaceItem.swift index 90ec618..8a97cc7 100644 --- a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/SharedPlaceItem.swift +++ b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/SharedPlaceItem.swift @@ -21,7 +21,7 @@ struct SharedPlaceItem { id = sharedPlace.id placeName = sharedPlace.place.name placeAddress = sharedPlace.place.address - voterProfileImages = sharedPlace.voters.map { $0.profileImageName } + voterProfileImages = sharedPlace.voters.map(\.profileImage) hasVoted = sharedPlace.voters.contains { $0.id == currentUserID } sharedAt = sharedPlace.sharedAt } diff --git a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/Contents.json b/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/otter.imageset/Contents.json b/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/otter.imageset/Contents.json deleted file mode 100644 index 828bfd4..0000000 --- a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/otter.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "otter.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/otter.imageset/otter.png b/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/otter.imageset/otter.png deleted file mode 100644 index 690c432..0000000 Binary files a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/otter.imageset/otter.png and /dev/null differ diff --git a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/shark.imageset/Contents.json b/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/shark.imageset/Contents.json deleted file mode 100644 index eece125..0000000 --- a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/shark.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "shark.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/shark.imageset/shark.png b/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/shark.imageset/shark.png deleted file mode 100644 index 1c17833..0000000 Binary files a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/shark.imageset/shark.png and /dev/null differ diff --git a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/turtle.imageset/Contents.json b/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/turtle.imageset/Contents.json deleted file mode 100644 index 73f8db3..0000000 --- a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/turtle.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "turtle.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/turtle.imageset/turtle.png b/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/turtle.imageset/turtle.png deleted file mode 100644 index 4df0100..0000000 Binary files a/WhereAreYou/WhereAreYou/Resource/Assets.xcassets/Profile/turtle.imageset/turtle.png and /dev/null differ diff --git a/firebase.json b/firebase.json index 93b4c19..b260b85 100644 --- a/firebase.json +++ b/firebase.json @@ -2,12 +2,12 @@ "firestore": { "database": "(default)", "location": "asia-northeast3", - "rules": "firestore.rules", - "indexes": "firestore.indexes.json" + "rules": "firebase/firestore.rules", + "indexes": "firebase/firestore.indexes.json" }, "functions": [ { - "source": "functions", + "source": "firebase/functions", "codebase": "default", "disallowLegacyRuntimeConfig": true, "ignore": [ @@ -20,7 +20,7 @@ } ], "storage": { - "rules": "storage.rules" + "rules": "firebase/storage.rules" }, "emulators": { "auth": { @@ -45,7 +45,7 @@ "singleProjectMode": true }, "database": { - "rules": "database.rules.json" + "rules": "firebase/database.rules.json" }, "auth": { "providers": {} diff --git a/database.rules.json b/firebase/database.rules.json similarity index 100% rename from database.rules.json rename to firebase/database.rules.json diff --git a/firestore.indexes.json b/firebase/firestore.indexes.json similarity index 100% rename from firestore.indexes.json rename to firebase/firestore.indexes.json diff --git a/firestore.rules b/firebase/firestore.rules similarity index 100% rename from firestore.rules rename to firebase/firestore.rules diff --git a/functions/.gitignore b/firebase/functions/.gitignore similarity index 100% rename from functions/.gitignore rename to firebase/functions/.gitignore diff --git a/functions/index.js b/firebase/functions/index.js similarity index 100% rename from functions/index.js rename to firebase/functions/index.js diff --git a/functions/package-lock.json b/firebase/functions/package-lock.json similarity index 100% rename from functions/package-lock.json rename to firebase/functions/package-lock.json diff --git a/functions/package.json b/firebase/functions/package.json similarity index 100% rename from functions/package.json rename to firebase/functions/package.json diff --git a/storage.rules b/firebase/storage.rules similarity index 100% rename from storage.rules rename to firebase/storage.rules