From b2618e5a8cada4c4b2b9123a998381f32a856565 Mon Sep 17 00:00:00 2001 From: DG Date: Sun, 4 Aug 2024 16:16:11 +0900 Subject: [PATCH] Feature/improvement profile/main (#72) * Feature/improvement profile/my profile view (#69) * feat: delete existing view * feat: delete myprofileview navigation * fix: DI * fix: DI * feat: present profileview * feat: pass binding * feat: draggable profile view * Feature/improvement profile/view UI (#70) * feat * feat * feat * feat * feat * feat * chore * Feature/improvement profile/view UI (#71) * feat * feat * feat * feat * feat * feat * chore * feat * feat * feat * feat: friend loading * chore * feat: FullScreenImageView * feat: binding url * feat * delete friend profile view * feat * feat * feat: navigation * chore * chore * chore * chore * feat: MyProfileEditView * feat: DI * feat: viewModel * feat: BackgroundImageView * feat: refactor name * feat: DI * feat: PhotosPicker * feat: delete legacy * feat: init background image * feat: update background photo * feat: add cancel button * feat: configure user image * feat: profile image view * chore * feat: WhiteUnderlineTextLabel * feat: displaying display name * feat: ProfileTextInputView * feat: focus * feat: edit profile * chore: update fastlane * feat: change ui * fix: animation * fix: animation * fix: animation * fix: animation * feat: present fullscreen image on friend profile * feat: fetch link * feat: ui * feat: MakeStringFromURLUsecase * feat: delete background image * feat: delete photo * chore * feat: update profile * chore * chore * fix test * Feature/improvement profile/button (#73) * myview * fix: friend list * chore --- Gemfile | 2 +- Gemfile.lock | 30 +-- .../User/MakeStringFromURLUsecaseTests.swift | 24 ++ .../User/MakeURLFromStringUsecaseTests.swift | 31 +++ .../sources/App/DI/HistoryAssembly.swift | 17 ++ .../sources/App/DI/HomeAssembly.swift | 16 +- .../sources/App/DI/MainAssembly.swift | 1 - dg-muscle-ios/sources/App/DI/MyAssembly.swift | 37 ++- .../sources/App/Delegate/SceneDelegate.swift | 2 - .../Repository/FriendRepositoryImpl.swift | 7 +- .../Repository/RapidRepositoryImpl.swift | 5 +- .../Friend/Repository/FriendRepository.swift | 1 + ...SearchUsersExceptForMyFriendsUsecase.swift | 3 +- ...ubscribeFetchingFriendLoadingUsecase.swift | 21 ++ .../Usecase/MakeStringFromURLUsecase.swift | 35 +++ .../Usecase/MakeURLFromStringUsecase.swift | 33 +++ ...se.swift => PostProfilePhotoUsecase.swift} | 4 +- .../Repository/FriendRepositoryMock.swift | 6 + .../Common/Util/ImagePicker.swift | 48 ---- .../Common/View/FullScreenImageView.swift | 101 ++++++++ .../Common/View/SnackbarView.swift | 57 +++++ .../Common/View/FriendListItemView.swift | 35 ++- .../History/View/FriendHistoryView.swift | 2 +- .../Friend/List/View/FriendListView.swift | 21 +- .../Friend/List/View/FriendProfileView.swift | 209 +++++++++++----- .../Friend/Main/View/FriendMainView.swift | 61 +++-- .../Main/View/FriendMainViewModel.swift | 28 +++ .../UserList/View/SearchUsersView.swift | 2 - .../Main/Coordinator/MyCoordinator.swift | 4 - .../Main/Navigation/MyNavigation.swift | 1 - .../Presentation/Main/View/HomeView.swift | 104 +++++--- .../Main/View/NavigationView.swift | 5 - .../My/Common/View/BackgroundImageView.swift | 36 +++ .../Presentation/My/My/View/MyView.swift | 69 ++++-- .../My/My/View/UserItemView.swift | 33 --- .../View/MyProfile/MyProfileView.swift | 201 ++++++++++++++++ .../View/MyProfile/MyProfileViewModel.swift | 35 +++ .../MyProfileEdit/MyProfileEditView.swift | 227 ++++++++++++++++++ .../MyProfileEditViewModel.swift | 199 +++++++++++++++ .../MyProfileEdit/ProfileTextInputView.swift | 137 +++++++++++ .../WhiteUnderlineTextLabel.swift | 35 +++ .../My/Profile/View/MyProfileView.swift | 154 ------------ .../My/Profile/View/MyProfileViewModel.swift | 110 --------- 43 files changed, 1636 insertions(+), 553 deletions(-) create mode 100644 dg-muscle-ios/Tests/User/MakeStringFromURLUsecaseTests.swift create mode 100644 dg-muscle-ios/Tests/User/MakeURLFromStringUsecaseTests.swift create mode 100644 dg-muscle-ios/sources/Domain/Friend/Usecase/SubscribeFetchingFriendLoadingUsecase.swift create mode 100644 dg-muscle-ios/sources/Domain/User/Usecase/MakeStringFromURLUsecase.swift create mode 100644 dg-muscle-ios/sources/Domain/User/Usecase/MakeURLFromStringUsecase.swift rename dg-muscle-ios/sources/Domain/User/Usecase/{PostPhotoURLUsecase.swift => PostProfilePhotoUsecase.swift} (82%) delete mode 100644 dg-muscle-ios/sources/Presentation/Common/Util/ImagePicker.swift create mode 100644 dg-muscle-ios/sources/Presentation/Common/View/FullScreenImageView.swift create mode 100644 dg-muscle-ios/sources/Presentation/Common/View/SnackbarView.swift create mode 100644 dg-muscle-ios/sources/Presentation/Friend/Main/View/FriendMainViewModel.swift create mode 100644 dg-muscle-ios/sources/Presentation/My/Common/View/BackgroundImageView.swift delete mode 100644 dg-muscle-ios/sources/Presentation/My/My/View/UserItemView.swift create mode 100644 dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfile/MyProfileView.swift create mode 100644 dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfile/MyProfileViewModel.swift create mode 100644 dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/MyProfileEditView.swift create mode 100644 dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/MyProfileEditViewModel.swift create mode 100644 dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/ProfileTextInputView.swift create mode 100644 dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/WhiteUnderlineTextLabel.swift delete mode 100644 dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileView.swift delete mode 100644 dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileViewModel.swift diff --git a/Gemfile b/Gemfile index 5c901a04..38157ce6 100644 --- a/Gemfile +++ b/Gemfile @@ -2,5 +2,5 @@ source "https://rubygems.org" -gem "fastlane", "~>2.221.0" +gem "fastlane", "~>2.222.0" # TODO: update ruby version \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 6cd3a0ac..bbf96fa5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,20 +10,20 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.947.0) - aws-sdk-core (3.199.0) + aws-partitions (1.961.0) + aws-sdk-core (3.201.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.87.0) - aws-sdk-core (~> 3, >= 3.199.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.154.0) - aws-sdk-core (~> 3, >= 3.199.0) + aws-sdk-kms (1.88.0) + aws-sdk-core (~> 3, >= 3.201.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.157.0) + aws-sdk-core (~> 3, >= 3.201.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.9.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) @@ -38,7 +38,7 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.110.0) + excon (0.111.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -60,7 +60,7 @@ GEM faraday-httpclient (1.0.1) faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) @@ -68,7 +68,7 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.3.1) - fastlane (2.221.1) + fastlane (2.222.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -154,7 +154,7 @@ GEM json (2.7.2) jwt (2.8.2) base64 - mini_magick (4.13.1) + mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) multipart-post (2.4.1) @@ -164,7 +164,7 @@ GEM optparse (0.5.0) os (1.1.4) plist (3.7.1) - public_suffix (6.0.0) + public_suffix (6.0.1) rake (13.2.1) representable (3.2.0) declarative (< 0.1.0) @@ -214,7 +214,7 @@ PLATFORMS ruby DEPENDENCIES - fastlane (~> 2.221.0) + fastlane (~> 2.222.0) BUNDLED WITH 2.5.9 diff --git a/dg-muscle-ios/Tests/User/MakeStringFromURLUsecaseTests.swift b/dg-muscle-ios/Tests/User/MakeStringFromURLUsecaseTests.swift new file mode 100644 index 00000000..b22761af --- /dev/null +++ b/dg-muscle-ios/Tests/User/MakeStringFromURLUsecaseTests.swift @@ -0,0 +1,24 @@ +// +// MakeStringFromURLUsecaseTests.swift +// AppTests +// +// Created by 신동규 on 8/3/24. +// + +import XCTest +import Domain + +final class MakeStringFromURLUsecaseTests: XCTestCase { + + let usecase = MakeStringFromURLUsecase() + + func testHttps() { + let url = URL(string: "https://www.naver.com")! + XCTAssertEqual("www.naver.com", usecase.implement(url: url)) + } + + func testHttp() { + let url = URL(string: "http://www.naver.com")! + XCTAssertEqual("www.naver.com", usecase.implement(url: url)) + } +} diff --git a/dg-muscle-ios/Tests/User/MakeURLFromStringUsecaseTests.swift b/dg-muscle-ios/Tests/User/MakeURLFromStringUsecaseTests.swift new file mode 100644 index 00000000..d6b58873 --- /dev/null +++ b/dg-muscle-ios/Tests/User/MakeURLFromStringUsecaseTests.swift @@ -0,0 +1,31 @@ +// +// MakeURLFromStringUsecaseTests.swift +// AppTests +// +// Created by 신동규 on 8/3/24. +// + +import XCTest +import Domain + +final class MakeURLFromStringUsecaseTests: XCTestCase { + + let usecase = MakeURLFromStringUsecase() + + func testWithoutPrefix() { + XCTAssertNotNil(usecase.implement(link: "www.naver.com")) + } + + func testWithPrefix() { + XCTAssertNotNil(usecase.implement(link: "https://www.naver.com")) + XCTAssertNotNil(usecase.implement(link: "http://www.naver.com")) + } + + func testEmpty() { + XCTAssertNil(usecase.implement(link: "")) + } + + func testFail() { + XCTAssertNil(usecase.implement(link: " ")) + } +} diff --git a/dg-muscle-ios/sources/App/DI/HistoryAssembly.swift b/dg-muscle-ios/sources/App/DI/HistoryAssembly.swift index 4c5ba599..25ed491e 100644 --- a/dg-muscle-ios/sources/App/DI/HistoryAssembly.swift +++ b/dg-muscle-ios/sources/App/DI/HistoryAssembly.swift @@ -14,6 +14,23 @@ import Presentation public struct HistoryAssembly: Assembly { public func assemble(container: Swinject.Container) { + + container.register(HistoryListView.self) { (resolver, today: Date) in + + let historyRepository = resolver.resolve(HistoryRepository.self)! + let exerciseRepository = resolver.resolve(ExerciseRepository.self)! + let heatMapRepository = resolver.resolve(HeatMapRepository.self)! + let userRepository = resolver.resolve(UserRepository.self)! + + return HistoryListView( + today: today, + historyRepository: historyRepository, + exerciseRepository: exerciseRepository, + heatMapRepository: heatMapRepository, + userRepository: userRepository + ) + } + container.register(HeatMapColorSelectView.self) { resolver in let userRepository = resolver.resolve(UserRepository.self)! return HeatMapColorSelectView(userRepository: userRepository) diff --git a/dg-muscle-ios/sources/App/DI/HomeAssembly.swift b/dg-muscle-ios/sources/App/DI/HomeAssembly.swift index 5b5176d5..ddb1ef03 100644 --- a/dg-muscle-ios/sources/App/DI/HomeAssembly.swift +++ b/dg-muscle-ios/sources/App/DI/HomeAssembly.swift @@ -9,24 +9,18 @@ import Swinject import Domain import Presentation import Foundation +import My +import History public struct HomeAssembly: Assembly { public func assemble(container: Swinject.Container) { container.register(HomeView.self) { (resolver, today: Date) in - let historyRepository = resolver.resolve(HistoryRepository.self)! - let exerciseRepository = resolver.resolve(ExerciseRepository.self)! - let heatMapRepository = resolver.resolve(HeatMapRepository.self)! - let userRepository = resolver.resolve(UserRepository.self)! - let logRepository = resolver.resolve(LogRepository.self)! - return HomeView( today: today, - historyRepository: historyRepository, - exerciseRepository: exerciseRepository, - heatMapRepository: heatMapRepository, - userRepository: userRepository, - logRepository: logRepository + historyListFactory: { today in resolver.resolve(HistoryListView.self, argument: today)! }, + myViewFactory: { presentProfileAction in resolver.resolve(MyView.self, argument: presentProfileAction)! }, + myProfileViewFactory: { shows in resolver.resolve(MyProfileView.self, argument: shows)! } ) } } diff --git a/dg-muscle-ios/sources/App/DI/MainAssembly.swift b/dg-muscle-ios/sources/App/DI/MainAssembly.swift index 24004e4c..def967ea 100644 --- a/dg-muscle-ios/sources/App/DI/MainAssembly.swift +++ b/dg-muscle-ios/sources/App/DI/MainAssembly.swift @@ -37,7 +37,6 @@ public struct MainAssembly: Assembly { setDurationFactory: { duration in resolver.resolve(SetDurationView.self, argument: duration)! }, manageMemoFactory: { memo in resolver.resolve(ManageMemoView.self, argument: memo)! }, dateToSelectHistoryFactory: { resolver.resolve(DateToSelectHistoryView.self)! }, - myProfileFactory: { resolver.resolve(MyProfileView.self)! }, deleteAccountConfirmFactory: { resolver.resolve(DeleteAccountConfirmView.self)! }, logsFactory: { resolver.resolve(LogsView.self)! }, friendMainFactory: { anchor in resolver.resolve(FriendMainView.self, argument: anchor)! }, diff --git a/dg-muscle-ios/sources/App/DI/MyAssembly.swift b/dg-muscle-ios/sources/App/DI/MyAssembly.swift index 0620056d..3f46757c 100644 --- a/dg-muscle-ios/sources/App/DI/MyAssembly.swift +++ b/dg-muscle-ios/sources/App/DI/MyAssembly.swift @@ -9,28 +9,45 @@ import Swinject import Domain import Presentation import My +import SwiftUI public struct MyAssembly: Assembly { public func assemble(container: Swinject.Container) { - container.register(MyProfileView.self) { resolver in - + container.register(MyView.self) { (resolver, presentProfileViewAction: (() -> Void)?) in + let userRepository = resolver.resolve(UserRepository.self)! - - return MyProfileView(userRepository: userRepository) + let logRepository = resolver.resolve(LogRepository.self)! + + return MyView( + userRepository: userRepository, + logRepository: logRepository, + presentProfileViewAction: presentProfileViewAction + ) } - + + container.register(MyProfileView.self) { (resolver, shows: Binding) in + let userRepository = resolver.resolve(UserRepository.self)! + return MyProfileView( + shows: shows, + userRepository: userRepository, + myProfileEditFactory: { isEditing in + MyProfileEditView(userRepository: userRepository, isEditing: isEditing) + } + ) + } + container.register(DeleteAccountConfirmView.self) { resolver in - + let userRepository = resolver.resolve(UserRepository.self)! - + return DeleteAccountConfirmView(userRepository: userRepository) } - + container.register(LogsView.self) { resolver in - + let logRepository = resolver.resolve(LogRepository.self)! let friendRepository = resolver.resolve(FriendRepository.self)! - + return LogsView( logRepository: logRepository, friendRepository: friendRepository diff --git a/dg-muscle-ios/sources/App/Delegate/SceneDelegate.swift b/dg-muscle-ios/sources/App/Delegate/SceneDelegate.swift index 0ad20791..6855a5df 100644 --- a/dg-muscle-ios/sources/App/Delegate/SceneDelegate.swift +++ b/dg-muscle-ios/sources/App/Delegate/SceneDelegate.swift @@ -72,8 +72,6 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { case "friendhistory": guard let friendId = URLManager.shared.getParameter(url: url, name: "id") else { return } coordinator?.friend.friendHistory(friendId: friendId) - case "profile": - coordinator?.my.profile() case "exercisemanage": coordinator?.exercise.exerciseManage() case "heatmapcolorselect": diff --git a/dg-muscle-ios/sources/DataLayer/Friend/Repository/FriendRepositoryImpl.swift b/dg-muscle-ios/sources/DataLayer/Friend/Repository/FriendRepositoryImpl.swift index 6b22c0bb..b53c4278 100644 --- a/dg-muscle-ios/sources/DataLayer/Friend/Repository/FriendRepositoryImpl.swift +++ b/dg-muscle-ios/sources/DataLayer/Friend/Repository/FriendRepositoryImpl.swift @@ -12,6 +12,9 @@ import Combine public final class FriendRepositoryImpl: FriendRepository { public static let shared = FriendRepositoryImpl() + public var loading: AnyPublisher { $_loading.eraseToAnyPublisher() } + @Published var _loading: Bool = true + public var friends: AnyPublisher<[Domain.User], Never> { $_friends.eraseToAnyPublisher() } @Published var _friends: [Domain.User] = [] @@ -142,12 +145,14 @@ public final class FriendRepositoryImpl: FriendRepository { self._friends = friends self._requests = requests + self._loading = false let friendIds = friends.map({ $0.uid }) for id in friendIds { Task { - try? await getHistories(friendId: id) + let _ = try? await getHistories(friendId: id) + let _ = try? await getExercises(friendId: id) } } } diff --git a/dg-muscle-ios/sources/DataLayer/Rapid/Repository/RapidRepositoryImpl.swift b/dg-muscle-ios/sources/DataLayer/Rapid/Repository/RapidRepositoryImpl.swift index 12a4ca3d..a0097ef0 100644 --- a/dg-muscle-ios/sources/DataLayer/Rapid/Repository/RapidRepositoryImpl.swift +++ b/dg-muscle-ios/sources/DataLayer/Rapid/Repository/RapidRepositoryImpl.swift @@ -38,7 +38,10 @@ public final class RapidRepositoryImpl: RapidRepository { logRepository: LogRepositoryImpl.shared, userRepository: UserRepositoryImpl.shared ) - .implement(message: error.localizedDescription, category: .error) + .implement(message: """ + location is RapidRepositoryImpl + \(error.localizedDescription) + """, category: .error) } _exercisesLoading = false } diff --git a/dg-muscle-ios/sources/Domain/Friend/Repository/FriendRepository.swift b/dg-muscle-ios/sources/Domain/Friend/Repository/FriendRepository.swift index ac9d6914..d1b3b5b8 100644 --- a/dg-muscle-ios/sources/Domain/Friend/Repository/FriendRepository.swift +++ b/dg-muscle-ios/sources/Domain/Friend/Repository/FriendRepository.swift @@ -9,6 +9,7 @@ import Foundation import Combine public protocol FriendRepository { + var loading: AnyPublisher { get } var friends: AnyPublisher<[User], Never> { get } var requests: AnyPublisher<[FriendRequest], Never> { get } var users: AnyPublisher<[User], Never> { get } diff --git a/dg-muscle-ios/sources/Domain/Friend/Usecase/SearchUsersExceptForMyFriendsUsecase.swift b/dg-muscle-ios/sources/Domain/Friend/Usecase/SearchUsersExceptForMyFriendsUsecase.swift index 77154996..b795256c 100644 --- a/dg-muscle-ios/sources/Domain/Friend/Usecase/SearchUsersExceptForMyFriendsUsecase.swift +++ b/dg-muscle-ios/sources/Domain/Friend/Usecase/SearchUsersExceptForMyFriendsUsecase.swift @@ -45,13 +45,12 @@ public final class SearchUsersExceptForMyFriendsUsecase { users = users .filter({ !excludeIds.contains($0.uid) }) - .filter({ $0.displayName?.isEmpty == false }) .sorted(by: { user1, user2 in if user1.photoURL != nil && user2.photoURL == nil { return true } - if user1.displayName != nil && user2.displayName == nil { + if (user1.displayName?.isEmpty == false) && (user2.displayName?.isEmpty != false) { return true } diff --git a/dg-muscle-ios/sources/Domain/Friend/Usecase/SubscribeFetchingFriendLoadingUsecase.swift b/dg-muscle-ios/sources/Domain/Friend/Usecase/SubscribeFetchingFriendLoadingUsecase.swift new file mode 100644 index 00000000..839378c7 --- /dev/null +++ b/dg-muscle-ios/sources/Domain/Friend/Usecase/SubscribeFetchingFriendLoadingUsecase.swift @@ -0,0 +1,21 @@ +// +// SubscribeFetchingFriendLoadingUsecase.swift +// Domain +// +// Created by 신동규 on 7/29/24. +// + +import Foundation +import Combine + +public final class SubscribeFetchingFriendLoadingUsecase { + let friendRepository: FriendRepository + + public init(friendRepository: FriendRepository) { + self.friendRepository = friendRepository + } + + public func implement() -> AnyPublisher { + friendRepository.loading + } +} diff --git a/dg-muscle-ios/sources/Domain/User/Usecase/MakeStringFromURLUsecase.swift b/dg-muscle-ios/sources/Domain/User/Usecase/MakeStringFromURLUsecase.swift new file mode 100644 index 00000000..6c0383ce --- /dev/null +++ b/dg-muscle-ios/sources/Domain/User/Usecase/MakeStringFromURLUsecase.swift @@ -0,0 +1,35 @@ +// +// MakeStringFromURLUsecase.swift +// Domain +// +// Created by 신동규 on 8/3/24. +// + +import Foundation + +public final class MakeStringFromURLUsecase { + public init() { } + + public func implement(url: URL) -> String { + var result: String = url.absoluteString + + let http = "http://" + let https = "https://" + + var prefix: String? + + if result.hasPrefix(http) { + prefix = http + } + + if result.hasPrefix(https) { + prefix = https + } + + if let prefix { + result.removeFirst(prefix.count) + } + + return result + } +} diff --git a/dg-muscle-ios/sources/Domain/User/Usecase/MakeURLFromStringUsecase.swift b/dg-muscle-ios/sources/Domain/User/Usecase/MakeURLFromStringUsecase.swift new file mode 100644 index 00000000..dcd5aa0e --- /dev/null +++ b/dg-muscle-ios/sources/Domain/User/Usecase/MakeURLFromStringUsecase.swift @@ -0,0 +1,33 @@ +// +// MakeURLFromStringUsecase.swift +// Domain +// +// Created by 신동규 on 8/3/24. +// + +import Foundation +import UIKit + +public final class MakeURLFromStringUsecase { + public init() { } + + public func implement(link: String) -> URL? { + var result: URL? + + var link = link + + if link.isEmpty { + return result + } + + let hasPrefix = link.hasPrefix("https://") || link.hasPrefix("http://") + + if !hasPrefix { + link = "https://\(link)" + } + + result = .init(string: link) + + return result + } +} diff --git a/dg-muscle-ios/sources/Domain/User/Usecase/PostPhotoURLUsecase.swift b/dg-muscle-ios/sources/Domain/User/Usecase/PostProfilePhotoUsecase.swift similarity index 82% rename from dg-muscle-ios/sources/Domain/User/Usecase/PostPhotoURLUsecase.swift rename to dg-muscle-ios/sources/Domain/User/Usecase/PostProfilePhotoUsecase.swift index 2964ac5f..e3c400af 100644 --- a/dg-muscle-ios/sources/Domain/User/Usecase/PostPhotoURLUsecase.swift +++ b/dg-muscle-ios/sources/Domain/User/Usecase/PostProfilePhotoUsecase.swift @@ -1,5 +1,5 @@ // -// PostPhotoURLUsecase.swift +// PostProfilePhotoUsecase.swift // Domain // // Created by 신동규 on 5/25/24. @@ -8,7 +8,7 @@ import Foundation import UIKit -public final class PostPhotoURLUsecase { +public final class PostProfilePhotoUsecase { private let userRepository: UserRepository public init(userRepository: UserRepository) { self.userRepository = userRepository diff --git a/dg-muscle-ios/sources/MockData/Friend/Repository/FriendRepositoryMock.swift b/dg-muscle-ios/sources/MockData/Friend/Repository/FriendRepositoryMock.swift index d8e1bb6f..6e42d28a 100644 --- a/dg-muscle-ios/sources/MockData/Friend/Repository/FriendRepositoryMock.swift +++ b/dg-muscle-ios/sources/MockData/Friend/Repository/FriendRepositoryMock.swift @@ -10,6 +10,12 @@ import Domain import Combine public final class FriendRepositoryMock: FriendRepository { + public var loading: AnyPublisher { + $_loading.eraseToAnyPublisher() + } + + @Published var _loading: Bool = true + public var friends: AnyPublisher<[Domain.User], Never> { $_friends.eraseToAnyPublisher() } @Published var _friends: [Domain.User] = [ USERS[0], diff --git a/dg-muscle-ios/sources/Presentation/Common/Util/ImagePicker.swift b/dg-muscle-ios/sources/Presentation/Common/Util/ImagePicker.swift deleted file mode 100644 index fd36838a..00000000 --- a/dg-muscle-ios/sources/Presentation/Common/Util/ImagePicker.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ImagePicker.swift -// Common -// -// Created by 신동규 on 5/25/24. -// - -import SwiftUI - -public struct ImagePicker: UIViewControllerRepresentable { - - @Binding var image: UIImage? - @Environment(\.presentationMode) var mode - - public init(image: Binding) { - _image = image - } - - public func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - public func makeUIViewController(context: Context) -> some UIViewController { - let picker = UIImagePickerController() - picker.delegate = context.coordinator - return picker - } - - public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - - } -} - -extension ImagePicker { - public class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - let parent: ImagePicker - - init(_ parent: ImagePicker) { - self.parent = parent - } - - public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { - guard let image = info[.originalImage] as? UIImage else { return } - parent.image = image - parent.mode.wrappedValue.dismiss() - } - } -} diff --git a/dg-muscle-ios/sources/Presentation/Common/View/FullScreenImageView.swift b/dg-muscle-ios/sources/Presentation/Common/View/FullScreenImageView.swift new file mode 100644 index 00000000..7f548aa0 --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/Common/View/FullScreenImageView.swift @@ -0,0 +1,101 @@ +// +// FullScreenImageView.swift +// Common +// +// Created by 신동규 on 7/31/24. +// + +import SwiftUI +import Kingfisher +import MockData + +public struct FullScreenImageView: View { + + @Binding private var url: URL? + @State private var viewOffset: CGFloat = 0 + @State private var opacity: CGFloat = 0 + + public init( + url: Binding + ) { + self._url = url + } + + public var body: some View { + ZStack { + Rectangle() + .fill(.black) + .ignoresSafeArea() + + Rectangle() + .fill(.clear) + .background { + KFImage(url) + .resizable() + .scaledToFill() + } + + VStack { + HStack { + Button { + withAnimation { + dismiss() + } + } label: { + Image(systemName: "xmark") + .foregroundStyle(.white) + .font(.title) + } + Spacer() + } + .padding(.top) + .padding(.horizontal) + + Spacer() + } + } + .opacity(opacity) + .offset(y: viewOffset) + .gesture( + DragGesture(minimumDistance: 15) + .onChanged { gesture in + guard gesture.translation.height > 0 else { return } + viewOffset = gesture.translation.height + } + .onEnded { gesture in + let dismissableLocation = gesture.translation.height > 150 + let dismissableVolocity = gesture.velocity.height > 150 + if dismissableLocation || dismissableVolocity { + dismiss() + } else { + dragViewUp() + } + } + ) + .onAppear { + withAnimation { + opacity = 1 + } + } + } + + private func dismiss() { + withAnimation { + viewOffset = 1000 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + url = nil + } + } + } + + private func dragViewUp() { + withAnimation { + viewOffset = 0 + } + } +} + +#Preview { + FullScreenImageView(url: .constant(USER_DG.photoURL)) + .preferredColorScheme(.dark) +} diff --git a/dg-muscle-ios/sources/Presentation/Common/View/SnackbarView.swift b/dg-muscle-ios/sources/Presentation/Common/View/SnackbarView.swift new file mode 100644 index 00000000..bc407441 --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/Common/View/SnackbarView.swift @@ -0,0 +1,57 @@ +// +// SnackbarView.swift +// Common +// +// Created by 신동규 on 8/3/24. +// + +import SwiftUI + +public struct SnackbarView: View { + + @Binding var message: String? + + @State private var offset: CGFloat = 200 + + public init(message: Binding) { + _message = message + } + + public var body: some View { + VStack { + Spacer() + HStack { + Text(message ?? "") + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.black.opacity(0.7)) + } + .padding(.horizontal) + } + .foregroundStyle(.white) + .offset(y: offset) + .onAppear { + withAnimation { + offset = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { + dismiss() + } + } + } + + private func dismiss() { + withAnimation { + offset = 200 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + message = nil + } + } +} diff --git a/dg-muscle-ios/sources/Presentation/Friend/Common/View/FriendListItemView.swift b/dg-muscle-ios/sources/Presentation/Friend/Common/View/FriendListItemView.swift index 91ab6a49..c10d03b1 100644 --- a/dg-muscle-ios/sources/Presentation/Friend/Common/View/FriendListItemView.swift +++ b/dg-muscle-ios/sources/Presentation/Friend/Common/View/FriendListItemView.swift @@ -16,16 +16,33 @@ struct FriendListItemView: View { var body: some View { HStack(spacing: 20) { - if let profilePhotoURL = friend.photoURL { - KFImage(profilePhotoURL) - .resizable() - .scaledToFill() - .frame(width: imageSize, height: imageSize) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - Text(friend.displayName ?? "Display Name") - .foregroundStyle(Color(uiColor: friend.displayName == nil ? .secondaryLabel : .label)) + + RoundedRectangle(cornerRadius: 20) + .fill(.clear) + .strokeBorder(.white.opacity(0.8), lineWidth: 1) + .frame(width: imageSize, height: imageSize) + .background { + if let profilePhotoURL = friend.photoURL { + KFImage(profilePhotoURL) + .resizable() + .scaledToFill() + .frame(width: imageSize, height: imageSize) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } else { + + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(.gray) + + Image(systemName: "person") + .foregroundStyle(.white) + } + } + } + + Text((friend.displayName?.isEmpty == false) ? friend.displayName! : friend.uid) + .foregroundStyle(Color(uiColor: (friend.displayName?.isEmpty == false) ? .label : .secondaryLabel)) } } } diff --git a/dg-muscle-ios/sources/Presentation/Friend/History/View/FriendHistoryView.swift b/dg-muscle-ios/sources/Presentation/Friend/History/View/FriendHistoryView.swift index ce41bec7..abccba85 100644 --- a/dg-muscle-ios/sources/Presentation/Friend/History/View/FriendHistoryView.swift +++ b/dg-muscle-ios/sources/Presentation/Friend/History/View/FriendHistoryView.swift @@ -82,12 +82,12 @@ public struct FriendHistoryView: View { .padding() } .scrollIndicators(.hidden) - .animation(.default, value: viewModel.status) .toolbar { if let user = viewModel.user { if let profileImage = user.photoURL { KFImage(profileImage) .resizable() + .scaledToFill() .frame(width: 30, height: 30) .clipShape(Circle()) } diff --git a/dg-muscle-ios/sources/Presentation/Friend/List/View/FriendListView.swift b/dg-muscle-ios/sources/Presentation/Friend/List/View/FriendListView.swift index 6a14235c..2b5dbc43 100644 --- a/dg-muscle-ios/sources/Presentation/Friend/List/View/FriendListView.swift +++ b/dg-muscle-ios/sources/Presentation/Friend/List/View/FriendListView.swift @@ -12,18 +12,25 @@ import MockData struct FriendListView: View { @StateObject var viewModel: FriendListViewModel - @State private var selectedFriend: Friend? + @Binding var selectedFriend: Friend? - init(friendRepository: FriendRepository) { + init( + friendRepository: FriendRepository, + selectedFriend: Binding + ) { _viewModel = .init(wrappedValue: .init(friendRepository: friendRepository)) + _selectedFriend = selectedFriend } var body: some View { List { ForEach(viewModel.friends, id: \.self) { friend in FriendListItemView(friend: friend) + .contentShape(Rectangle()) .onTapGesture { - selectedFriend = friend + if selectedFriend == nil { + selectedFriend = friend + } } .contextMenu { Button("delete") { @@ -33,13 +40,13 @@ struct FriendListView: View { } } .scrollIndicators(.hidden) - .fullScreenCover(item: $selectedFriend) { friend in - FriendProfileView(friend: friend, selectedFriend: $selectedFriend) - } } } #Preview { - return FriendListView(friendRepository: FriendRepositoryMock()) + return FriendListView( + friendRepository: FriendRepositoryMock(), + selectedFriend: .constant(nil) + ) .preferredColorScheme(.dark) } diff --git a/dg-muscle-ios/sources/Presentation/Friend/List/View/FriendProfileView.swift b/dg-muscle-ios/sources/Presentation/Friend/List/View/FriendProfileView.swift index ebf3e02d..f73623f2 100644 --- a/dg-muscle-ios/sources/Presentation/Friend/List/View/FriendProfileView.swift +++ b/dg-muscle-ios/sources/Presentation/Friend/List/View/FriendProfileView.swift @@ -14,84 +14,177 @@ import Common struct FriendProfileView: View { let friend: Friend + @Binding var selectedFriend: Friend? - private let profileImageSize: CGFloat = 80 + @State private var viewOffset: CGFloat = 0 + @State private var selectedImageURL: URL? = nil + @State private var opacity: CGFloat = 0 var body: some View { ZStack { - if let backgroundURL = friend.backgroundImageURL { - Rectangle() - .fill(.clear) - .background( - KFImage(backgroundURL) + backgroundView + + VStack { + xButton + Spacer() + profileView + + Text((friend.displayName?.isEmpty == false) ? friend.displayName! : friend.uid) + .foregroundStyle((friend.displayName?.isEmpty == false) ? .white : .gray) + + whiteLine + .padding(.top, 30) + bottomSection + .padding(.top) + } + + if selectedImageURL != nil { + FullScreenImageView(url: $selectedImageURL) + } + } + .offset(y: viewOffset) + .gesture ( + DragGesture(minimumDistance: 15) + .onChanged { gesture in + guard gesture.translation.height > 0 else { return } + viewOffset = gesture.translation.height + } + .onEnded { gesture in + let dismissableLocation = gesture.translation.height > 150 + let dismissableVolocity = gesture.velocity.height > 150 + if dismissableLocation || dismissableVolocity { + dismiss() + } else { + dragViewUp() + } + } + ) + .opacity(opacity) + .onAppear { + withAnimation { + opacity = 1 + } + } + } + + var backgroundView: some View { + Rectangle() + .fill(.clear) + .background { + ZStack { + Rectangle() + .fill(.gray) + + if let url = friend.backgroundImageURL { + KFImage(url) .resizable() .scaledToFill() - ) - .ignoresSafeArea() + .onTapGesture { + selectedImageURL = url + } + } + } + } + .ignoresSafeArea() + } + + var xButton: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .foregroundStyle(.white) + .font(.title) } - VStack(spacing: 16) { - - HStack { - Spacer() - Button("close", systemImage: "xmark") { - selectedFriend = nil + Spacer() + } + .padding(.horizontal) + } + + var profileView: some View { + RoundedRectangle(cornerRadius: 25.0, style: .continuous) + .stroke(.white.opacity(0.6)) + .fill(.clear) + .frame(width: 100, height: 100) + .background { + ZStack { + RoundedRectangle(cornerRadius: 25.0, style: .continuous) + .fill(.gray) + + Image(systemName: "person") + .font(.title) + .foregroundStyle(.white) + + if let url = friend.photoURL { + KFImage(url) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 25.0)) + .onTapGesture { + selectedImageURL = url + } } - .foregroundStyle(Color(uiColor: .label)) } - .padding(.horizontal) - - Spacer() - - if let url = friend.photoURL { - KFImage(url) - .resizable() - .frame(width: profileImageSize, height: profileImageSize) - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 16)) - } else { - RoundedRectangle(cornerRadius: 16) - .fill(Color(uiColor: .tertiarySystemBackground)) - .frame(width: profileImageSize, height: profileImageSize) - .overlay { - Image(systemName: "person") - .font(.largeTitle) - } - } - - Text(friend.displayName ?? friend.id) - .fontWeight(.black) - - if let link = friend.link { - VStack { - HStack { - Text(link.absoluteString) - Image(systemName: "link") - } - .onTapGesture { - URLManager.shared.open(url: link) - } - Divider() - .frame(height: 1) + } + } + + var whiteLine: some View { + Rectangle() + .fill(.white.opacity(0.7)) + .frame(height: 1) + } + + var bottomSection: some View { + HStack(spacing: 40) { + + if let link = friend.link { + Button { + URLManager.shared.open(url: link) + } label: { + VStack(spacing: 12) { + Image(systemName: "link") + Text("Link") } } - - GradientButton(action: { - selectedFriend = nil - URLManager.shared.open(url: "dgmuscle://friendhistory?id=\(friend.uid)") - }, text: "Workout History", backgroundColor: friend.heatMapColor.color) - .padding(.horizontal) + .foregroundStyle(.white) + } + + Button { + URLManager.shared.open(url: "dgmuscle://friendhistory?id=\(friend.uid)") + } label: { + VStack(spacing: 12) { + Image(systemName: "doc.richtext.ko") + Text("Exercise Record") + } + } + .foregroundStyle(.white) + } + } + + private func dismiss() { + withAnimation { + viewOffset = 1000 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + selectedFriend = nil } } } + + private func dragViewUp() { + withAnimation { + viewOffset = 0 + } + } } #Preview { - let view = FriendProfileView( + FriendProfileView( friend: .init(domain: USER_DG), selectedFriend: .constant(.init(domain: USER_DG)) ) - .preferredColorScheme(.dark) - return view + .preferredColorScheme(.dark) } diff --git a/dg-muscle-ios/sources/Presentation/Friend/Main/View/FriendMainView.swift b/dg-muscle-ios/sources/Presentation/Friend/Main/View/FriendMainView.swift index 49a6f584..ddcb1230 100644 --- a/dg-muscle-ios/sources/Presentation/Friend/Main/View/FriendMainView.swift +++ b/dg-muscle-ios/sources/Presentation/Friend/Main/View/FriendMainView.swift @@ -15,6 +15,9 @@ public struct FriendMainView: View { private let userRepository: UserRepository @State var page: PageAnchorView.Page + @State var selectedFriend: Friend? + + @StateObject var viewModel: FriendMainViewModel public init( friendRepository: FriendRepository, @@ -24,34 +27,50 @@ public struct FriendMainView: View { self.friendRepository = friendRepository self.userRepository = userRepository self.page = page + self._viewModel = .init(wrappedValue: .init(friendRepository: friendRepository)) } public var body: some View { - - VStack { - PageAnchorView( - page: $page, - friendRepository: friendRepository - ) - TabView(selection: $page) { - FriendListView(friendRepository: friendRepository) - .tag(PageAnchorView.Page.friend) - - SearchUsersView( - friendRepository: friendRepository, - userRepository: userRepository + ZStack { + VStack { + PageAnchorView( + page: $page, + friendRepository: friendRepository ) - .tag(PageAnchorView.Page.search) - RequestListView( - friendRepository: friendRepository, - userRepository: userRepository - ) - .tag(PageAnchorView.Page.request) + if viewModel.loading { + ProgressView() + } + + TabView(selection: $page) { + FriendListView( + friendRepository: friendRepository, + selectedFriend: $selectedFriend + ) + .tag(PageAnchorView.Page.friend) + + SearchUsersView( + friendRepository: friendRepository, + userRepository: userRepository + ) + .tag(PageAnchorView.Page.search) + + RequestListView( + friendRepository: friendRepository, + userRepository: userRepository + ) + .tag(PageAnchorView.Page.request) + } + .tabViewStyle(.page) + } + .animation(.default, value: page) + .animation(.default, value: viewModel.loading) + + if let selectedFriend { + FriendProfileView(friend: selectedFriend, selectedFriend: $selectedFriend) } - .tabViewStyle(.page) } - .animation(.default, value: page) + .navigationBarBackButtonHidden(selectedFriend != nil) } } diff --git a/dg-muscle-ios/sources/Presentation/Friend/Main/View/FriendMainViewModel.swift b/dg-muscle-ios/sources/Presentation/Friend/Main/View/FriendMainViewModel.swift new file mode 100644 index 00000000..c2ea8f6d --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/Friend/Main/View/FriendMainViewModel.swift @@ -0,0 +1,28 @@ +// +// FriendMainViewModel.swift +// Friend +// +// Created by 신동규 on 7/29/24. +// + +import Foundation +import Combine +import Domain + +final class FriendMainViewModel: ObservableObject { + @Published var loading: Bool = true + + let subscribeFetchingFriendLoadingUsecase: SubscribeFetchingFriendLoadingUsecase + + init(friendRepository: FriendRepository) { + subscribeFetchingFriendLoadingUsecase = .init(friendRepository: friendRepository) + bind() + } + + private func bind() { + subscribeFetchingFriendLoadingUsecase + .implement() + .receive(on: DispatchQueue.main) + .assign(to: &$loading) + } +} diff --git a/dg-muscle-ios/sources/Presentation/Friend/UserList/View/SearchUsersView.swift b/dg-muscle-ios/sources/Presentation/Friend/UserList/View/SearchUsersView.swift index 8c3b58cf..ba5b5b64 100644 --- a/dg-muscle-ios/sources/Presentation/Friend/UserList/View/SearchUsersView.swift +++ b/dg-muscle-ios/sources/Presentation/Friend/UserList/View/SearchUsersView.swift @@ -38,8 +38,6 @@ struct SearchUsersView: View { } } } - - } TextField("Search users by display name", text: $viewModel.query) diff --git a/dg-muscle-ios/sources/Presentation/Main/Coordinator/MyCoordinator.swift b/dg-muscle-ios/sources/Presentation/Main/Coordinator/MyCoordinator.swift index d3b03dbd..9c131367 100644 --- a/dg-muscle-ios/sources/Presentation/Main/Coordinator/MyCoordinator.swift +++ b/dg-muscle-ios/sources/Presentation/Main/Coordinator/MyCoordinator.swift @@ -15,10 +15,6 @@ public final class MyCoordinator { self._path = path } - public func profile() { - path.append(MyNavigation(name: .profile)) - } - public func deleteAccountConfirm() { path.append(MyNavigation(name: .deleteAccountConfirm)) } diff --git a/dg-muscle-ios/sources/Presentation/Main/Navigation/MyNavigation.swift b/dg-muscle-ios/sources/Presentation/Main/Navigation/MyNavigation.swift index 56548f10..31fe27ae 100644 --- a/dg-muscle-ios/sources/Presentation/Main/Navigation/MyNavigation.swift +++ b/dg-muscle-ios/sources/Presentation/Main/Navigation/MyNavigation.swift @@ -13,7 +13,6 @@ public struct MyNavigation: Hashable { extension MyNavigation { public enum Name { - case profile case deleteAccountConfirm case logs } diff --git a/dg-muscle-ios/sources/Presentation/Main/View/HomeView.swift b/dg-muscle-ios/sources/Presentation/Main/View/HomeView.swift index 8748138f..02c957cb 100644 --- a/dg-muscle-ios/sources/Presentation/Main/View/HomeView.swift +++ b/dg-muscle-ios/sources/Presentation/Main/View/HomeView.swift @@ -12,62 +12,90 @@ import Domain import MockData public struct HomeView: View { - + + @State private var showProfileView: Bool = false + let today: Date - let historyRepository: HistoryRepository - let exerciseRepository: ExerciseRepository - let heatMapRepository: HeatMapRepository - let userRepository: UserRepository - let logRepository: LogRepository - - public init(today: Date, - historyRepository: HistoryRepository, - exerciseRepository: ExerciseRepository, - heatMapRepository: HeatMapRepository, - userRepository: UserRepository, - logRepository: LogRepository + let historyListFactory: (Date) -> HistoryListView + let myViewFactory: ((() -> Void)?) -> MyView + let myProfileViewFactory: (Binding) -> MyProfileView + + public init( + today: Date, + historyListFactory: @escaping (Date) -> HistoryListView, + myViewFactory: @escaping ((() -> Void)?) -> MyView, + myProfileViewFactory: @escaping (Binding) -> MyProfileView ) { self.today = today - self.historyRepository = historyRepository - self.exerciseRepository = exerciseRepository - self.heatMapRepository = heatMapRepository - self.userRepository = userRepository - self.logRepository = logRepository + self.historyListFactory = historyListFactory + self.myViewFactory = myViewFactory + self.myProfileViewFactory = myProfileViewFactory } - + public var body: some View { ZStack { Rectangle().fill(Color(uiColor: .systemBackground)) TabView { - HistoryListView(today: today, - historyRepository: historyRepository, - exerciseRepository: exerciseRepository, - heatMapRepository: heatMapRepository, - userRepository: userRepository) - - MyView(userRepository: userRepository, - logRepository: logRepository) - + historyListFactory(today) + myViewFactory { + if showProfileView == false { + showProfileView = true + } + } } .ignoresSafeArea() - .tabViewStyle(.page(indexDisplayMode: .always)) - .indexViewStyle(.page(backgroundDisplayMode: .always)) + .tabViewStyle(.page(indexDisplayMode: showProfileView ? .never : .always)) + .indexViewStyle(.page(backgroundDisplayMode: showProfileView ? .never : .always)) + + if showProfileView { + myProfileViewFactory($showProfileView) + } } } } #Preview { - + let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyyMMdd" let today = dateFormatter.date(from: "20240515")! - - return HomeView(today: today, - historyRepository: HistoryRepositoryMock(), - exerciseRepository: ExerciseRepositoryMock(), - heatMapRepository: HeatMapRepositoryMock(), - userRepository: UserRepositoryMock(), - logRepository: LogRepositoryMock() + + let historyRepository = HistoryRepositoryMock() + let exerciseRepository = ExerciseRepositoryMock() + let heatMapRepository = HeatMapRepositoryMock() + let userRepository = UserRepositoryMock() + let logRepository = LogRepositoryMock() + + return HomeView( + today: today, + historyListFactory: { + today in HistoryListView( + today: today, + historyRepository: historyRepository, + exerciseRepository: exerciseRepository, + heatMapRepository: heatMapRepository, + userRepository: userRepository + ) + }, + myViewFactory: { _ in + MyView( + userRepository: userRepository, + logRepository: logRepository, + presentProfileViewAction: nil + ) + }, + myProfileViewFactory: {_ in + MyProfileView( + shows: .constant(false), + userRepository: userRepository, + myProfileEditFactory: { _ in + MyProfileEditView( + userRepository: userRepository, + isEditing: .constant(true) + ) + } + ) + } ) .preferredColorScheme(.dark) } diff --git a/dg-muscle-ios/sources/Presentation/Main/View/NavigationView.swift b/dg-muscle-ios/sources/Presentation/Main/View/NavigationView.swift index 837268a4..fdcb721f 100644 --- a/dg-muscle-ios/sources/Presentation/Main/View/NavigationView.swift +++ b/dg-muscle-ios/sources/Presentation/Main/View/NavigationView.swift @@ -29,7 +29,6 @@ public struct NavigationView: View { let setDurationFactory: (Int) -> SetDurationView let manageMemoFactory: (Binding) -> ManageMemoView let dateToSelectHistoryFactory: () -> DateToSelectHistoryView - let myProfileFactory: () -> MyProfileView let deleteAccountConfirmFactory: () -> DeleteAccountConfirmView let logsFactory: () -> LogsView let friendMainFactory: (PageAnchorView.Page) -> FriendMainView @@ -56,7 +55,6 @@ public struct NavigationView: View { setDurationFactory: @escaping (Int) -> SetDurationView, manageMemoFactory: @escaping (Binding) -> ManageMemoView, dateToSelectHistoryFactory: @escaping () -> DateToSelectHistoryView, - myProfileFactory: @escaping () -> MyProfileView, deleteAccountConfirmFactory: @escaping () -> DeleteAccountConfirmView, logsFactory: @escaping () -> LogsView, friendMainFactory: @escaping (PageAnchorView.Page) -> FriendMainView, @@ -82,7 +80,6 @@ public struct NavigationView: View { self.setDurationFactory = setDurationFactory self.manageMemoFactory = manageMemoFactory self.dateToSelectHistoryFactory = dateToSelectHistoryFactory - self.myProfileFactory = myProfileFactory self.deleteAccountConfirmFactory = deleteAccountConfirmFactory self.logsFactory = logsFactory self.friendMainFactory = friendMainFactory @@ -131,8 +128,6 @@ public struct NavigationView: View { } .navigationDestination(for: MyNavigation.self) { navigation in switch navigation.name { - case .profile: - myProfileFactory() case .deleteAccountConfirm: deleteAccountConfirmFactory() case .logs: diff --git a/dg-muscle-ios/sources/Presentation/My/Common/View/BackgroundImageView.swift b/dg-muscle-ios/sources/Presentation/My/Common/View/BackgroundImageView.swift new file mode 100644 index 00000000..bd491461 --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/My/Common/View/BackgroundImageView.swift @@ -0,0 +1,36 @@ +// +// BackgroundImageView.swift +// My +// +// Created by 신동규 on 8/3/24. +// + +import SwiftUI +import Kingfisher + +struct BackgroundImageView: View { + + let url: URL? + let tapImage: ((URL) -> ())? + + var body: some View { + Rectangle() + .fill(.clear) + .background { + ZStack { + Rectangle() + .fill(.gray) + + if let url { + KFImage(url) + .resizable() + .scaledToFill() + .onTapGesture { + tapImage?(url) + } + } + } + } + .ignoresSafeArea() + } +} diff --git a/dg-muscle-ios/sources/Presentation/My/My/View/MyView.swift b/dg-muscle-ios/sources/Presentation/My/My/View/MyView.swift index 9d221ffd..74444f95 100644 --- a/dg-muscle-ios/sources/Presentation/My/My/View/MyView.swift +++ b/dg-muscle-ios/sources/Presentation/My/My/View/MyView.swift @@ -14,14 +14,18 @@ import Common public struct MyView: View { @StateObject var viewModel: MyViewModel + let presentProfileViewAction: (() -> Void)? + public init( userRepository: any UserRepository, - logRepository: LogRepository + logRepository: LogRepository, + presentProfileViewAction: (() -> Void)? ) { _viewModel = .init( wrappedValue: .init(userRepository: userRepository, logRepository: logRepository) ) + self.presentProfileViewAction = presentProfileViewAction } public var body: some View { @@ -38,24 +42,33 @@ public struct MyView: View { Section { VStack(spacing: 20) { + + Button { + presentProfileViewAction?() + } label: { + ListItemView(systemName: "person", text: "Profile", color: Color(uiColor: .secondaryLabel)) + } + .buttonStyle(.borderless) + + Button { URLManager.shared.open(url: "dgmuscle://friend") } label: { - ListItemView(systemName: "person", text: "Friend", color: .green) + ListItemView(systemName: "link", text: "Friend", color: Color(uiColor: .secondaryLabel)) } .buttonStyle(.borderless) Button { URLManager.shared.open(url: "dgmuscle://exercisemanage") } label: { - ListItemView(systemName: "dumbbell", text: "Exercise", color: .blue) + ListItemView(systemName: "dumbbell", text: "Exercise", color: Color(uiColor: .secondaryLabel)) } .buttonStyle(.borderless) Button { URLManager.shared.open(url: "dgmuscle://rapidsearchtype") } label: { - ListItemView(systemName: "doc", text: "Exercise DB", color: .blue) + ListItemView(systemName: "doc", text: "Exercise DB", color: Color(uiColor: .secondaryLabel)) } .buttonStyle(.borderless) @@ -63,7 +76,7 @@ public struct MyView: View { Button { URLManager.shared.open(url: "dgmuscle://logs") } label: { - ListItemView(systemName: "doc", text: "Logs", color: .purple) + ListItemView(systemName: "doc", text: "Logs", color: Color(uiColor: .secondaryLabel)) } .buttonStyle(.borderless) .overlay { @@ -81,19 +94,42 @@ public struct MyView: View { } header: { if let user = viewModel.user { - if user.displayName?.isEmpty == false { - Button { - URLManager.shared.open(url: "dgmuscle://profile") - } label: { - UserItemView(user: user) - .padding(.bottom) - } - } else { - Button("Update Profile") { - URLManager.shared.open(url: "dgmuscle://profile") + Button { + presentProfileViewAction?() + } label: { + let size: CGFloat = 50 + HStack { + RoundedRectangle(cornerRadius: 20) + .fill(.clear) + .strokeBorder(.white.opacity(0.7), lineWidth: 1) + .frame(width: size, height: size) + .background { + ZStack { + if let url = user.photoURL { + KFImage(url) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } else { + RoundedRectangle(cornerRadius: 20) + .fill(.gray) + Image(systemName: "person") + .foregroundStyle(.white) + } + } + } + .padding(.trailing, 10) + + Text((user.displayName?.isEmpty == false) ? user.displayName! : user.uid) + .foregroundStyle((user.displayName?.isEmpty == false) ? Color(uiColor: .label) : Color(uiColor: .secondaryLabel)) + + Spacer() } .padding(.bottom) + .contentShape(Rectangle()) } + .buttonStyle(.plain) } } @@ -121,7 +157,8 @@ public struct MyView: View { #Preview { return MyView( userRepository: UserRepositoryMock(), - logRepository: LogRepositoryMock() + logRepository: LogRepositoryMock(), + presentProfileViewAction: nil ) .preferredColorScheme(.dark) } diff --git a/dg-muscle-ios/sources/Presentation/My/My/View/UserItemView.swift b/dg-muscle-ios/sources/Presentation/My/My/View/UserItemView.swift deleted file mode 100644 index 123bb5a3..00000000 --- a/dg-muscle-ios/sources/Presentation/My/My/View/UserItemView.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// UserItemView.swift -// My -// -// Created by 신동규 on 5/19/24. -// - -import SwiftUI -import Common -import Kingfisher - -struct UserItemView: View { - - let user: Common.User - - var body: some View { - HStack { - if let url = user.photoURL { - KFImage(url) - .resizable() - .scaledToFill() - .frame(width: 35, height: 35) - .clipShape(Circle()) - } - - Text(user.displayName ?? "Display Name") - .foregroundStyle(Color(uiColor: .label)) - .fontWeight(.black) - - Spacer() - } - } -} diff --git a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfile/MyProfileView.swift b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfile/MyProfileView.swift new file mode 100644 index 00000000..341ceeda --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfile/MyProfileView.swift @@ -0,0 +1,201 @@ +// +// MyProfileView.swift +// My +// +// Created by 신동규 on 5/25/24. +// + +import Foundation +import SwiftUI +import Domain +import MockData +import Common +import Kingfisher + +public struct MyProfileView: View { + + struct IdentifiableURL: Identifiable { + let id = UUID().uuidString + let url: URL + } + + @Binding var shows: Bool + + @State private var viewOffset: CGFloat = 0 + @State private var selectedImageURL: URL? = nil + @State private var opacity: CGFloat = 0 + + @StateObject var viewModel: MyProfileViewModel + + private let myProfileEditFactory: (Binding) -> MyProfileEditView + + public init( + shows: Binding, + userRepository: UserRepository, + myProfileEditFactory: @escaping (Binding) -> MyProfileEditView + ) { + _shows = shows + _viewModel = .init(wrappedValue: .init(userRepository: userRepository)) + self.myProfileEditFactory = myProfileEditFactory + } + + public var body: some View { + ZStack { + BackgroundImageView(url: viewModel.user?.backgroundImageURL) { url in + selectedImageURL = url + } + VStack { + xButton + Spacer() + profileView + if let user = viewModel.user { + Text((user.displayName?.isEmpty == false) ? user.displayName! : user.uid) + .foregroundStyle((user.displayName?.isEmpty == false) ? .white : .gray) + } + + whiteLine + .padding(.top, 30) + bottomSection + .padding(.top) + } + + if selectedImageURL != nil { + FullScreenImageView(url: $selectedImageURL) + } + + if viewModel.isEditing { + myProfileEditFactory($viewModel.isEditing) + } + } + .opacity(opacity) + .offset(y: viewOffset) + .gesture ( + DragGesture(minimumDistance: 15) + .onChanged { gesture in + guard gesture.translation.height > 0 else { return } + viewOffset = gesture.translation.height + } + .onEnded { gesture in + let dismissableLocation = gesture.translation.height > 150 + let dismissableVolocity = gesture.velocity.height > 150 + if dismissableLocation || dismissableVolocity { + dismiss() + } else { + dragViewUp() + } + } + ) + .onAppear { + withAnimation { + opacity = 1 + } + } + } + + private func dismiss() { + withAnimation { + viewOffset = 1000 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + shows = false + } + } + } + + private func dragViewUp() { + withAnimation { + viewOffset = 0 + } + } + + var xButton: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .foregroundStyle(.white) + .font(.title) + } + + Spacer() + } + .padding(.horizontal) + } + + var profileView: some View { + RoundedRectangle(cornerRadius: 40, style: .continuous) + .stroke(.white.opacity(0.6)) + .fill(.clear) + .frame(width: 100, height: 100) + .background { + ZStack { + RoundedRectangle(cornerRadius: 40, style: .continuous) + .fill(.gray) + + Image(systemName: "person") + .font(.title) + .foregroundStyle(.white) + + if let url = viewModel.user?.photoURL { + KFImage(url) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 40)) + .onTapGesture { + if selectedImageURL == nil { + selectedImageURL = url + } + } + } + } + } + } + + var whiteLine: some View { + Rectangle() + .fill(.white.opacity(0.7)) + .frame(height: 1) + } + + var bottomSection: some View { + HStack(spacing: 40) { + + if let link = viewModel.user?.link { + Button { + URLManager.shared.open(url: link) + } label: { + VStack(spacing: 12) { + Image(systemName: "link") + Text("Link") + } + } + .foregroundStyle(.white) + } + + Button { + viewModel.isEditing = true + } label: { + VStack(spacing: 12) { + Image(systemName: "pencil") + Text("Edit") + } + } + .foregroundStyle(.white) + } + } +} + +#Preview { + return MyProfileView( + shows: .constant(true), + userRepository: UserRepositoryMock(), + myProfileEditFactory: { _ in + MyProfileEditView( + userRepository: UserRepositoryMock(), + isEditing: .constant(true) + ) + } + ) + .preferredColorScheme(.dark) +} diff --git a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfile/MyProfileViewModel.swift b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfile/MyProfileViewModel.swift new file mode 100644 index 00000000..5a66716a --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfile/MyProfileViewModel.swift @@ -0,0 +1,35 @@ +// +// MyProfileViewModel.swift +// My +// +// Created by 신동규 on 5/25/24. +// + +import Foundation +import Combine +import Common +import Domain + +final class MyProfileViewModel: ObservableObject { + + @Published var user: Common.User? + @Published var isEditing: Bool = false + + private let subscribeUserUsecase: SubscribeUserUsecase + + init( + userRepository: UserRepository + ) { + subscribeUserUsecase = .init(userRepository: userRepository) + bind() + } + + private func bind() { + subscribeUserUsecase + .implement() + .compactMap({ $0 }) + .map({ Common.User(domain: $0) }) + .receive(on: DispatchQueue.main) + .assign(to: &$user) + } +} diff --git a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/MyProfileEditView.swift b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/MyProfileEditView.swift new file mode 100644 index 00000000..6b15fec1 --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/MyProfileEditView.swift @@ -0,0 +1,227 @@ +// +// MyProfileEditView.swift +// My +// +// Created by 신동규 on 8/3/24. +// + +import SwiftUI +import MockData +import Domain +import PhotosUI +import Common + +public struct MyProfileEditView: View { + + @StateObject var viewModel: MyProfileEditViewModel + @Binding var isEditing: Bool + + @State var isEditingDisplayName: Bool = false + @State var isEditingLink: Bool = false + + private let makeURLFromStringUsecase: MakeURLFromStringUsecase + private let makeStringFromURLUsecase: MakeStringFromURLUsecase + + public init( + userRepository: UserRepository, + isEditing: Binding + ) { + _viewModel = .init(wrappedValue: .init(userRepository: userRepository)) + _isEditing = isEditing + makeURLFromStringUsecase = .init() + makeStringFromURLUsecase = .init() + } + + public var body: some View { + ZStack { + backgroundView + VStack { + topSection + Spacer() + profileImageView + .padding(.bottom, 39) + labels + .padding(.horizontal) + .padding(.bottom) + } + + if isEditingDisplayName { + displayNameForm + } else if isEditingLink { + linkForm + } + } + .overlay { + ZStack { + if viewModel.snackbar != nil { + Common.SnackbarView(message: $viewModel.snackbar) + } + + if viewModel.loading { + ProgressView() + } + } + } + } + + var backgroundView: some View { + Rectangle() + .fill(.clear) + .background { + ZStack { + PhotosPicker( + selection: $viewModel.selectedBackgroundPhoto, + label: { + if let image = viewModel.backgroundImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .contextMenu { + Button("Delete", systemImage: "trash") { + viewModel.deleteBackgroundImage() + } + } + } else { + Rectangle() + .fill(.gray) + } + } + ) + } + } + .ignoresSafeArea() + } + + var topSection: some View { + HStack { + Button { + isEditing = false + } label: { + Text("Cancel") + } + .disabled(viewModel.loading) + + Spacer() + + Button { + Task { + await viewModel.done() + isEditing = false + } + } label: { + Text("Done") + } + .disabled(viewModel.loading) + } + .padding(.horizontal) + .foregroundStyle(.white) + } + + var profileImageView: some View { + RoundedRectangle(cornerRadius: 40) + .stroke(.white.opacity(0.6)) + .frame(width: 100, height: 100) + .background { + PhotosPicker(selection: $viewModel.selectedUserPhoto) { + if let image = viewModel.userImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 40)) + .contextMenu { + Button("Delete", systemImage: "trash") { + viewModel.deleteUserPhoto() + } + } + } else { + ZStack { + RoundedRectangle(cornerRadius: 40) + .fill(.gray) + + Image(systemName: "person") + .foregroundStyle(.white) + } + } + } + } + .overlay { + VStack { + Spacer() + HStack { + Spacer() + PhotosPicker(selection: $viewModel.selectedUserPhoto) { + Image(systemName: "camera.fill") + .foregroundStyle(.black) + .background { + Circle() + .fill(.white) + .frame(width: 30, height: 30) + } + } + } + } + } + } + + var labels: some View { + VStack(spacing: 20) { + Button { + if isEditingDisplayName == false { + isEditingDisplayName = true + } + } label: { + WhiteUnderlineTextLabel(text: viewModel.displayName) + } + + Button { + isEditingLink = true + } label: { + if let link = viewModel.link { + WhiteUnderlineTextLabel(text: makeStringFromURLUsecase.implement(url: link)) + } else { + WhiteUnderlineTextLabel(text: "Enter a link to express yourself") + } + } + } + } + + var displayNameForm: some View { + return ProfileTextInputView( + text: viewModel.displayName, + showing: $isEditingDisplayName, + maxLength: 20 + ) { value in + viewModel.setDisplayName(value) + } + } + + var linkForm: some View { + ProfileTextInputView( + text: viewModel.link?.absoluteString ?? "", + showing: $isEditingLink, + maxLength: 200) { value in + + if value.isEmpty { + viewModel.setLink(nil) + return + } + + let link = makeURLFromStringUsecase.implement(link: value) + + viewModel.setLink(link) + + if link == nil { + viewModel.snackbar = "Please enter a valid URL" + } + } + } +} + +#Preview { + MyProfileEditView( + userRepository: UserRepositoryMock(), + isEditing: .constant(true) + ) + .preferredColorScheme(.dark) +} diff --git a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/MyProfileEditViewModel.swift b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/MyProfileEditViewModel.swift new file mode 100644 index 00000000..67223028 --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/MyProfileEditViewModel.swift @@ -0,0 +1,199 @@ +// +// MyProfileEditViewModel.swift +// My +// +// Created by 신동규 on 8/3/24. +// + +import Combine +import Domain +import SwiftUI +import PhotosUI +import Common + +final class MyProfileEditViewModel: ObservableObject { + + @Published var selectedBackgroundPhoto: PhotosPickerItem? + @Published var backgroundImage: UIImage? + private var backgroundImageChanged: Bool = false + + @Published var selectedUserPhoto: PhotosPickerItem? + @Published var userImage: UIImage? + private var userImageChanged: Bool = false + + @Published var displayName: String = "" + private var displayNameChanged: Bool = false + + @Published var link: URL? + private var linkChanged: Bool = false + + @Published var loading: Bool = false + @Published var snackbar: String? + + private let getUserUsecase: GetUserUsecase + private let postBackgroundImageUsecase: PostBackgroundImageUsecase + private let postDisplayNameUsecase: PostDisplayNameUsecase + private let postLinkUsecase: PostLinkUsecase + private let postProfilePhotoUsecase: PostProfilePhotoUsecase + + private var cancellables = Set() + + init(userRepository: UserRepository) { + getUserUsecase = .init(userRepository: userRepository) + postBackgroundImageUsecase = .init(userRepository: userRepository) + postDisplayNameUsecase = .init(userRepository: userRepository) + postLinkUsecase = .init(userRepository: userRepository) + postProfilePhotoUsecase = .init(userRepository: userRepository) + + guard let user = getUserUsecase.implement() else { return } + + if let backgroundImageURL = user.backgroundImageURL { + Task { + let backgroundImage = try await UIImageGenerator.shared.generateImageFrom(url: backgroundImageURL) + DispatchQueue.main.async { [weak self] in + self?.backgroundImage = backgroundImage + } + } + } + + if let userImageUrl = user.photoURL { + Task { + let userImage = try await UIImageGenerator.shared.generateImageFrom(url: userImageUrl) + DispatchQueue.main.async { [weak self] in + self?.userImage = userImage + } + } + } + + self.displayName = (user.displayName ?? "Display Name") + self.link = user.link + + bind() + } + + @MainActor + func done() async { + loading = true + do { + async let postBackgroundImage: Void = { + if backgroundImageChanged { + try await postBackgroundImageUsecase.implement(backgroundImage: backgroundImage) + } + }() + + async let postProfilePhoto: Void = { + if userImageChanged { + try await postProfilePhotoUsecase.implement(photo: userImage) + } + }() + + async let postDisplayName: Void = { + if displayNameChanged { + try await postDisplayNameUsecase.implement(displayName: displayName.isEmpty ? nil : displayName) + } + }() + + let postLink: Void = { + if linkChanged { + postLinkUsecase.implement(link: link) + } + }() + + // 병렬로 시작된 모든 작업이 완료될 때까지 기다립니다. + try await postBackgroundImage + try await postProfilePhoto + try await postDisplayName + postLink // 이 부분은 비동기가 아니므로 try await가 필요 없습니다. + + } catch { + snackbar = error.localizedDescription + } + loading = false + } + + @MainActor + func deleteUserPhoto() { + userImage = nil + userImageChanged = true + } + + @MainActor + func deleteBackgroundImage() { + backgroundImage = nil + backgroundImageChanged = true + } + + @MainActor + func setLink(_ value: URL?) { + linkChanged = true + self.link = value + } + + @MainActor + func setDisplayName(_ value: String) { + displayNameChanged = true + self.displayName = value + } + + private func bind() { + $selectedBackgroundPhoto + .dropFirst() + .sink { [weak self] photo in + self?.configureBackgroundImage(photo: photo) + } + .store(in: &cancellables) + + $selectedUserPhoto + .dropFirst() + .sink { [weak self] photo in + self?.configureUserImage(photo: photo) + } + .store(in: &cancellables) + } + + private func configureBackgroundImage(photo: PhotosPickerItem?) { + Task { + backgroundImageChanged = true + guard let photo else { + DispatchQueue.main.async { [weak self] in + self?.backgroundImage = nil + } + return + } + + guard let data = try await photo.loadTransferable(type: Data.self) else { + DispatchQueue.main.async { [weak self] in + self?.backgroundImage = nil + } + return + } + + DispatchQueue.main.async { [weak self] in + self?.backgroundImage = UIImage(data: data) + } + } + } + + private func configureUserImage(photo: PhotosPickerItem?) { + Task { + userImageChanged = true + guard let photo else { + DispatchQueue.main.async { [weak self] in + self?.userImage = nil + } + return + } + + guard let data = try await photo.loadTransferable(type: Data.self) else { + DispatchQueue.main.async { [weak self] in + self?.userImage = nil + } + return + } + + DispatchQueue.main.async { [weak self] in + self?.userImage = UIImage(data: data) + } + } + } +} diff --git a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/ProfileTextInputView.swift b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/ProfileTextInputView.swift new file mode 100644 index 00000000..18a758d7 --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/ProfileTextInputView.swift @@ -0,0 +1,137 @@ +// +// ProfileTextInputView.swift +// My +// +// Created by 신동규 on 8/3/24. +// + +import SwiftUI + +struct ProfileTextInputView: View { + + @State var text: String { + didSet { + if text.count > maxLength { + text = String(text.prefix(maxLength)) + } + } + } + @State var opacity: CGFloat = 0 + + @Binding var showing: Bool + + @FocusState var focus + + let enter: ((String) -> ())? + let maxLength: Int + + init( + text: String, + showing: Binding, + maxLength: Int, + enter: ((String) -> ())? + ) { + self.text = text + self._showing = showing + self.enter = enter + self.maxLength = maxLength + } + + var body: some View { + ZStack { + Rectangle() + .fill(.black.opacity(0.7)) + .ignoresSafeArea() + + topSection + + textInput + .padding(.horizontal) + } + .foregroundStyle(.white) + .opacity(opacity) + .onAppear { + focus = true + withAnimation { + opacity = 1 + } + } + } + + func dismiss() { + withAnimation { + opacity = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + showing = false + } + } + + var topSection: some View { + VStack { + HStack { + Button { + dismiss() + } label: { + Text("Cancel") + } + + Spacer() + + Button { + enter?(text) + dismiss() + } label: { + Text("OK") + .foregroundStyle(text.isEmpty ? .gray : .white) + } + .disabled(text.isEmpty) + + } + .overlay { + Text("Name").fontWeight(.black) + } + .padding(.horizontal) + Spacer() + } + } + + var textInput: some View { + VStack(spacing: 12) { + TextField("", text: $text) + .focused($focus) + .multilineTextAlignment(.center) + .overlay { + HStack { + Spacer() + + if text.isEmpty == false { + Button { + text = "" + } label: { + Image(systemName: "xmark") + .font(.caption2) + .padding(4) + .background { + Circle() + .fill(.gray) + } + } + } + } + } + + Rectangle() + .fill(.white.opacity(0.6)) + .frame(height: 1) + + Text("\(text.count) / \(maxLength)") + .font(.caption2) + } + } +} + +#Preview { + ProfileTextInputView(text: "동규", showing: .constant(true), maxLength: 20, enter: nil) +} diff --git a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/WhiteUnderlineTextLabel.swift b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/WhiteUnderlineTextLabel.swift new file mode 100644 index 00000000..b24ff629 --- /dev/null +++ b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileEdit/WhiteUnderlineTextLabel.swift @@ -0,0 +1,35 @@ +// +// WhiteUnderlineTextLabel.swift +// My +// +// Created by 신동규 on 8/3/24. +// + +import SwiftUI + +struct WhiteUnderlineTextLabel: View { + + let text: String + + var body: some View { + VStack { + Text(text) + .foregroundStyle(.white) + Rectangle() + .fill(.white.opacity(0.6)) + .frame(height: 1) + } + .overlay { + HStack { + Spacer() + Image(systemName: "pencil") + .foregroundStyle(.white) + } + } + } +} + +#Preview { + WhiteUnderlineTextLabel(text: "동규") + .preferredColorScheme(.dark) +} diff --git a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileView.swift b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileView.swift deleted file mode 100644 index f99a7283..00000000 --- a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileView.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// MyProfileView.swift -// My -// -// Created by 신동규 on 5/25/24. -// - -import Foundation -import SwiftUI -import Domain -import MockData -import Common - -public struct MyProfileView: View { - - @StateObject var viewModel: MyProfileViewModel - @FocusState var displayNameFocus - @FocusState var linkFocus - @State var isPresentImagePickerForProfilePhoto: Bool = false - @State var isPresentImagePickerForBackground: Bool = false - - private let profilePhotoSize: CGFloat = 80 - - public init(userRepository: UserRepository) { - _viewModel = .init(wrappedValue: .init(userRepository: userRepository)) - } - - public var body: some View { - ZStack { - - if let image = viewModel.backgroundImage { - Rectangle() - .fill(.clear) - .background( - Image(uiImage: image) - .resizable() - .scaledToFill() - ) - .ignoresSafeArea() - } - - VStack(spacing: 20) { - - if let status = viewModel.status { - - switch status { - case .loading: - Common.StatusView(status: status) - case .success, .error: - Common.StatusView(status: status) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - viewModel.status = nil - } - } - } - } - - Spacer() - - Button { - isPresentImagePickerForProfilePhoto.toggle() - } label: { - if let uiimage = viewModel.profilePhoto { - Image(uiImage: uiimage) - .resizable() - .frame(width: profilePhotoSize, height: profilePhotoSize) - .clipShape( - RoundedRectangle(cornerRadius: profilePhotoSize / 4) - ) - } else { - ZStack { - RoundedRectangle(cornerRadius: profilePhotoSize / 4) - .fill( - Color(uiColor: .secondarySystemBackground) - ) - .frame(width: profilePhotoSize, height: profilePhotoSize) - - Image(systemName: "person") - .font(.largeTitle) - - } - } - } - .foregroundStyle(Color(uiColor: .label)) - - HStack { - Image(systemName: "pencil").hidden() - - TextField("Display Name", text: $viewModel.displayName) - .multilineTextAlignment(.center) - .focused($displayNameFocus) - .fontWeight(.black) - Image(systemName: "pencil") - .onTapGesture { - displayNameFocus.toggle() - } - } - - HStack { - Image(systemName: "link").hidden() - - TextField("Link", text: $viewModel.link) - .multilineTextAlignment(.center) - .focused($linkFocus) - .fontWeight(.black) - Image(systemName: "link") - .onTapGesture { - linkFocus.toggle() - } - } - - Divider() - - Common.GradientButton(action: { - viewModel.save() - }, text: "SAVE", backgroundColor: viewModel.color) - } - .padding(.horizontal) - .animation(.default, value: viewModel.status) - } - .fullScreenCover(isPresented: $isPresentImagePickerForProfilePhoto) { - Common.ImagePicker(image: $viewModel.profilePhoto) - .ignoresSafeArea() - } - .fullScreenCover(isPresented: $isPresentImagePickerForBackground) { - Common.ImagePicker(image: $viewModel.backgroundImage) - .ignoresSafeArea() - } - .overlay { - VStack { - HStack { - Spacer() - Button { - isPresentImagePickerForBackground.toggle() - } label: { - Image(systemName: "camera") - .font(.title) - .padding(.trailing) - .foregroundStyle(Color(uiColor: .label)) - } - - } - - Spacer() - } - } - } -} - -#Preview { - return MyProfileView(userRepository: UserRepositoryMock()) - .preferredColorScheme(.dark) -} diff --git a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileViewModel.swift b/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileViewModel.swift deleted file mode 100644 index 466d62f5..00000000 --- a/dg-muscle-ios/sources/Presentation/My/Profile/View/MyProfileViewModel.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// MyProfileViewModel.swift -// My -// -// Created by 신동규 on 5/25/24. -// - -import Foundation -import Combine -import Domain -import Common -import UIKit -import SwiftUI - -final class MyProfileViewModel: ObservableObject { - @Published var color: Color - @Published var displayName: String - @Published var profilePhoto: UIImage? - @Published var backgroundImage: UIImage? - @Published var status: Common.StatusView.Status? - @Published var link: String - - private let user: Common.User - private let postDisplayNameUsecase: PostDisplayNameUsecase - private let postPhotoURLUsecase: PostPhotoURLUsecase - private let postBackgroundImageUsecase: PostBackgroundImageUsecase - private let postLinkUsecase: PostLinkUsecase - private let getUserUsecase: GetUserUsecase - private let signOutUsecase: SignOutUsecase - private let getHeatMapColorUsecase: GetHeatMapColorUsecase - private var cancellables = Set() - - init(userRepository: UserRepository) { - postDisplayNameUsecase = .init(userRepository: userRepository) - postPhotoURLUsecase = .init(userRepository: userRepository) - postBackgroundImageUsecase = .init(userRepository: userRepository) - postLinkUsecase = .init(userRepository: userRepository) - getUserUsecase = .init(userRepository: userRepository) - signOutUsecase = .init(userRepository: userRepository) - getHeatMapColorUsecase = .init(userRepository: userRepository) - - if let userDomain = getUserUsecase.implement() { - self.user = .init(domain: userDomain) - self.displayName = userDomain.displayName ?? "" - } else { - self.user = .init() - self.displayName = "" - try? signOutUsecase.implement() - } - - let domainColor: Domain.HeatMapColor = getHeatMapColorUsecase.implement() - let heatMapColor: Common.HeatMapColor = .init(domain: domainColor) - self.color = heatMapColor.color - - self.link = user.link?.absoluteString ?? "" - - if let url = user.photoURL { - Task { @MainActor in - let uiimage = try await Common.UIImageGenerator.shared.generateImageFrom(url: url) - self.profilePhoto = uiimage - } - } - - if let url = user.backgroundImageURL { - Task { @MainActor in - let uiimage = try await Common.UIImageGenerator.shared.generateImageFrom(url: url) - self.backgroundImage = uiimage - } - } - } - - private var saveTask: Task<(), Never>? - @MainActor - func save() { - guard saveTask == nil else { return } - - if link.isEmpty == false { - guard let url = URL(string: link) else { - status = .error("Link must be url that can be opened") - return - } - - guard UIApplication.shared.canOpenURL(url) else { - status = .error("Link must be url that can be opened") - return - } - } - - saveTask = Task { - do { - status = .loading - - async let displayNameTask: () = postDisplayNameUsecase.implement(displayName: displayName) - async let photoTask: () = postPhotoURLUsecase.implement(photo: profilePhoto) - async let backgroundImageTask: () = postBackgroundImageUsecase.implement(backgroundImage: backgroundImage) - - postLinkUsecase.implement(link: .init(string: link)) - - try await displayNameTask - try await photoTask - try await backgroundImageTask - - status = .success("Done") - } catch { - status = .error(error.localizedDescription) - } - saveTask = nil - } - } -}