From e3fcee3351cca42156a3fb4786ac71254b1a7730 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Mon, 31 Aug 2026 19:11:38 +0700 Subject: [PATCH] updated ui for macos and ios --- Luma.xcodeproj/project.pbxproj | 10 +- Sources/Shared/UI/AppLockView.swift | 147 ++++--- Sources/Shared/UI/ChatView.swift | 109 +---- .../UI/Components/ConnectionBanner.swift | 23 - Sources/Shared/UI/LoginView.swift | 3 +- Sources/Shared/UI/MainTabView.swift | 185 ++++---- Sources/Shared/UI/NewChatView.swift | 59 ++- Sources/Shared/UI/NewGroupView.swift | 110 +++-- Sources/Shared/UI/ServerInfoView.swift | 144 +++---- Sources/Shared/UI/SettingsView.swift | 405 ++++++++++-------- 10 files changed, 619 insertions(+), 576 deletions(-) diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index c03520b..53c6cb2 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -440,7 +440,7 @@ E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = ""; }; E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = ""; }; EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = ""; }; - EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = LumaMac.app; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EA54340DD785335237158BEF /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = ""; }; F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = ""; }; F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; @@ -714,7 +714,7 @@ isa = PBXGroup; children = ( DF8C531E464C9C4EDE2648C6 /* Luma.app */, - EA54340DD785335237158BEF /* LumaMac.app */, + EA54340DD785335237158BEF /* Luma.app */, 6317FAA29F22A3EA8450208F /* LumaTests.xctest */, 96720381802141EBA1DA9772 /* LumaUITests.xctest */, 19D4E9404712E8643E31CB93 /* LumaWatch.app */, @@ -807,7 +807,7 @@ 2A745A82DD6F34F0CB46B52B /* WebRTC */, ); productName = LumaMac; - productReference = EA54340DD785335237158BEF /* LumaMac.app */; + productReference = EA54340DD785335237158BEF /* Luma.app */; productType = "com.apple.product-type.application"; }; A086B6D25E7969CB643F1C8A /* LumaUITests */ = { @@ -1255,7 +1255,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.macos; PRODUCT_NAME = Luma; SDKROOT = macosx; @@ -1277,7 +1277,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.macos; PRODUCT_NAME = Luma; SDKROOT = macosx; diff --git a/Sources/Shared/UI/AppLockView.swift b/Sources/Shared/UI/AppLockView.swift index 92e850d..88eb599 100644 --- a/Sources/Shared/UI/AppLockView.swift +++ b/Sources/Shared/UI/AppLockView.swift @@ -33,9 +33,9 @@ struct AppLockView: View { SecureField("Пароль", text: $passcode) .focused($isFocused) .textContentType(.password) - #if os(iOS) - .textInputAutocapitalization(.never) - #endif + #if os(iOS) + .textInputAutocapitalization(.never) + #endif .autocorrectionDisabled() .textFieldStyle(.roundedBorder) .multilineTextAlignment(.center) @@ -57,36 +57,44 @@ struct AppLockView: View { .buttonStyle(.borderedProminent) .disabled(passcode.isEmpty) - if model.biometricUnlockAvailable, model.appLockBiometricIsEnabled { + if model.biometricUnlockAvailable, + model.appLockBiometricIsEnabled + { Button { Task { await biometricUnlock() } } label: { Label( "Войти по \(model.biometricUnlockName)", - systemImage: model.biometricUnlockName == "Face ID" ? "faceid" : "touchid" + systemImage: model.biometricUnlockName == "Face ID" + ? "faceid" : "touchid" ) .foregroundStyle(.white) } } } .padding(28) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 28, style: .continuous) + ) .padding(24) } .onAppear { isFocused = true } -#if os(iOS) - .task { - // Auto-prompt biometrics once when the lock screen appears. iOS - // only: on macOS the system Touch ID dialog blocks the window - // and freezes the lock screen. - guard model.biometricUnlockAvailable, model.appLockBiometricIsEnabled else { - return + #if os(iOS) + .task { + // Auto-prompt biometrics once when the lock screen appears. iOS + // only: on macOS the system Touch ID dialog blocks the window + // and freezes the lock screen. + guard model.biometricUnlockAvailable, + model.appLockBiometricIsEnabled + else { + return + } + await biometricUnlock() } - await biometricUnlock() - } -#endif + #endif } private func submit() { @@ -139,30 +147,26 @@ struct AppLockPasscodeSheet: View { @State private var errorText: String? var body: some View { - NavigationStack { - Form { - if needsOldPasscode { - Section("Текущий пароль") { - SecureField("Текущий пароль", text: $oldPasscode) - .textContentType(.password) - } + #if os(iOS) + NavigationStack { + Form { + formcontent() } - Section(title) { - SecureField(fieldLabel, text: $passcode) - .textContentType(.password) - if needsRepeatedPasscode { - SecureField("Повторите пароль", text: $repeatedPasscode) - .textContentType(.password) + .navigationTitle(navigationTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } } - } - if let errorText { - Section { - Text(errorText) - .font(.caption) - .foregroundStyle(.red) + ToolbarItem(placement: .confirmationAction) { + Button("Готово", action: submit) + .disabled(!canSubmit) } } } + #else + List { + formcontent() + } .navigationTitle(navigationTitle) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -173,27 +177,43 @@ struct AppLockPasscodeSheet: View { .disabled(!canSubmit) } } - } -#if os(macOS) - .frame(minWidth: 420, minHeight: 320) -#endif + .frame(minWidth: 420, minHeight: 320) + #endif } - private var needsOldPasscode: Bool { + @ViewBuilder + private func formcontent() -> some View { switch mode { - case .change, .verify: - return true case .setup: - return false - } - } - - private var needsRepeatedPasscode: Bool { - switch mode { - case .setup, .change: - return true + Section(title) { + SecureField(fieldLabel, text: $passcode) + .textContentType(.password) + SecureField("Повторите пароль", text: $repeatedPasscode) + .textContentType(.password) + } case .verify: - return false + Section("Текущий пароль") { + SecureField("Текущий пароль", text: $oldPasscode) + .textContentType(.password) + } + case .change: + Section("Текущий пароль") { + SecureField("Текущий пароль", text: $oldPasscode) + .textContentType(.password) + } + Section(title) { + SecureField(fieldLabel, text: $passcode) + .textContentType(.password) + SecureField("Повторите пароль", text: $repeatedPasscode) + .textContentType(.password) + } + } + if let errorText { + Section { + Text(errorText) + .font(.caption) + .foregroundStyle(.red) + } } } @@ -212,14 +232,16 @@ struct AppLockPasscodeSheet: View { case .verify(let action): switch action { case .disableLock: return "Выключить блокировку" - case .enableBiometrics, .disableBiometrics: return "Подтвердите пароль" + case .enableBiometrics, .disableBiometrics: + return "Подтвердите пароль" } } } private var fieldLabel: String { switch mode { - case .setup, .change: return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)" + case .setup, .change: + return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)" case .verify: return "Пароль" } } @@ -229,7 +251,8 @@ struct AppLockPasscodeSheet: View { case .setup: return !passcode.isEmpty && !repeatedPasscode.isEmpty case .change: - return !oldPasscode.isEmpty && !passcode.isEmpty && !repeatedPasscode.isEmpty + return !oldPasscode.isEmpty && !passcode.isEmpty + && !repeatedPasscode.isEmpty case .verify: return !oldPasscode.isEmpty } @@ -240,7 +263,8 @@ struct AppLockPasscodeSheet: View { switch mode { case .setup: guard AppLockPolicy.isValid(passcode) else { - errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа." + errorText = + "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа." return } guard passcode == repeatedPasscode else { @@ -251,11 +275,13 @@ struct AppLockPasscodeSheet: View { if model.enableAppLock(passcode: passcode) { dismiss() } else { - errorText = model.errorMessage ?? "Не удалось включить блокировку." + errorText = + model.errorMessage ?? "Не удалось включить блокировку." } case .change: guard AppLockPolicy.isValid(passcode) else { - errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа." + errorText = + "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа." return } guard passcode == repeatedPasscode else { @@ -279,14 +305,16 @@ struct AppLockPasscodeSheet: View { oldPasscode = "" } case .enableBiometrics: - if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) { + if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) + { dismiss() } else { errorText = "Неверный пароль." oldPasscode = "" } case .disableBiometrics: - if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) { + if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) + { dismiss() } else { errorText = "Неверный пароль." @@ -296,4 +324,3 @@ struct AppLockPasscodeSheet: View { } } } - diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index 5309195..ca3b709 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -814,7 +814,7 @@ struct ChatView: View { } else if let replyingToMessage { replyBanner(replyingToMessage) } - HStack(alignment: .bottom, spacing: 8) { + HStack(alignment: .center, spacing: 8) { TextField( editingMessageID == nil ? "Сообщение" : "Изменить сообщение", @@ -843,26 +843,7 @@ struct ChatView: View { .padding(.trailing, 12) .padding(.vertical, 9) .frame(minHeight: 42) - .background( - .ultraThinMaterial, - in: RoundedRectangle(cornerRadius: 21, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: 21, style: .continuous) - .stroke( - LinearGradient( - colors: [ - Color.white.opacity(0.3), - Color.secondary.opacity(0.18), - ], - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.75 - ) - ) - .shadow(color: .black.opacity(0.075), radius: 8, y: 2) - .contentShape(RoundedRectangle(cornerRadius: 21, style: .continuous)) + .glassEffect(in: .rect(cornerRadius: 21)) } @ViewBuilder @@ -875,21 +856,14 @@ struct ChatView: View { canUseComposer ? Color.accentColor : Color.secondary ) .frame(width: 42, height: 42) - .background(.ultraThinMaterial, in: Circle()) - .overlay( - Circle() - .stroke( - Color.secondary.opacity(0.2), - lineWidth: 0.75 - ) - ) - .shadow(color: .black.opacity(0.075), radius: 8, y: 2) +// .background(.ultraThinMaterial, in: Circle()) } .buttonStyle(.plain) .disabled(!canUseComposer) .accessibilityLabel( editingMessageID == nil ? "Отправить" : "Сохранить исправление" ) + .glassEffect() } else { TelegramRecordButton( mode: .voice, @@ -968,15 +942,6 @@ struct ChatView: View { } } label: { ZStack { - Circle() - .fill(.ultraThinMaterial) - .overlay( - Circle() - .stroke( - Color.secondary.opacity(0.18), - lineWidth: 0.75 - ) - ) if model.isSendingAttachment || isPreparingAttachments { ProgressView() .controlSize(.small) @@ -987,8 +952,8 @@ struct ChatView: View { } } .frame(width: 42, height: 42) - .shadow(color: .black.opacity(0.075), radius: 8, y: 2) .contentShape(Circle()) + .glassEffect() } .buttonStyle(.plain) .accessibilityLabel("Добавить вложение") @@ -1166,14 +1131,7 @@ struct ChatView: View { } .padding(.horizontal, 13) .frame(minHeight: 42) - .background( - .ultraThinMaterial, - in: RoundedRectangle(cornerRadius: 21, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: 21, style: .continuous) - .stroke(Color.secondary.opacity(0.2), lineWidth: 0.75) - ) + .glassEffect() } private var recordingLockIndicator: some View { @@ -1185,10 +1143,7 @@ struct ChatView: View { } .foregroundStyle(.primary) .frame(width: 38, height: 55) - .background(.ultraThinMaterial, in: Capsule()) - .overlay( - Capsule().stroke(Color.secondary.opacity(0.22), lineWidth: 0.75) - ) + .glassEffect(in: Capsule()) .accessibilityLabel("Проведите вверх, чтобы зафиксировать запись") } @@ -1799,8 +1754,6 @@ struct ChatView: View { Capsule() .fill(Color.accentColor) .frame(width: 3, height: 34) -// Image(systemName: "pencil") -// .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 1) { Text("Редактирование") .font(.caption.weight(.semibold)) @@ -1818,8 +1771,6 @@ struct ChatView: View { .buttonStyle(.plain) .accessibilityLabel("Отменить редактирование") } - // .padding(.horizontal, 14) - // .padding(.top, 8) } /// Telegram-style reply plate: a rounded card above the composer with @@ -1831,9 +1782,6 @@ struct ChatView: View { .frame(width: 3, height: 34) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 7) { -// Image(systemName: "arrowshape.turn.up.left.fill") -// .font(.caption.weight(.semibold)) -// .foregroundStyle(.tint) Text( "Ответ: \(message.senderDisplayName ?? model.displayName(for: message.senderJID))" ) @@ -1856,17 +1804,6 @@ struct ChatView: View { .accessibilityLabel("Отменить ответ") } .accessibilityIdentifier("reply-banner") - // .padding(.horizontal, 12) - // .padding(.vertical, 8) - // .background( - // .thinMaterial, - // in: RoundedRectangle(cornerRadius: 14, style: .continuous) - // ) - // .overlay { - // RoundedRectangle(cornerRadius: 14, style: .continuous) - // .stroke(Color.secondary.opacity(0.16), lineWidth: 0.75) - // } - // .padding(.horizontal, 10) } private func perform(_ action: MessageDestructiveAction) { @@ -1908,15 +1845,10 @@ struct ChatView: View { .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.primary) .frame(width: 46, height: 46) - .background(.ultraThinMaterial, in: Circle()) - .overlay { - Circle() - .stroke(Color.accentColor.opacity(0.42), lineWidth: 1) - } - .shadow(color: .black.opacity(0.16), radius: 10, y: 4) } .buttonStyle(.plain) .contentShape(Circle()) + .glassEffect() .accessibilityLabel("Перейти к последнему сообщению") .help("Перейти в конец истории") } @@ -2211,24 +2143,6 @@ private struct TelegramRecordButton: View { var body: some View { ZStack { - Circle() - .fill(.ultraThinMaterial) - .overlay { - Circle() - .fill( - isRecording ? Color.red.opacity(0.13) : Color.clear - ) - } - .overlay( - Circle() - .stroke( - isRecording - ? Color.red.opacity(0.42) - : Color.secondary.opacity(0.2), - lineWidth: 0.75 - ) - ) - if isFinalizing { ProgressView() .controlSize(.small) @@ -2242,13 +2156,8 @@ private struct TelegramRecordButton: View { } .frame(width: 42, height: 42) .scaleEffect(isRecording && !isLocked ? 1.34 : (isPressing ? 1.08 : 1)) - .shadow( - color: isRecording - ? Color.black.opacity(0.2) : Color.black.opacity(0.075), - radius: 8, - y: isRecording ? 4 : 2 - ) .contentShape(Circle()) + .glassEffect() #if os(iOS) .overlay { ComposerRecordTouchSurface( diff --git a/Sources/Shared/UI/Components/ConnectionBanner.swift b/Sources/Shared/UI/Components/ConnectionBanner.swift index 6562cb4..1638ae1 100644 --- a/Sources/Shared/UI/Components/ConnectionBanner.swift +++ b/Sources/Shared/UI/Components/ConnectionBanner.swift @@ -9,7 +9,6 @@ struct ConnectionBanner: View { case .connected: if model.isArchiveSyncing || !model.isOMEMOReady { banner( -// text: model.isArchiveSyncing ? "Синхронизация истории…" : "Публикация ключей OMEMO…", text: "Обновление", color: .clear, progress: true @@ -19,27 +18,9 @@ struct ConnectionBanner: View { .font(.headline) } case .connecting: -// banner(icon: "antenna.radiowaves.left.and.right", text: "Подключение…", color: .clear, progress: true) banner(text: "Подключение", color: .clear, progress: true) case .disconnected: banner(text: "Ожидание сети", color: .clear, progress: true) -// HStack(spacing: 8) { -// Image(systemName: "wifi.exclamationmark") -// Text(reason ?? "Нет соединения") -// .lineLimit(1) -// Spacer() -// Button { Task { await model.reconnect() } } label: { -// Label("Обновить", systemImage: "arrow.clockwise") -// } -// .buttonStyle(.borderless) -// .fontWeight(.semibold) -// } -// .font(.caption) -// .foregroundStyle(.white) -// .padding(.horizontal, 12) -// .padding(.vertical, 8) -//// .background(Color.red.opacity(0.88)) -// .background(Color.clear) } } @@ -49,13 +30,9 @@ struct ConnectionBanner: View { ProgressView().controlSize(.mini).tint(.white) } Text(text) -// Spacer() } .font(.caption) .foregroundStyle(.white) -// .padding(.horizontal, 12) -// .padding(.vertical, 8) -// .background(color.opacity(0.88)) } } diff --git a/Sources/Shared/UI/LoginView.swift b/Sources/Shared/UI/LoginView.swift index 30d6030..5ce3a49 100644 --- a/Sources/Shared/UI/LoginView.swift +++ b/Sources/Shared/UI/LoginView.swift @@ -135,6 +135,7 @@ struct LoginView: View { ) .textFieldStyle(.roundedBorder) Toggle("Direct TLS", isOn: $directTLS) + .toggleStyle(.switch) Text( "Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS." ) @@ -158,7 +159,7 @@ struct LoginView: View { .frame(maxWidth: .infinity) .frame(height: 34) } - .buttonStyle(.borderedProminent) + .buttonStyle(.glassProminent) .controlSize(.large) .disabled(model.isAuthenticating) diff --git a/Sources/Shared/UI/MainTabView.swift b/Sources/Shared/UI/MainTabView.swift index 0b1b12a..08535b2 100644 --- a/Sources/Shared/UI/MainTabView.swift +++ b/Sources/Shared/UI/MainTabView.swift @@ -53,10 +53,11 @@ private struct SearchField: View { } .padding(.horizontal, 10) .padding(.vertical, 7) - .background( - Color.secondary.opacity(0.14), - in: RoundedRectangle(cornerRadius: 10) - ) + .glassEffect() + // .background( + // Color.secondary.opacity(0.14), + // in: RoundedRectangle(cornerRadius: 10) + // ) .padding(.horizontal, 12) .padding(.vertical, 6) } @@ -85,16 +86,16 @@ private struct SearchField: View { var icon: String { switch self { - case .contacts: return "person.2" - case .calls: return "phone" - case .chats: return "bubble.left.and.bubble.right" - case .settings: return "gearshape" + case .contacts: return "person.circle.fill" + case .calls: return "phone.fill" + case .chats: return "bubble.left.and.bubble.right.fill" + case .settings: return "gearshape.fill" } } var selectedIcon: String { switch self { - case .contacts: return "person.2.fill" + case .contacts: return "person.circle.fill" case .calls: return "phone.fill" case .chats: return "bubble.left.and.bubble.right.fill" case .settings: return "gearshape.fill" @@ -169,12 +170,12 @@ private struct ChatsTab: View { } .navigationTitle("") #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Поиск чатов" - ) + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Поиск чатов" + ) #endif .toolbar { ToolbarItem(placement: .principal) { @@ -319,12 +320,12 @@ private struct ContactsTab: View { } .navigationTitle("") #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Поиск контактов" - ) + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Поиск контактов" + ) #endif .toolbar { ToolbarItem(placement: .principal) { @@ -504,7 +505,7 @@ private struct CallsTab: View { } .navigationTitle("") #if os(iOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .principal) { @@ -596,9 +597,9 @@ private struct ContactRow: View { } Spacer(minLength: 8) -// Image(systemName: "chevron.right") -// .font(.caption.bold()) -// .foregroundStyle(.tertiary) + // Image(systemName: "chevron.right") + // .font(.caption.bold()) + // .foregroundStyle(.tertiary) } .contentShape(Rectangle()) } @@ -614,9 +615,9 @@ private struct ContactRow: View { #if os(macOS) private struct MainSplitView: View { private enum Mode: String, CaseIterable, Identifiable { - case chats case contacts case calls + case chats case settings var id: String { rawValue } @@ -632,10 +633,10 @@ private struct ContactRow: View { var icon: String { switch self { - case .chats: return "bubble.left.and.bubble.right" - case .contacts: return "person.2" - case .calls: return "phone" - case .settings: return "gearshape" + case .chats: return "bubble.left.and.bubble.right.fill" + case .contacts: return "person.circle.fill" + case .calls: return "phone.fill" + case .settings: return "gearshape.fill" } } @@ -675,30 +676,34 @@ private struct ContactRow: View { ideal: 290, max: 380 ) + .toolbar { + ToolbarItem(placement: .principal) { + Text(mode.title) + .font(.headline) + } + if mode != .settings { + ToolbarItemGroup(placement: .primaryAction) { + Button { + showingNewGroup = true + } label: { + Image(systemName: "person.3.fill") + } + .accessibilityLabel("Новый групповой чат") + .help("Новый групповой чат") + Button { + showingNewChat = true + } label: { + Image(systemName: "square.and.pencil") + } + .accessibilityLabel("Новый личный чат") + .help("Новый личный чат") + } + } + } } detail: { detailPane } .frame(minWidth: 840, minHeight: 560) - .toolbar { - if mode != .settings { - ToolbarItemGroup(placement: .primaryAction) { - Button { - showingNewGroup = true - } label: { - Image(systemName: "person.3.fill") - } - .accessibilityLabel("Новый групповой чат") - .help("Новый групповой чат") - Button { - showingNewChat = true - } label: { - Image(systemName: "square.and.pencil") - } - .accessibilityLabel("Новый личный чат") - .help("Новый личный чат") - } - } - } .sheet(isPresented: $showingNewChat) { NewChatView(model: model) } @@ -733,40 +738,41 @@ private struct ContactRow: View { private var sidebar: some View { VStack(spacing: 0) { - HStack(spacing: 8) { - Menu { - ForEach(Mode.allCases) { item in - Button { - switchMode(item) - } label: { - Label(item.title, systemImage: item.icon) - } - } - } label: { - Image(systemName: "line.3.horizontal") - .font(.system(size: 13, weight: .semibold)) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - .menuStyle(.borderlessButton) - .fixedSize() - .help("Меню") - .accessibilityLabel("Меню") - - if mode != .settings { - SearchField( - text: $searchText, - prompt: mode.searchPrompt - ) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - if mode != .settings { ConnectionBanner(model: model, text: "") - sidebarList + SearchField( + text: $searchText, + prompt: mode.searchPrompt + ) } + sidebarList + } + .safeAreaInset(edge: .bottom) { + HStack(spacing: 0) { + ForEach(Mode.allCases) { item in + Button { + withAnimation(.easeInOut) { + switchMode(item) + } + } label: { + VStack { + Image(systemName: item.icon) + .font(.largeTitle) + } + } + .frame(maxWidth: .infinity) + .foregroundColor( + mode == item ? .accentColor : .secondary + ) + .padding(.vertical, 8) + .buttonStyle(.plain) + } + } + .animation( + .spring(response: 0.35, dampingFraction: 0.82), + value: mode + ) + .glassEffect(in: .rect(cornerRadius: 20)) } } @@ -780,7 +786,10 @@ private struct ContactRow: View { case .calls: callRows case .settings: - EmptyView() + // NavigationStack { + SettingsView(model: model, presentedAsTab: true) + // } + // EmptyView() } } .listStyle(.sidebar) @@ -837,7 +846,7 @@ private struct ContactRow: View { @ViewBuilder private var contactRows: some View { if !filteredRoster.isEmpty { - Section("Люди из roster") { + Section { ForEach(filteredRoster) { conversation in ContactRow( conversation: conversation, @@ -848,7 +857,7 @@ private struct ContactRow: View { } } if !filteredGroups.isEmpty { - Section("Групповые чаты") { + Section { ForEach(filteredGroups) { conversation in ContactRow( conversation: conversation, @@ -888,9 +897,11 @@ private struct ContactRow: View { @ViewBuilder private var detailPane: some View { if mode == .settings { - NavigationStack { - SettingsView(model: model, presentedAsTab: true) - } + ContentUnavailableView( + emptyTitle, + systemImage: "gearshape.fill", + description: Text("Выберите пункт из списка слева.") + ) } else if let conversation = selectedConversation ?? defaultConversation { diff --git a/Sources/Shared/UI/NewChatView.swift b/Sources/Shared/UI/NewChatView.swift index 0cb2b21..6dd3a18 100644 --- a/Sources/Shared/UI/NewChatView.swift +++ b/Sources/Shared/UI/NewChatView.swift @@ -8,21 +8,10 @@ struct NewChatView: View { @State private var addToRoster = true var body: some View { +#if os(iOS) NavigationStack { Form { - Section("Контакт") { - TextField("user@example.org", text: $jid) - .disableAutocorrection(true) - #if os(iOS) - .keyboardType(.emailAddress) - #endif - TextField("Имя (необязательно)", text: $name) - } - Section { - Toggle("Добавить в roster", isOn: $addToRoster) - } footer: { - Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.") - } + formcontent() } .navigationTitle("Новый чат") .toolbar { @@ -43,8 +32,48 @@ struct NewChatView: View { } } } -#if os(macOS) - .frame(minWidth: 380, minHeight: 300) +#else + List { + formcontent() + HStack { + Button("Отмена") { dismiss() } + .buttonStyle(.glass) + .controlSize(.large) + .tint(.secondary) + Spacer() + Button("Начать") { + model.openConversation( + jid: jid, + name: name.isEmpty ? nil : name, + addToRoster: addToRoster + ) + if model.errorMessage == nil { dismiss() } + } + .fontWeight(.semibold) + .disabled(jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .buttonStyle(.glassProminent) + .controlSize(.large) + } + } + .navigationTitle("Новый чат") + .frame(minWidth: 380, minHeight: 220) #endif } + @ViewBuilder + private func formcontent() -> some View { + Section("Контакт") { + TextField("user@example.org", text: $jid) + .disableAutocorrection(true) + #if os(iOS) + .keyboardType(.emailAddress) + #endif + TextField("Имя (необязательно)", text: $name) + } + Section { + Toggle("Добавить в roster", isOn: $addToRoster) + .toggleStyle(.switch) + } footer: { + Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.") + } + } } diff --git a/Sources/Shared/UI/NewGroupView.swift b/Sources/Shared/UI/NewGroupView.swift index b76b9af..a128736 100644 --- a/Sources/Shared/UI/NewGroupView.swift +++ b/Sources/Shared/UI/NewGroupView.swift @@ -13,65 +13,87 @@ struct NewGroupView: View { @State private var isJoining = false var body: some View { - NavigationStack { - Form { - GroupBox { - VStack(alignment: .leading, spacing: 12) { - TextField("room@conference.example.org", text: $roomJID) - .disableAutocorrection(true) - #if os(iOS) - .keyboardType(.emailAddress) - #endif - TextField("Название (необязательно)", text: $name) - TextField("Ваш псевдоним", text: $nickname) - } - } label: { - Text("Комната") - .font(.headline) + #if os(iOS) + NavigationStack { + Form { + formcontent() } - - GroupBox { - VStack(alignment: .leading, spacing: 8) { - TextField("alice@example.org, bob@example.org", text: $invitees, axis: .vertical) - .lineLimit(2...5) - #if os(iOS) - .keyboardType(.emailAddress) - #endif - Text("Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию.") - .font(.footnote) - .foregroundStyle(.secondary) + .navigationTitle("Новая группа") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + .disabled(isJoining) + } + ToolbarItem(placement: .confirmationAction) { + Button("Создать / войти") { join() } + .fontWeight(.semibold) + .disabled(!canJoin || isJoining) + } + } + .onAppear { + if nickname.isEmpty { + nickname = model.suggestedGroupNickname } - } label: { - Text("Пригласить") - .font(.headline) } - - Label( - "Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.", - systemImage: "lock.shield.fill" - ) - .font(.footnote) - .foregroundStyle(.secondary) } - .navigationTitle("Новая группа") - .toolbar { - ToolbarItem(placement: .cancellationAction) { + #else + List { + formcontent() + HStack { Button("Отмена") { dismiss() } .disabled(isJoining) - } - ToolbarItem(placement: .confirmationAction) { + .buttonStyle(.glass) + .tint(.secondary) + .controlSize(.large) + Spacer() Button("Создать / войти") { join() } .fontWeight(.semibold) .disabled(!canJoin || isJoining) + .buttonStyle(.glassProminent) + .controlSize(.large) } } + .navigationTitle("Новая группа") .onAppear { if nickname.isEmpty { nickname = model.suggestedGroupNickname } } + .frame(minWidth: 480, minHeight: 330) + #endif + } + + @ViewBuilder + private func formcontent() -> some View { + Section("Комната") { + TextField("room@conference.example.org", text: $roomJID) + .disableAutocorrection(true) + #if os(iOS) + .keyboardType(.emailAddress) + #endif + TextField("Название (необязательно)", text: $name) + TextField("Ваш псевдоним", text: $nickname) } -#if os(macOS) - .frame(minWidth: 480, minHeight: 470) -#endif + Section("Пригласить") { + TextField( + "alice@example.org, bob@example.org", + text: $invitees, + axis: .vertical + ) + .lineLimit(2...5) + #if os(iOS) + .keyboardType(.emailAddress) + #endif + Text( + "Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию." + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + Label( + "Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.", + systemImage: "lock.shield.fill" + ) + .font(.footnote) + .foregroundStyle(.secondary) } private var canJoin: Bool { diff --git a/Sources/Shared/UI/ServerInfoView.swift b/Sources/Shared/UI/ServerInfoView.swift index 39a6efa..a9611c6 100644 --- a/Sources/Shared/UI/ServerInfoView.swift +++ b/Sources/Shared/UI/ServerInfoView.swift @@ -8,76 +8,26 @@ struct ServerInfoView: View { @State private var isLoading = false var body: some View { + #if os(iOS) Form { - if let info = model.serverInformation { - Section("Это статистика вашего соединения.") { - statisticsEntries(info) - } //header: { -// Text("Это статистика вашего соединения.") -// } - - Section { - softwareEntry(info) - } header: { - Text("Это программное обеспечение, работающее на вашем сервере.") - } - - Section { - connectionRows() - } header: { - Text("Параметры подключения к вашему серверу.") - } - - Section { - ForEach(info.capabilities) { capability in - entry( - title: capability.title, - detail: capability.detail, - status: capability.status - ) - } - } header: { - Text("Современные возможности XMPP, обнаруженные на вашем сервере после входа.") - } - - Section { - mucEntries(info) - } header: { - Text("MUC-серверы, обнаруженные на вашем сервере.") - } - - Section { - stunTurnEntries(info) - } header: { - Text("STUN и TURN сервисы, объявленные вашим сервером.") - } - - Section { - saslEntries(info) - } header: { - Text("Методы аутентификации SASL, которые поддерживает ваш сервер.") - } - } else if isLoading { - Section { - HStack(spacing: 12) { - ProgressView() - Text("Загрузка информации о сервере…") - .foregroundStyle(.secondary) - } - } - } else { - Section { - ContentUnavailableView( - "Нет данных о сервере", - systemImage: "server.rack", - description: Text( - model.connectionStatus == .connected - ? "Сервер не ответил на запрос. Попробуйте обновить." - : "Подключитесь к серверу, чтобы увидеть его возможности." - ) - ) - } + infocontent() + } + .navigationTitle(model.account?.domain ?? "Сервер") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await load() } + } label: { + Label("Обновить", systemImage: "arrow.clockwise") + } + .disabled(isLoading) } + } + .task { await load() } + .listStyle(.grouped) + #else + List { + infocontent() } .navigationTitle(model.account?.domain ?? "Сервер") .toolbar { @@ -91,12 +41,62 @@ struct ServerInfoView: View { } } .task { await load() } -#if os(macOS) .listStyle(.inset) .frame(minWidth: 480, minHeight: 420) -#else - .listStyle(.grouped) -#endif + #endif + } + + @ViewBuilder + private func infocontent() -> some View { + if let info = model.serverInformation { + Section("Это статистика вашего соединения.") { + statisticsEntries(info) + } + Section("Это программное обеспечение, работающее на вашем сервере.") { + softwareEntry(info) + } + Section("Параметры подключения к вашему серверу.") { + connectionRows() + } + Section("Современные возможности XMPP, обнаруженные на вашем сервере после входа.") { + ForEach(info.capabilities) { capability in + entry( + title: capability.title, + detail: capability.detail, + status: capability.status + ) + } + } + Section("MUC-серверы, обнаруженные на вашем сервере.") { + mucEntries(info) + } + Section("STUN и TURN сервисы, объявленные вашим сервером.") { + stunTurnEntries(info) + } + Section("Методы аутентификации SASL, которые поддерживает ваш сервер.") { + saslEntries(info) + } + } else if isLoading { + Section { + HStack(spacing: 12) { + ProgressView() + Text("Загрузка информации о сервере…") + .foregroundStyle(.secondary) + } + } + } else { + Section { + ContentUnavailableView( + "Нет данных о сервере", + systemImage: "server.rack", + description: Text( + model.connectionStatus == .connected + ? "Сервер не ответил на запрос. Попробуйте обновить." + : "Подключитесь к серверу, чтобы увидеть его возможности." + ) + ) + } + } } // MARK: - Sections diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift index 06f74f0..0a567c9 100644 --- a/Sources/Shared/UI/SettingsView.swift +++ b/Sources/Shared/UI/SettingsView.swift @@ -14,182 +14,74 @@ struct SettingsView: View { @State private var passcodeSheetMode: AppLockPasscodeSheet.Mode? var body: some View { - NavigationStack { - Form { - if let account = model.account { - Section("Профиль") { - HStack(spacing: 14) { - AvatarView( - conversation: Conversation( - jid: account.normalizedJID, - displayName: account.displayName.isEmpty ? nil : account.displayName - ), - imageData: model.avatarData(for: account.normalizedJID), - size: 64 - ) - VStack(alignment: .leading, spacing: 6) { - Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName) - .font(.headline) - PhotosPicker(selection: $avatarItem, matching: .images) { - Label("Обновить аватар", systemImage: "photo.badge.plus") - } - .disabled(model.isUpdatingAvatar) - } - Spacer() - if model.isUpdatingAvatar { - ProgressView() - } - } - } - - Section("Аккаунт") { - LabeledContent("JID", value: account.normalizedJID) - LabeledContent("Ресурс", value: account.effectiveResource) - if let host = account.manualHost { - LabeledContent("Сервер", value: "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))") - } else { - LabeledContent("Обнаружение", value: "DNS SRV") - } - } + #if os(iOS) + NavigationStack { + Form { + formcontent() } - - Section("Соединение") { - HStack { - Label(connectionTitle, systemImage: connectionIcon) - .foregroundStyle(connectionColor) - Spacer() - if model.connectionStatus != .connected { - Button("Подключить") { Task { await model.reconnect() } } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Настройки") + .font(.headline) + } + if !presentedAsTab { + ToolbarItem(placement: .confirmationAction) { + Button("Готово") { dismiss() } } } - LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово") - NavigationLink { - ServerInfoView(model: model) - } label: { - Label("Информация о сервере", systemImage: "server.rack") - } - } - - Section("Шифрование") { - Toggle("OMEMO по умолчанию", isOn: Binding( - get: { model.globalEncryptionEnabled }, - set: { model.setGlobalEncryptionEnabled($0) } - )) - Text("Для отдельного чата режим можно изменить через значок замка в его панели.") - .font(.caption) - .foregroundStyle(.secondary) - if !model.globalEncryptionEnabled { - Label("Новые сообщения без отдельного правила отправляются открытым текстом.", systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - } - LabeledContent("Состояние", value: model.isOMEMOReady ? "Активно" : "Подготовка") - if let fingerprint = model.ownFingerprint { - VStack(alignment: .leading, spacing: 7) { - Text("Отпечаток этого устройства") - .font(.subheadline) - Text(fingerprint.chunked(every: 8).joined(separator: " ")) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - } - - Section("Уведомления") { - Label("Локальные уведомления включаются после входа", systemImage: "bell.badge") - Text("Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Блокировка приложения") { - Toggle("Заблокировать приложение", isOn: Binding( - get: { model.appLockIsEnabled }, - set: { enabled in - if enabled { - passcodeSheetMode = .setup - } else { - passcodeSheetMode = .verify(.disableLock) - } - } - )) - if model.appLockIsEnabled { - Toggle("Вход по \(model.biometricUnlockName)", isOn: Binding( - get: { model.appLockBiometricIsEnabled }, - set: { enabled in - passcodeSheetMode = .verify( - enabled ? .enableBiometrics : .disableBiometrics - ) - } - )) - .disabled(!model.biometricUnlockAvailable) - Button("Сменить пароль") { - passcodeSheetMode = .change - } - } - Text("При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Приватность чата") { - Toggle("Показывать статус набора", isOn: Binding( - get: { model.typingIndicatorsEnabled }, - set: { model.setTypingIndicatorsEnabled($0) } - )) - Text("Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section { - Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory) - - Button(role: .destructive) { - showingSignOutConfirmation = true - } label: { - Label("Выйти из аккаунта", systemImage: "rectangle.portrait.and.arrow.right") - } } } - .navigationTitle("") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .principal) { - Text("Настройки") + #else + formcontent() + #endif + } + + @ViewBuilder + private func formcontent() -> some View { + if let account = model.account { + Section("Профиль") { + HStack(spacing: 14) { + AvatarView( + conversation: Conversation( + jid: account.normalizedJID, + displayName: account.displayName.isEmpty + ? nil : account.displayName + ), + imageData: model.avatarData(for: account.normalizedJID), + size: 64 + ) + VStack(alignment: .leading, spacing: 6) { + Text( + account.displayName.isEmpty + ? account.normalizedJID : account.displayName + ) .font(.headline) - } - if !presentedAsTab { - ToolbarItem(placement: .confirmationAction) { - Button("Готово") { dismiss() } + PhotosPicker(selection: $avatarItem, matching: .images) + { + Label( + "Обновить аватар", + systemImage: "photo.badge.plus" + ) + } + .disabled(model.isUpdatingAvatar) + } + Spacer() + if model.isUpdatingAvatar { + ProgressView() } } } - .confirmationDialog( - "Выйти из Luma?", - isPresented: $showingSignOutConfirmation, - titleVisibility: .visible - ) { - Button("Выйти", role: .destructive) { - Task { - await model.signOut(forgetHistory: forgetHistory) - dismiss() - } - } - Button("Отмена", role: .cancel) {} - } message: { - Text(forgetHistory - ? "Пароль и локальная история будут удалены. Серверная история MAM не изменится." - : "Пароль будет удалён из Keychain. Серверная история MAM не изменится.") - } .onChange(of: avatarItem) { _, item in guard let item else { return } Task { do { - guard let data = try await item.loadTransferable(type: Data.self) else { return } + guard + let data = try await item.loadTransferable( + type: Data.self + ) + else { return } await model.updateOwnAvatar(from: data) avatarItem = nil } catch { @@ -197,13 +89,187 @@ struct SettingsView: View { } } } + + Section("Аккаунт") { + LabeledContent("JID", value: account.normalizedJID) + LabeledContent("Ресурс", value: account.effectiveResource) + if let host = account.manualHost { + LabeledContent( + "Сервер", + value: + "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))" + ) + } else { + LabeledContent("Обнаружение", value: "DNS SRV") + } + } + } + + Section("Соединение") { + HStack { + Label(connectionTitle, systemImage: connectionIcon) + .foregroundStyle(connectionColor) + Spacer() + if model.connectionStatus != .connected { + Button("Подключить") { Task { await model.reconnect() } } + } + } + LabeledContent( + "История MAM", + value: model.isArchiveSyncing ? "Синхронизация" : "Готово" + ) + NavigationLink { + ServerInfoView(model: model) + } label: { + Label("Информация о сервере", systemImage: "server.rack") + } + } + + Section("Шифрование") { + Toggle( + "OMEMO по умолчанию", + isOn: Binding( + get: { model.globalEncryptionEnabled }, + set: { model.setGlobalEncryptionEnabled($0) } + ) + ) + .toggleStyle(.switch) + Text( + "Для отдельного чата режим можно изменить через значок замка в его панели." + ) + .font(.caption) + .foregroundStyle(.secondary) + if !model.globalEncryptionEnabled { + Label( + "Новые сообщения без отдельного правила отправляются открытым текстом.", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) + } + LabeledContent( + "Состояние", + value: model.isOMEMOReady ? "Активно" : "Подготовка" + ) + if let fingerprint = model.ownFingerprint { + VStack(alignment: .leading, spacing: 7) { + Text("Отпечаток этого устройства") + .font(.subheadline) + Text(fingerprint.chunked(every: 8).joined(separator: " ")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + + Section("Уведомления") { + Label( + "Локальные уведомления включаются после входа", + systemImage: "bell.badge" + ) + Text( + "Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Блокировка приложения") { + Toggle( + "Заблокировать приложение", + isOn: Binding( + get: { model.appLockIsEnabled }, + set: { enabled in + if enabled { + passcodeSheetMode = .setup + } else { + passcodeSheetMode = .verify(.disableLock) + } + } + ) + ) + .toggleStyle(.switch) .sheet(item: $passcodeSheetMode) { mode in AppLockPasscodeSheet(model: model, mode: mode) } + if model.appLockIsEnabled { + Toggle( + "Вход по \(model.biometricUnlockName)", + isOn: Binding( + get: { model.appLockBiometricIsEnabled }, + set: { enabled in + passcodeSheetMode = .verify( + enabled ? .enableBiometrics : .disableBiometrics + ) + } + ) + ) + .toggleStyle(.switch) + .disabled(!model.biometricUnlockAvailable) + Button("Сменить пароль") { + passcodeSheetMode = .change + } + } + Text( + "При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Приватность чата") { + Toggle( + "Показывать статус набора", + isOn: Binding( + get: { model.typingIndicatorsEnabled }, + set: { model.setTypingIndicatorsEnabled($0) } + ) + ) + .toggleStyle(.switch) + Text( + "Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory) + .toggleStyle(.switch) + + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label( + "Выйти из аккаунта", + systemImage: "rectangle.portrait.and.arrow.right" + ) + } + #if os(macOS) + .buttonStyle(.glassProminent) + .controlSize(.large) + #endif + } + .confirmationDialog( + "Выйти из Luma?", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Выйти", role: .destructive) { + Task { + await model.signOut(forgetHistory: forgetHistory) + dismiss() + } + } + Button("Отмена", role: .cancel) {} + } message: { + Text( + forgetHistory + ? "Пароль и локальная история будут удалены. Серверная история MAM не изменится." + : "Пароль будет удалён из Keychain. Серверная история MAM не изменится." + ) } -#if os(macOS) - .frame(minWidth: 500, minHeight: 560) -#endif } private var connectionTitle: String { @@ -231,13 +297,14 @@ struct SettingsView: View { } } -private extension String { - func chunked(every size: Int) -> [String] { +extension String { + fileprivate func chunked(every size: Int) -> [String] { guard size > 0 else { return [self] } var result: [String] = [] var cursor = startIndex while cursor < endIndex { - let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex + let end = + index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex result.append(String(self[cursor..