diff --git a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj index 4ef85a61..727a27a8 100644 --- a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj +++ b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj @@ -30,7 +30,7 @@ 67551C362AEC338600084A35 /* SWAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67551C352AEC338600084A35 /* SWAddress.swift */; }; 6758463F2965B7F2000BA5E0 /* PDFViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6758463E2965B7F2000BA5E0 /* PDFViewRepresentable.swift */; }; 675EC64F2814126800C2E229 /* TextEntryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675EC64E2814126800C2E229 /* TextEntryScreen.swift */; }; - 675EC6572815433600C2E229 /* UsersListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675EC6562815433600C2E229 /* UsersListScreen.swift */; }; + 675EC6572815433600C2E229 /* FriendsListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675EC6562815433600C2E229 /* FriendsListScreen.swift */; }; 675EC65F2815532800C2E229 /* EventFormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675EC65E2815532800C2E229 /* EventFormScreen.swift */; }; 675EC6632815AA4A00C2E229 /* MapSnapshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675EC6622815AA4A00C2E229 /* MapSnapshotView.swift */; }; 675FB8DB2ADDB87200C9671F /* ParksMapScreen+LocationSettingReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675FB8DA2ADDB87200C9671F /* ParksMapScreen+LocationSettingReminderView.swift */; }; @@ -48,6 +48,8 @@ 67795FB22D1C05D90087132F /* SWModels in Frameworks */ = {isa = PBXBuildFile; productRef = 67795FB12D1C05D90087132F /* SWModels */; }; 67795FB42D1C05D90087132F /* SWNetworkClient in Frameworks */ = {isa = PBXBuildFile; productRef = 67795FB32D1C05D90087132F /* SWNetworkClient */; }; 67891E38283E947B00B10802 /* ParkFormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67891E37283E947B00B10802 /* ParkFormScreen.swift */; }; + 679781892D5732D0004D0629 /* ParticipantsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679781882D5732D0004D0629 /* ParticipantsScreen.swift */; }; + 6797818B2D574C0E004D0629 /* MainUserFriendsListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6797818A2D574C0E004D0629 /* MainUserFriendsListScreen.swift */; }; 6798AA3E280AEDC900DB76F1 /* SwiftUI_WorkoutAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6798AA3D280AEDC900DB76F1 /* SwiftUI_WorkoutAppApp.swift */; }; 6798AA40280AEDC900DB76F1 /* RootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6798AA3F280AEDC900DB76F1 /* RootScreen.swift */; }; 6798AA42280AEDCA00DB76F1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6798AA41280AEDCA00DB76F1 /* Assets.xcassets */; }; @@ -119,7 +121,7 @@ 6758463E2965B7F2000BA5E0 /* PDFViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFViewRepresentable.swift; sourceTree = ""; }; 675EC64E2814126800C2E229 /* TextEntryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextEntryScreen.swift; sourceTree = ""; }; 675EC65528153B8200C2E229 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 675EC6562815433600C2E229 /* UsersListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersListScreen.swift; sourceTree = ""; }; + 675EC6562815433600C2E229 /* FriendsListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsListScreen.swift; sourceTree = ""; }; 675EC65E2815532800C2E229 /* EventFormScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventFormScreen.swift; sourceTree = ""; }; 675EC6622815AA4A00C2E229 /* MapSnapshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapSnapshotView.swift; sourceTree = ""; }; 675FB8DA2ADDB87200C9671F /* ParksMapScreen+LocationSettingReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParksMapScreen+LocationSettingReminderView.swift"; sourceTree = ""; }; @@ -137,6 +139,8 @@ 677717162B36D87200ED90BD /* SwiftUI-WorkoutApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "SwiftUI-WorkoutApp.xctestplan"; sourceTree = ""; }; 67891E37283E947B00B10802 /* ParkFormScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkFormScreen.swift; sourceTree = ""; }; 678CE36A2D1C510900F060C6 /* SWNetwork */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SWNetwork; sourceTree = ""; }; + 679781882D5732D0004D0629 /* ParticipantsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantsScreen.swift; sourceTree = ""; }; + 6797818A2D574C0E004D0629 /* MainUserFriendsListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUserFriendsListScreen.swift; sourceTree = ""; }; 6798AA3A280AEDC900DB76F1 /* WorkoutApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WorkoutApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6798AA3D280AEDC900DB76F1 /* SwiftUI_WorkoutAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUI_WorkoutAppApp.swift; sourceTree = ""; }; 6798AA3F280AEDC900DB76F1 /* RootScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootScreen.swift; sourceTree = ""; }; @@ -411,6 +415,7 @@ 674D0622282A9896007E75C6 /* SearchUsersScreen.swift */, 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */, 6740003F2D55E97900E5CB06 /* BlackListScreen.swift */, + 6797818A2D574C0E004D0629 /* MainUserFriendsListScreen.swift */, ); path = Profile; sourceTree = ""; @@ -426,7 +431,8 @@ 67C87FD4284388EF00D6377D /* PhotoSection */, 67D9169528396C1E0098D3CB /* SendMessageScreen.swift */, 675EC64E2814126800C2E229 /* TextEntryScreen.swift */, - 675EC6562815433600C2E229 /* UsersListScreen.swift */, + 675EC6562815433600C2E229 /* FriendsListScreen.swift */, + 679781882D5732D0004D0629 /* ParticipantsScreen.swift */, ); path = Common; sourceTree = ""; @@ -624,6 +630,7 @@ 671B4AEB2D4F683E00286996 /* ImagePickerViews.swift in Sources */, 67BD2D012AF7D21B00F44064 /* ParksManager.swift in Sources */, 6705E7EE283B703400DABCC8 /* JournalSettingsScreen.swift in Sources */, + 6797818B2D574C0E004D0629 /* MainUserFriendsListScreen.swift in Sources */, 6798AA40280AEDC900DB76F1 /* RootScreen.swift in Sources */, 671B4AE92D4F623100286996 /* ModernPickedImagesGrid.swift in Sources */, 675EC64F2814126800C2E229 /* TextEntryScreen.swift in Sources */, @@ -637,6 +644,7 @@ 676D5A612D48BF0700EE5E9E /* String+localized.swift in Sources */, 6765B2562D451771006164AB /* UIImage+toMediaFile.swift in Sources */, 674DF03E2B11254D00828016 /* Binding+.swift in Sources */, + 679781892D5732D0004D0629 /* ParticipantsScreen.swift in Sources */, 6766A036284603CA0033F1E8 /* TabViewModel.swift in Sources */, 67D916862838F0DD0098D3CB /* DialogScreen.swift in Sources */, 67627750283A3A54009C203F /* JournalsListScreen.swift in Sources */, @@ -655,7 +663,7 @@ 67D9169628396C1E0098D3CB /* SendMessageScreen.swift in Sources */, 6764D6382D52009F00699007 /* DialogsViewModel.swift in Sources */, 6747575928128603002F0A24 /* ParkDetailScreen.swift in Sources */, - 675EC6572815433600C2E229 /* UsersListScreen.swift in Sources */, + 675EC6572815433600C2E229 /* FriendsListScreen.swift in Sources */, 675EC65F2815532800C2E229 /* EventFormScreen.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -860,7 +868,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; @@ -911,7 +919,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift index b4510fc1..e7f60950 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift @@ -28,12 +28,12 @@ enum Endpoint { case deleteUser // MARK: Получить профиль пользователя - /// **GET** ${API}/users/ + /// **GET** ${API}/users/ /// `id` - идентификатор пользователя, чей профиль нужно получить case getUser(id: Int) // MARK: Получить список друзей пользователя - /// **GET** ${API}/users//friends + /// **GET** ${API}/users//friends /// `id` - идентификатор пользователя, чьих друзей нужно получить case getFriendsForUser(id: Int) @@ -42,7 +42,7 @@ enum Endpoint { case getFriendRequests // MARK: Принять заявку на добавление в друзья - /// **POST** ${API}/friends//accept + /// **POST** ${API}/friends//accept case acceptFriendRequest(from: Int) // MARK: Отклонить заявку на добавление в друзья @@ -70,7 +70,7 @@ enum Endpoint { case deleteFromBlacklist(_ userID: Int) // MARK: Найти пользователей по логину - /// **GET** ${API}/users/search?name= + /// **GET** ${API}/users/search?name= case findUsers(with: String) // MARK: Получить список стран/городов @@ -88,23 +88,23 @@ enum Endpoint { case getUpdatedParks(from: String) // MARK: Получить выбранную площадку: - /// **GET** ${API}/areas/ + /// **GET** ${API}/areas/ /// /// - Работает и с аутентификацией, и без /// - Для авторизованного пользователя нужно делать запрос с токеном, /// чтобы получить корректные данные (тренируется ли на площадке) case getPark(id: Int) - // MARK: Добавить новую спортплощадку + // MARK: Добавить новую площадку /// **POST** ${API}/areas case createPark(form: ParkForm) - // MARK: Изменить выбранную спортплощадку - /// **POST** ${API}/areas/ + // MARK: Изменить выбранную площадку + /// **POST** ${API}/areas/ case editPark(id: Int, form: ParkForm) // MARK: Удалить площадку - /// **DELETE** ${API}/areas/ + /// **DELETE** ${API}/areas/ case deletePark(_ parkID: Int) // MARK: Добавить комментарий для площадки @@ -156,7 +156,7 @@ enum Endpoint { case createEvent(form: EventForm) // MARK: Изменить существующее мероприятие - /// **POST** ${API}/trainings/ + /// **POST** ${API}/trainings/ case editEvent(id: Int, form: EventForm) // MARK: Сообщить, что пользователь пойдет на мероприятие @@ -176,7 +176,7 @@ enum Endpoint { case deleteEventComment(_ eventID: Int, commentID: Int) // MARK: Изменить свой комментарий для мероприятия - /// **POST** ${API}/trainings//comments/ + /// **POST** ${API}/trainings//comments/ case editEventComment(eventID: Int, commentID: Int, newComment: String) // MARK: Удалить мероприятие @@ -228,11 +228,11 @@ enum Endpoint { case saveJournalEntry(userID: Int, journalID: Int, message: String) // MARK: Изменить запись в дневнике пользователя - /// **PUT** ${API}/users//journals//messages/ + /// **PUT** ${API}/users//journals//messages/ case editEntry(userID: Int, journalID: Int, entryID: Int, newEntryText: String) // MARK: Удалить запись в дневнике пользователя - /// **DELETE** ${API}/users//journals//messages/ + /// **DELETE** ${API}/users//journals//messages/ case deleteEntry(userID: Int, journalID: Int, entryID: Int) // MARK: Удалить дневник пользователя diff --git a/SwiftUI-WorkoutApp/Screens/Common/FriendsListScreen.swift b/SwiftUI-WorkoutApp/Screens/Common/FriendsListScreen.swift new file mode 100644 index 00000000..e003b169 --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Common/FriendsListScreen.swift @@ -0,0 +1,139 @@ +import SWDesignSystem +import SwiftUI +import SWModels +import SWNetworkClient +import SWUtils + +/// Экран со списком друзей +struct FriendsListScreen: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var defaults: DefaultsService + @State private var friends = [UserResponse]() + @State private var isLoading = false + @State private var messagingModel = MessagingModel() + @State private var sendMessageTask: Task? + private var client: SWClient { SWClient(with: defaults) } + let mode: Mode + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(friends) { user in + listItem(for: user) + .disabled(user.id == defaults.mainUserInfo?.id) + } + } + .animation(.default, value: friends) + .padding() + } + .sheet( + item: $messagingModel.recipient, + onDismiss: { endMessaging() }, + content: messageSheet + ) + .loadingOverlay(if: isLoading) + .background(Color.swBackground) + .task { await askForUsers() } + .refreshable { await askForUsers(refresh: true) } + .navigationTitle("Друзья") + .navigationBarTitleDisplayMode(.inline) + } +} + +extension FriendsListScreen { + enum Mode { + /// Друзья пользователя с указанным `id` + /// + /// При нажатии на друга откроется его профиль + case user(id: Int) + /// Друзья пользователя с указанным `id` для чата + /// + /// При нажатии на друга откроется окно отправки сообщения + case chat(userID: Int) + } +} + +private extension FriendsListScreen { + @ViewBuilder + func listItem(for model: UserResponse) -> some View { + switch mode { + case .chat: + Button { + messagingModel.recipient = model + } label: { + userRowView(with: model) + } + case .user: + NavigationLink { + UserDetailsScreen(for: model) + .navigationBarTitleDisplayMode(.inline) + } label: { + userRowView(with: model) + } + } + } + + func userRowView(with model: UserResponse) -> some View { + UserRowView( + mode: .regular( + .init( + imageURL: model.avatarURL, + name: model.userName ?? "", + address: SWAddress(model.countryID, model.cityID)?.address ?? "" + ) + ) + ) + } + + func messageSheet(for recipient: UserResponse) -> some View { + SendMessageScreen( + header: .init(recipient.messageFor), + text: $messagingModel.message, + isLoading: messagingModel.isLoading, + isSendButtonDisabled: !messagingModel.canSendMessage, + sendAction: { sendMessage(to: recipient.id) } + ) + } + + func sendMessage(to userID: Int) { + messagingModel.isLoading = true + sendMessageTask = Task { + do { + let isSuccess = try await client.sendMessage(messagingModel.message, to: userID) + endMessaging(isSuccess: isSuccess) + } catch { + SWAlert.shared.presentDefaultUIKit(error) + } + messagingModel.isLoading = false + } + } + + func endMessaging(isSuccess: Bool = true) { + if isSuccess { + messagingModel.message = "" + messagingModel.recipient = nil + } + } + + func askForUsers(refresh: Bool = false) async { + guard !isLoading else { return } + do { + switch mode { + case let .user(id), let .chat(id): + if !friends.isEmpty, !refresh { return } + if !refresh { isLoading = true } + friends = try await client.getFriendsForUser(id: id) + } + } catch { + SWAlert.shared.presentDefaultUIKit(error) + } + isLoading = false + } +} + +#if DEBUG +#Preview { + FriendsListScreen(mode: .user(id: .previewUserID)) + .environmentObject(DefaultsService()) +} +#endif diff --git a/SwiftUI-WorkoutApp/Screens/Common/ParticipantsScreen.swift b/SwiftUI-WorkoutApp/Screens/Common/ParticipantsScreen.swift new file mode 100644 index 00000000..e7c0cfd8 --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Common/ParticipantsScreen.swift @@ -0,0 +1,76 @@ +import SWDesignSystem +import SwiftUI +import SWModels +import SWUtils + +/// Экран со списком участников мероприятия/тренирующихся на площадке +struct ParticipantsScreen: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var defaults: DefaultsService + let mode: Mode + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(list) { user in + NavigationLink(destination: UserDetailsScreen(for: user)) { + makeLabelForRow(with: user) + } + .disabled(user.id == defaults.mainUserInfo?.id) + } + } + .padding() + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } + + private func makeLabelForRow(with user: UserResponse) -> some View { + UserRowView( + mode: .regular( + .init( + imageURL: user.avatarURL, + name: user.userName ?? "", + address: SWAddress(user.countryID, user.cityID)?.address ?? "" + ) + ) + ) + } +} + +extension ParticipantsScreen { + enum Mode { + /// Участники мероприятия + case event(list: [UserResponse]) + /// Тренирующиеся на площадке + case park(list: [UserResponse]) + } +} + +private extension ParticipantsScreen { + var title: LocalizedStringKey { + switch mode { + case .event: + "Участники мероприятия" + case .park: + "Здесь тренируются" + } + } + + var list: [UserResponse] { + switch mode { + case let .event(users), let .park(users): + users + } + } +} + +#if DEBUG +#Preview("Мероприятие") { + ParticipantsScreen(mode: .event(list: [.preview])) +} + +#Preview("Площадка") { + ParticipantsScreen(mode: .park(list: [.preview])) +} +#endif diff --git a/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift b/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift deleted file mode 100644 index 5774ac49..00000000 --- a/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift +++ /dev/null @@ -1,224 +0,0 @@ -import SWDesignSystem -import SwiftUI -import SWModels -import SWNetworkClient -import SWUtils - -/// Экран со списком пользователей -struct UsersListScreen: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.isNetworkConnected) private var isNetworkConnected - @EnvironmentObject private var defaults: DefaultsService - @State private var users = [UserResponse]() - @State private var friendRequests = [UserResponse]() - @State private var isLoading = false - @State private var messagingModel = MessagingModel() - @State private var sendMessageTask: Task? - @State private var friendRequestTask: Task? - private var client: SWClient { SWClient(with: defaults) } - let mode: Mode - - var body: some View { - ScrollView { - VStack(spacing: 0) { - friendRequestsSectionIfNeeded - friendsSectionIfNeeded - } - .padding(.horizontal) - .frame(maxWidth: .infinity) - } - .sheet( - item: $messagingModel.recipient, - onDismiss: { endMessaging() }, - content: messageSheet - ) - .loadingOverlay(if: isLoading) - .background(Color.swBackground) - .disabled(!isNetworkConnected) - .task { await askForUsers() } - .refreshable { await askForUsers(refresh: true) } - .onDisappear { sendMessageTask?.cancel() } - .navigationTitle(mode.title) - .navigationBarTitleDisplayMode(.inline) - } -} - -extension UsersListScreen { - enum Mode { - /// Друзья пользователя с указанным `id` - /// - /// При нажатии на друга откроется его профиль - case friends(userID: Int) - /// Друзья пользователя с указанным `id` для чата - /// - /// При нажатии на друга откроется окно отправки сообщения - case friendsForChat(userID: Int) - /// Участники мероприятия - case eventParticipants(list: [UserResponse]) - /// Тренирующиеся на площадке - case parkParticipants(list: [UserResponse]) - } -} - -private extension UsersListScreen.Mode { - var title: LocalizedStringKey { - switch self { - case .friends, .friendsForChat: - "Друзья" - case .eventParticipants: - "Участники мероприятия" - case .parkParticipants: - "Здесь тренируются" - } - } -} - -private extension UsersListScreen { - @ViewBuilder - var friendRequestsSectionIfNeeded: some View { - if !friendRequests.isEmpty { - FriendRequestsView( - friendRequests: friendRequests, - action: respondToFriendRequest - ) - .padding(.top) - } - } - - @ViewBuilder - var friendsSectionIfNeeded: some View { - ZStack { - if !users.isEmpty { - SectionView( - header: friendRequests.isEmpty ? nil : "Друзья", - mode: .regular - ) { - LazyVStack(spacing: 12) { - ForEach(users) { item in - listItem(for: item) - .disabled(item.id == defaults.mainUserInfo?.id) - } - } - } - } - } - .animation(.default, value: users) - .padding(.top) - } - - @ViewBuilder - func listItem(for model: UserResponse) -> some View { - switch mode { - case .friendsForChat: - Button { - messagingModel.recipient = model - } label: { - userRowView(with: model) - } - case .friends, .eventParticipants, .parkParticipants: - NavigationLink { - UserDetailsScreen(for: model) - .navigationBarTitleDisplayMode(.inline) - } label: { - userRowView(with: model) - } - } - } - - func userRowView(with model: UserResponse) -> some View { - UserRowView( - mode: .regular( - .init( - imageURL: model.avatarURL, - name: model.userName ?? "", - address: SWAddress(model.countryID, model.cityID)?.address ?? "" - ) - ) - ) - } - - func messageSheet(for recipient: UserResponse) -> some View { - SendMessageScreen( - header: .init(recipient.messageFor), - text: $messagingModel.message, - isLoading: messagingModel.isLoading, - isSendButtonDisabled: !messagingModel.canSendMessage, - sendAction: { sendMessage(to: recipient.id) } - ) - } - - func sendMessage(to userID: Int) { - messagingModel.isLoading = true - sendMessageTask = Task { - do { - let isSuccess = try await client.sendMessage(messagingModel.message, to: userID) - endMessaging(isSuccess: isSuccess) - } catch { - SWAlert.shared.presentDefaultUIKit(error) - } - messagingModel.isLoading = false - } - } - - func endMessaging(isSuccess: Bool = true) { - if isSuccess { - messagingModel.message = "" - messagingModel.recipient = nil - } - } - - func askForUsers(refresh: Bool = false) async { - guard !isLoading else { return } - do { - switch mode { - case let .friends(userID), let .friendsForChat(userID): - if !users.isEmpty, !refresh { return } - if !refresh { isLoading = true } - if userID == defaults.mainUserInfo?.id { - try await makeListForMainUser(userID) - } else { - users = try await client.getFriendsForUser(id: userID) - } - case let .eventParticipants(list), let .parkParticipants(list): - users = list - } - } catch { - SWAlert.shared.presentDefaultUIKit(error) - } - isLoading = false - } - - func respondToFriendRequest(from userID: Int, accept: Bool) { - isLoading = true - friendRequestTask = Task { - do { - let isSuccess = try await client.respondToFriendRequest(from: userID, accept: accept) - if isSuccess { - friendRequests.removeAll(where: { $0.id == userID }) - try await makeListForMainUser(defaults.mainUserInfo?.id) - } - } catch { - SWAlert.shared.presentDefaultUIKit(error) - } - isLoading = false - } - } - - func makeListForMainUser(_ id: Int?) async throws { - guard let id else { return } - async let friendsTask = client.getFriendsForUser(id: id) - async let requestsTask = client.getFriendRequests() - let (friends, requests) = try await (friendsTask, requestsTask) - try? defaults.saveFriendsIds(friends.map(\.id)) - try? defaults.saveFriendRequests(requests) - users = friends - friendRequests = requests - } -} - -#if DEBUG -#Preview { - UsersListScreen(mode: .friends(userID: .previewUserID)) - .environmentObject(DefaultsService()) -} -#endif diff --git a/SwiftUI-WorkoutApp/Screens/Events/EventDetailsScreen.swift b/SwiftUI-WorkoutApp/Screens/Events/EventDetailsScreen.swift index 6ef8311c..875b56a3 100644 --- a/SwiftUI-WorkoutApp/Screens/Events/EventDetailsScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Events/EventDetailsScreen.swift @@ -292,7 +292,7 @@ private extension EventDetailsScreen { case let .eventAuthor(user): UserDetailsScreen(for: user) case let .eventParticipants(users): - UsersListScreen(mode: .eventParticipants(list: users)) + ParticipantsScreen(mode: .event(list: users)) case let .editEvent(eventToEdit): EventFormScreen(mode: .editExisting(eventToEdit), refreshClbk: refreshAction) } diff --git a/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift b/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift index ec365ac8..1a0a788a 100644 --- a/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift @@ -74,7 +74,7 @@ private extension DialogsListScreen { var friendListButton: some View { NavigationLink(isActive: $openFriendList) { if hasFriends, let mainUserID = defaults.mainUserInfo?.id { - UsersListScreen(mode: .friendsForChat(userID: mainUserID)) + FriendsListScreen(mode: .chat(userID: mainUserID)) } else { SearchUsersScreen(mode: .chat) } diff --git a/SwiftUI-WorkoutApp/Screens/Parks/ParkDetailScreen.swift b/SwiftUI-WorkoutApp/Screens/Parks/ParkDetailScreen.swift index 1214b513..26d4e742 100644 --- a/SwiftUI-WorkoutApp/Screens/Parks/ParkDetailScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Parks/ParkDetailScreen.swift @@ -197,7 +197,7 @@ private extension ParkDetailScreen { case let .parkAuthor(user): UserDetailsScreen(for: user) case let .parkParticipants(users): - UsersListScreen(mode: .parkParticipants(list: users)) + ParticipantsScreen(mode: .park(list: users)) case let .editPark(park): ParkFormScreen(.editExisting(park)) { refreshAction() } case let .createEvent(parkId, parkLongTitle): diff --git a/SwiftUI-WorkoutApp/Screens/Profile/FriendRequestsView.swift b/SwiftUI-WorkoutApp/Screens/Profile/FriendRequestsView.swift index ad1f4663..c5a3be04 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/FriendRequestsView.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/FriendRequestsView.swift @@ -12,26 +12,31 @@ struct FriendRequestsView: View { } var body: some View { - SectionView(headerWithPadding: "Заявки", mode: .card()) { - LazyVStack(spacing: 0) { - ForEach(listItems, id: \.0) { index, item in - UserRowView( - mode: .friendRequest( - .init( - imageURL: item.avatarURL, - name: item.userName ?? "", - address: SWAddress(item.countryID, item.cityID)?.address ?? "" - ), - .init( - accept: { action(item.id, true) }, - reject: { action(item.id, false) } + ZStack { + if !friendRequests.isEmpty { + SectionView(headerWithPadding: "Заявки", mode: .card()) { + LazyVStack(spacing: 0) { + ForEach(listItems, id: \.1.id) { index, item in + UserRowView( + mode: .friendRequest( + .init( + imageURL: item.avatarURL, + name: item.userName ?? "", + address: SWAddress(item.countryID, item.cityID)?.address ?? "" + ), + .init( + accept: { action(item.id, true) }, + reject: { action(item.id, false) } + ) + ) ) - ) - ) - .withDivider( - if: index != friendRequests.endIndex - 1 - ) + .withDivider( + if: index != friendRequests.endIndex - 1 + ) + } + } } + .padding(.top) } } .animation(.default, value: friendRequests) diff --git a/SwiftUI-WorkoutApp/Screens/Profile/MainUserFriendsListScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/MainUserFriendsListScreen.swift new file mode 100644 index 00000000..943f8c7e --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Profile/MainUserFriendsListScreen.swift @@ -0,0 +1,120 @@ +import SWDesignSystem +import SwiftUI +import SWModels +import SWNetworkClient +import SWUtils + +/// Экран со списком входящих заявок и друзей основного пользователя +struct MainUserFriendsListScreen: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var defaults: DefaultsService + @State private var friendRequests = [UserResponse]() + @State private var friends = [UserResponse]() + @State private var isLoading = false + @State private var friendRequestTask: Task? + private var client: SWClient { SWClient(with: defaults) } + let userId: Int + + var body: some View { + ScrollView { + VStack(spacing: 16) { + FriendRequestsView( + friendRequests: friendRequests, + action: respondToFriendRequest + ) + friendsSectionIfNeeded + } + .padding([.horizontal, .bottom]) + .frame(maxWidth: .infinity) + } + .loadingOverlay(if: isLoading) + .background(Color.swBackground) + .task { await askForUsers() } + .refreshable { await askForUsers(refresh: true) } + .navigationTitle("Друзья") + .navigationBarTitleDisplayMode(.inline) + } +} + +private extension MainUserFriendsListScreen { + var friendsSectionIfNeeded: some View { + ZStack { + if !friends.isEmpty { + SectionView( + headerWithPadding: friendRequests.isEmpty ? nil : "Друзья", + mode: .regular + ) { + LazyVStack(spacing: 12) { + ForEach(friends) { user in + NavigationLink { + UserDetailsScreen(for: user) + } label: { + userRowView(with: user) + } + .disabled(user.id == defaults.mainUserInfo?.id) + } + } + } + } + } + .animation(.default, value: friends) + } + + func userRowView(with model: UserResponse) -> some View { + UserRowView( + mode: .regular( + .init( + imageURL: model.avatarURL, + name: model.userName ?? "", + address: SWAddress(model.countryID, model.cityID)?.address ?? "" + ) + ) + ) + } + + func askForUsers(refresh: Bool = false) async { + guard !isLoading else { return } + let isEmpty = [friends, friendRequests].allSatisfy(\.isEmpty) + guard isEmpty || refresh else { return } + do { + if !refresh { isLoading = true } + try await getFriendsAndRequests() + } catch { + SWAlert.shared.presentDefaultUIKit(error) + } + isLoading = false + } + + func respondToFriendRequest(from userID: Int, accept: Bool) { + isLoading = true + friendRequestTask = Task { + do { + let isSuccess = try await client.respondToFriendRequest(from: userID, accept: accept) + if isSuccess { + friendRequests.removeAll(where: { $0.id == userID }) + try await getFriendsAndRequests() + } + } catch { + SWAlert.shared.presentDefaultUIKit(error) + } + isLoading = false + } + } + + func getFriendsAndRequests() async throws { + async let friendsTask = client.getFriendsForUser(id: userId) + async let requestsTask = client.getFriendRequests() + let (friends, requests) = try await (friendsTask, requestsTask) + self.friends = friends + friendRequests = requests + try defaults.saveFriendsIds(friends.map(\.id)) + try defaults.saveFriendRequests(requests) + } +} + +#if DEBUG +#Preview { + MainUserFriendsListScreen(userId: UserResponse.preview.id) + .environmentObject(DefaultsService()) +} +#endif diff --git a/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift index 2fb6c71a..a9554b72 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift @@ -64,6 +64,7 @@ private extension MainUserProfileScreen { VStack(spacing: 12) { ProfileViews.makeFriends( for: user, + isMainUser: true, friendRequestsCount: defaults.friendRequestsList.count ) ProfileViews.makeUsedParks(for: user) diff --git a/SwiftUI-WorkoutApp/Screens/Profile/ProfileViews.swift b/SwiftUI-WorkoutApp/Screens/Profile/ProfileViews.swift index e2c69b2d..fdee09b7 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/ProfileViews.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/ProfileViews.swift @@ -19,12 +19,19 @@ extension ProfileViews { @ViewBuilder @MainActor static func makeFriends( for user: UserResponse, + isMainUser: Bool = false, friendRequestsCount: Int = 0 ) -> some View { let showButton = user.hasFriends || friendRequestsCount > 0 ZStack { if showButton { - NavigationLink(destination: UsersListScreen(mode: .friends(userID: user.id))) { + NavigationLink { + if isMainUser { + MainUserFriendsListScreen(userId: user.id) + } else { + FriendsListScreen(mode: .user(id: user.id)) + } + } label: { FormRowView( title: "Друзья", trailingContent: .textWithBadgeAndChevron( diff --git a/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift index 92081486..36a48e94 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift @@ -56,6 +56,7 @@ struct UserDetailsScreen: View { .refreshable { await askForUserInfo(refresh: true) } .task { await askForUserInfo() } .navigationTitle("Профиль") + .navigationBarTitleDisplayMode(.inline) } }