diff --git a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj index 8e953256..67a24fff 100644 --- a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj +++ b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 67627750283A3A54009C203F /* JournalsListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6762774F283A3A54009C203F /* JournalsListScreen.swift */; }; 67627755283A4C77009C203F /* JournalEntriesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67627754283A4C77009C203F /* JournalEntriesScreen.swift */; }; 6762775B283A87AD009C203F /* JournalCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6762775A283A87AD009C203F /* JournalCell.swift */; }; + 6764D6382D52009F00699007 /* DialogsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6764D6372D52009F00699007 /* DialogsViewModel.swift */; }; 6765B2562D451771006164AB /* UIImage+toMediaFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B2552D451771006164AB /* UIImage+toMediaFile.swift */; }; 6765B2582D4544C8006164AB /* MainUserProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */; }; 6765B25B2D455D5C006164AB /* ProfileViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B25A2D455D5C006164AB /* ProfileViews.swift */; }; @@ -123,6 +124,7 @@ 6762774F283A3A54009C203F /* JournalsListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalsListScreen.swift; sourceTree = ""; }; 67627754283A4C77009C203F /* JournalEntriesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalEntriesScreen.swift; sourceTree = ""; }; 6762775A283A87AD009C203F /* JournalCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalCell.swift; sourceTree = ""; }; + 6764D6372D52009F00699007 /* DialogsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogsViewModel.swift; sourceTree = ""; }; 6765B2552D451771006164AB /* UIImage+toMediaFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+toMediaFile.swift"; sourceTree = ""; }; 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUserProfileScreen.swift; sourceTree = ""; }; 6765B25A2D455D5C006164AB /* ProfileViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViews.swift; sourceTree = ""; }; @@ -227,6 +229,7 @@ 67419AD5282E8E7C004F5339 /* Messages */ = { isa = PBXGroup; children = ( + 6764D6372D52009F00699007 /* DialogsViewModel.swift */, 67D916802838E2460098D3CB /* DialogsListScreen.swift */, 67D916852838F0DD0098D3CB /* DialogScreen.swift */, ); @@ -646,6 +649,7 @@ 6798AA84280C0F7D00DB76F1 /* EditProfileScreen.swift in Sources */, 6798AA73280B43FE00DB76F1 /* LoginScreen.swift in Sources */, 67D9169628396C1E0098D3CB /* SendMessageScreen.swift in Sources */, + 6764D6382D52009F00699007 /* DialogsViewModel.swift in Sources */, 6747575928128603002F0A24 /* ParkDetailScreen.swift in Sources */, 675EC6572815433600C2E229 /* UsersListScreen.swift in Sources */, 675EC65F2815532800C2E229 /* EventFormScreen.swift in Sources */, @@ -852,7 +856,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; @@ -903,7 +907,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; 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/SWClient.swift b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift index 6e97382f..1556b5b9 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift @@ -62,7 +62,6 @@ public struct SWClient: Sendable { /// - Parameters: /// - userID: `id` пользователя /// - Returns: вся информация о пользователе - @discardableResult public func getUserByID(_ userID: Int) async throws -> UserResponse { let endpoint = Endpoint.getUser(id: userID) return try await makeResult(for: endpoint) diff --git a/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift b/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift index 3d2bebd0..68851a08 100644 --- a/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift @@ -8,9 +8,8 @@ import SWUtils struct DialogsListScreen: View { @Environment(\.isNetworkConnected) private var isNetworkConnected @EnvironmentObject private var defaults: DefaultsService - @State private var dialogs = [DialogResponse]() + @EnvironmentObject private var viewModel: DialogsViewModel @State private var selectedDialog: DialogResponse? - @State private var isLoading = false @State private var indexToDelete: Int? @State private var openFriendList = false @State private var showDeleteConfirmation = false @@ -34,7 +33,8 @@ struct DialogsListScreen: View { .navigationTitle("Сообщения") } .navigationViewStyle(.stack) - .task { await askForDialogs() } + .onChange(of: defaults.isAuthorized, perform: viewModel.clearDialogsOnLogout) + .task(id: defaults.isAuthorized) { await askForDialogs() } } } @@ -42,14 +42,13 @@ private extension DialogsListScreen { var authorizedContentView: some View { dialogList .overlay { emptyContentView } - .loadingOverlay(if: isLoading) + .loadingOverlay(if: viewModel.isLoading) .background(Color.swBackground) .confirmationDialog( .init(Constants.Alert.deleteDialog), isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { deleteDialogButton } - .refreshable { await askForDialogs(refresh: true) } .toolbar { ToolbarItem(placement: .topBarLeading) { refreshButton @@ -58,9 +57,6 @@ private extension DialogsListScreen { friendListButton } } - .onDisappear { - [refreshTask, deleteDialogTask].forEach { $0?.cancel() } - } } var refreshButton: some View { @@ -71,8 +67,8 @@ private extension DialogsListScreen { } label: { Icons.Regular.refresh.view } - .opacity(showEmptyView || !DeviceOSVersionChecker.iOS16Available ? 1 : 0) - .disabled(isLoading) + .opacity(viewModel.showEmptyView ? 1 : 0) + .disabled(viewModel.isLoading || !isNetworkConnected) } var friendListButton: some View { @@ -86,20 +82,16 @@ private extension DialogsListScreen { Icons.Regular.plus.view .symbolVariant(.circle) } - .opacity(hasFriends || !dialogs.isEmpty ? 1 : 0) + .opacity(hasFriends || viewModel.hasDialogs ? 1 : 0) .disabled(!isNetworkConnected) } var emptyContentView: some View { EmptyContentView( mode: .dialogs, - action: emptyViewAction + action: { openFriendList.toggle() } ) - .opacity(showEmptyView ? 1 : 0) - } - - var showEmptyView: Bool { - dialogs.isEmpty && !isLoading + .opacity(viewModel.showEmptyView ? 1 : 0) } @ViewBuilder @@ -107,7 +99,7 @@ private extension DialogsListScreen { ZStack { Color.swBackground List { - ForEach(dialogs) { model in + ForEach(viewModel.dialogs) { model in dialogListItem(model) .listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16)) .listRowBackground(Color.swBackground) @@ -116,9 +108,10 @@ private extension DialogsListScreen { .onDelete { initiateDeletion(at: $0) } } .listStyle(.plain) - .opacity(dialogs.isEmpty ? 0 : 1) + .opacity(viewModel.hasDialogs ? 1 : 0) + .refreshable { await askForDialogs(refresh: true) } } - .animation(.default, value: dialogs.count) + .animation(.default, value: viewModel.dialogs.count) .background( NavigationLink( destination: lazyDestination, @@ -130,7 +123,12 @@ private extension DialogsListScreen { @ViewBuilder var lazyDestination: some View { if let selectedDialog { - DialogScreen(dialog: selectedDialog) { markAsRead($0) } + DialogScreen( + dialog: selectedDialog, + markedAsReadClbk: { dialog in + viewModel.markAsRead(dialog, defaults: defaults) + } + ) } } @@ -162,39 +160,12 @@ private extension DialogsListScreen { defaults.hasFriends } - func emptyViewAction() { - openFriendList.toggle() - } - - func markAsRead(_ dialog: DialogResponse) { - dialogs = dialogs.map { item in - if item.id == dialog.id { - var updatedDialog = dialog - updatedDialog.unreadMessagesCount = 0 - return updatedDialog - } else { - return item - } - } - guard dialog.unreadMessagesCount > 0, - defaults.unreadMessagesCount >= dialog.unreadMessagesCount - else { return } - let newValue = defaults.unreadMessagesCount - dialog.unreadMessagesCount - defaults.saveUnreadMessagesCount(newValue) - } - func askForDialogs(refresh: Bool = false) async { - guard defaults.isAuthorized else { return } - if isLoading || (!dialogs.isEmpty && !refresh) { return } - if !refresh { isLoading = true } do { - dialogs = try await client.getDialogs() - let unreadMessagesCount = dialogs.map(\.unreadMessagesCount).reduce(0, +) - defaults.saveUnreadMessagesCount(unreadMessagesCount) + try await viewModel.askForDialogs(refresh: refresh, defaults: defaults) } catch { SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) } - isLoading = false } func initiateDeletion(at indexSet: IndexSet) { @@ -204,17 +175,11 @@ private extension DialogsListScreen { func deleteAction(at index: Int?) { deleteDialogTask = Task { - guard let index, !isLoading else { return } - isLoading = true do { - let dialogID = dialogs[index].id - if try await client.deleteDialog(dialogID) { - dialogs.remove(at: index) - } + try await viewModel.deleteDialog(at: index, defaults: defaults) } catch { SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) } - isLoading = false } } } diff --git a/SwiftUI-WorkoutApp/Screens/Messages/DialogsViewModel.swift b/SwiftUI-WorkoutApp/Screens/Messages/DialogsViewModel.swift new file mode 100644 index 00000000..f64ef1af --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Messages/DialogsViewModel.swift @@ -0,0 +1,60 @@ +import Foundation +import SWModels +import SWNetworkClient +import SWUtils + +final class DialogsViewModel: ObservableObject { + @Published private(set) var dialogs = [DialogResponse]() + @Published private(set) var isLoading = false + var hasDialogs: Bool { !dialogs.isEmpty } + var showEmptyView: Bool { !hasDialogs && !isLoading } + + @MainActor + func askForDialogs( + refresh: Bool = false, + defaults: DefaultsService + ) async throws { + guard defaults.isAuthorized else { return } + if isLoading || (!dialogs.isEmpty && !refresh) { return } + if !refresh || dialogs.isEmpty { isLoading = true } + dialogs = try await SWClient(with: defaults).getDialogs() + let unreadMessagesCount = dialogs.map(\.unreadMessagesCount).reduce(0, +) + defaults.saveUnreadMessagesCount(unreadMessagesCount) + isLoading = false + } + + @MainActor + func deleteDialog(at index: Int?, defaults: DefaultsService) async throws { + guard let index, !isLoading else { return } + isLoading = true + let dialogID = dialogs[index].id + if try await SWClient(with: defaults).deleteDialog(dialogID) { + dialogs.remove(at: index) + } + isLoading = false + } + + @MainActor + func markAsRead(_ dialog: DialogResponse, defaults: DefaultsService) { + dialogs = dialogs.map { item in + if item.id == dialog.id { + var updatedDialog = dialog + updatedDialog.unreadMessagesCount = 0 + return updatedDialog + } else { + return item + } + } + guard dialog.unreadMessagesCount > 0, + defaults.unreadMessagesCount >= dialog.unreadMessagesCount + else { return } + let newValue = defaults.unreadMessagesCount - dialog.unreadMessagesCount + defaults.saveUnreadMessagesCount(newValue) + } + + @MainActor + func clearDialogsOnLogout(isAuthorized: Bool) { + guard !isAuthorized else { return } + dialogs.removeAll() + } +} diff --git a/SwiftUI-WorkoutApp/Screens/Root/RootScreen.swift b/SwiftUI-WorkoutApp/Screens/Root/RootScreen.swift index c520de9c..1d14b93c 100644 --- a/SwiftUI-WorkoutApp/Screens/Root/RootScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Root/RootScreen.swift @@ -4,6 +4,7 @@ import SwiftUI struct RootScreen: View { @Environment(\.userFlags) private var userFlags @Binding var selectedTab: TabViewModel.Tab + let unreadCount: Int var body: some View { TabView(selection: $selectedTab) { @@ -11,6 +12,7 @@ struct RootScreen: View { tab.screen .tabItem { tab.tabItemLabel } .tag(tab) + .badge(tab == .messages ? unreadCount : 0) } } .navigationViewStyle(.stack) @@ -18,9 +20,21 @@ struct RootScreen: View { } #if DEBUG -#Preview { - RootScreen(selectedTab: .constant(.map)) - .environmentObject(ParksManager()) - .environmentObject(DefaultsService()) +#Preview("Есть бейдж для чатов") { + RootScreen( + selectedTab: .constant(.map), + unreadCount: 1 + ) + .environmentObject(ParksManager()) + .environmentObject(DefaultsService()) +} + +#Preview("Нет бейджа") { + RootScreen( + selectedTab: .constant(.map), + unreadCount: 0 + ) + .environmentObject(ParksManager()) + .environmentObject(DefaultsService()) } #endif diff --git a/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift b/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift index c260d98d..2df92457 100644 --- a/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift +++ b/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift @@ -11,8 +11,10 @@ struct SwiftUI_WorkoutAppApp: App { @StateObject private var defaults = DefaultsService() @StateObject private var network = NetworkStatus() @StateObject private var parksManager = ParksManager() + @StateObject private var dialogsViewModel = DialogsViewModel() @State private var countriesUpdateTask: Task? @State private var socialUpdateTask: Task? + @State private var dialogsUpdateTask: Task? private let countriesStorage = SWAddress() private var client: SWClient { SWClient(with: defaults) } private var colorScheme: ColorScheme? { @@ -30,28 +32,24 @@ struct SwiftUI_WorkoutAppApp: App { var body: some Scene { WindowGroup { - RootScreen(selectedTab: $tabViewModel.selectedTab) - .environmentObject(tabViewModel) - .environmentObject(network) - .environmentObject(defaults) - .environmentObject(parksManager) - .preferredColorScheme(colorScheme) - .environment(\.isNetworkConnected, network.isConnected) - .environment(\.userFlags, defaults.userFlags) + RootScreen( + selectedTab: $tabViewModel.selectedTab, + unreadCount: defaults.unreadMessagesCount + ) + .environmentObject(tabViewModel) + .environmentObject(network) + .environmentObject(defaults) + .environmentObject(parksManager) + .environmentObject(dialogsViewModel) + .preferredColorScheme(colorScheme) + .environment(\.isNetworkConnected, network.isConnected) + .environment(\.userFlags, defaults.userFlags) } .onChange(of: scenePhase) { phase in switch phase { case .active: updateCountriesIfNeeded() - guard let mainUserId = defaults.mainUserInfo?.id else { return } - socialUpdateTask = Task { - if let result = await client.getSocialUpdates(userID: mainUserId) { - try? defaults.saveFriendsIds(result.friends.map(\.id)) - try? defaults.saveFriendRequests(result.friendRequests) - try? defaults.saveBlacklist(result.blacklist) - defaults.setUserNeedUpdate(false) - } - } + updateSocialInfoIfNeeded() default: [socialUpdateTask, countriesUpdateTask].forEach { $0?.cancel() } defaults.setUserNeedUpdate(true) @@ -68,6 +66,21 @@ struct SwiftUI_WorkoutAppApp: App { } } } + + private func updateSocialInfoIfNeeded() { + guard let mainUserId = defaults.mainUserInfo?.id else { return } + socialUpdateTask = Task { + if let result = await client.getSocialUpdates(userID: mainUserId) { + try? defaults.saveFriendsIds(result.friends.map(\.id)) + try? defaults.saveFriendRequests(result.friendRequests) + try? defaults.saveBlacklist(result.blacklist) + defaults.setUserNeedUpdate(false) + } + } + dialogsUpdateTask = Task { + try? await dialogsViewModel.askForDialogs(refresh: true, defaults: defaults) + } + } } private extension SwiftUI_WorkoutAppApp { @@ -80,7 +93,13 @@ private extension SwiftUI_WorkoutAppApp { $0.backgroundColor = .init(Color.swBackground) $0.shadowColor = nil } + let tabBarItemAppearance = makeTabBarItemAppearance() + tabBarAppearance.inlineLayoutAppearance = tabBarItemAppearance + tabBarAppearance.stackedLayoutAppearance = tabBarItemAppearance + tabBarAppearance.compactInlineLayoutAppearance = tabBarItemAppearance + UITabBar.appearance().standardAppearance = tabBarAppearance UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance + UINavigationBar.appearance().standardAppearance = navBarAppearance UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance fixAlertAccentColor() if !DeviceOSVersionChecker.iOS16Available { @@ -91,15 +110,27 @@ private extension SwiftUI_WorkoutAppApp { /// Исправляет баг с accentColor у алертов, [обсуждение](https://developer.apple.com/forums/thread/673147) /// /// Без этой настройки у всех алертов при первом появлении стандартный tintColor (синий), - /// а при нажатии он меняется на `AccentColor` в проекте + /// а при нажатии он меняется на AccentColor в проекте func fixAlertAccentColor() { UIView.appearance().tintColor = .accent } + /// Настройки цветовых параметров для табов в таббаре + func makeTabBarItemAppearance() -> UITabBarItemAppearance { + let tabBarItemAppearance = UITabBarItemAppearance() + tabBarItemAppearance.normal.iconColor = .init(.swSmallElements) + tabBarItemAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor(.swSmallElements)] + tabBarItemAppearance.normal.badgeBackgroundColor = .accent + tabBarItemAppearance.normal.badgeTextAttributes = [.foregroundColor: UIColor(.swBackground)] + return tabBarItemAppearance + } + + #if DEBUG func prepareForUITestIfNeeded() { if ProcessInfo.processInfo.arguments.contains("UITest") { UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) UIView.setAnimationsEnabled(false) } } + #endif }