From efe3986ba596dc8a9022ee3748d75727f666ea1b Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sun, 30 Aug 2026 01:39:54 +0700 Subject: [PATCH] add a telegram-style bottom tab bar to the main screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the sidebar-based MainChatView with MainTabView: a custom bottom bar with Контакты / Звонки / Чаты / Настройки that looks the same on iOS and macOS, and open chats via push navigation. - Put the search field above the chat and contact lists (searchable on iOS, an inline field on macOS) and add an aggregated call-history screen backed by model.callHistoryMessages. - Embed SettingsView as a tab, update verify.sh guards, and keep CI on deterministic unit tests while the UI tests stay runnable manually. --- .github/workflows/ios.yml | 5 +- AGENTS.md | 7 +- Luma.xcodeproj/project.pbxproj | 12 +- .../xcshareddata/xcschemes/Luma.xcscheme | 11 + Scripts/verify.sh | 14 +- Sources/Shared/Models/AppModel.swift | 10 + Sources/Shared/UI/MainChatView.swift | 314 ------------ Sources/Shared/UI/MainTabView.swift | 484 ++++++++++++++++++ Sources/Shared/UI/RootView.swift | 2 +- Sources/Shared/UI/SettingsView.swift | 9 +- project.yml | 3 +- 11 files changed, 539 insertions(+), 332 deletions(-) delete mode 100644 Sources/Shared/UI/MainChatView.swift create mode 100644 Sources/Shared/UI/MainTabView.swift diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 5832ef7..33cdbdb 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -29,13 +29,16 @@ jobs: - name: Resolve Swift Packages run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma - # Step 4: Build and run tests using xcodebuild + # Step 4: Build and run tests using xcodebuild. CI runs the + # deterministic unit tests only; the UI tests (LumaUITests) are + # exercised manually on a simulator. - name: Build and Test run: | xcodebuild test \ -project Luma.xcodeproj \ -scheme Luma \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \ + -only-testing:LumaTests \ -resultBundlePath TestResults.xcresult - name: Upload Test Results diff --git a/AGENTS.md b/AGENTS.md index a3c26ae..278359a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,10 @@ and the Martin / MartinOMEMO libraries. The Xcode project is generated from - `Models/` — value types and pure policy enums (`ArchiveSyncPagination`, `ChatTypingPolicy`, …) plus the SwiftData `@Model` classes (`ChatMessage`, `Conversation`). - - `UI/` — SwiftUI views; chat list, timeline and forward picker read - SwiftData through `@Query` (`MainChatView`, `ChatView`, - `ForwardMessageView`). + - `UI/` — SwiftUI views; chat/contact lists read SwiftData through + `@Query` (`MainTabView`, `ChatView`, `ForwardMessageView`); the main + screen is a Telegram-style bottom tab bar (Контакты/Звонки/Чаты/ + Настройки). - `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store, `SASLprep` (RFC 4013), SCRAM-SHA-512 mechanism, SASL failure observer/messages. diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 3f95ac2..7617a93 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -16,7 +16,6 @@ 03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; }; 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */; }; 067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; }; - 08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; }; 087E600676B3F6134BB26112 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; }; 088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; }; 097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */; }; @@ -101,6 +100,7 @@ 5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; }; 58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; }; 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; }; + 6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; }; 60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; }; 6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; }; 61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */; }; @@ -133,7 +133,6 @@ 7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; }; 7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; }; 7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; }; - 7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; }; 7F012252FA73A8282B92323C /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; }; 7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; }; 7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; }; @@ -203,6 +202,7 @@ CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; }; CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; }; CC5297F90A3DBD45C5E41A3A /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */; }; + D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; }; D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5B4C1116F6ACB6F9B64BF4 /* NotificationCoordinator.swift */; }; D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; }; D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */; }; @@ -383,6 +383,7 @@ 96720381802141EBA1DA9772 /* LumaUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = ""; }; 99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = ""; }; + 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; 9A338A8EB30206647BDD922F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 9B11FED2D58621C071C3AD14 /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicyTests.swift; sourceTree = ""; }; @@ -420,7 +421,6 @@ CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreferenceTests.swift; sourceTree = ""; }; D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNotePreview.swift; sourceTree = ""; }; D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpointTests.swift; sourceTree = ""; }; - D3AA66547A2AA2D0979DA76E /* MainChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatView.swift; sourceTree = ""; }; D414B62940A9234C51C3D14E /* MessageReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReaction.swift; sourceTree = ""; }; D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImageProcessor.swift; sourceTree = ""; }; D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessagePlayer.swift; sourceTree = ""; }; @@ -647,7 +647,7 @@ BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */, 2786A15415F65305F78D892C /* LocationPickerView.swift */, 05EEEE8A125347F7C5014A09 /* LoginView.swift */, - D3AA66547A2AA2D0979DA76E /* MainChatView.swift */, + 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */, 43A4CE3E678096BA76F2988C /* NewChatView.swift */, 99BE34DC082D19007241AA62 /* NewGroupView.swift */, 515DD6F1F80A848C39EBAE84 /* RootView.swift */, @@ -988,7 +988,7 @@ F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */, 0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */, E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */, - 08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */, + D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */, 0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */, 2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */, 7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */, @@ -1101,7 +1101,7 @@ 7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */, C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */, 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */, - 7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */, + 6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */, EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */, AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */, D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */, diff --git a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme index b7f0e83..f7ce986 100644 --- a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme +++ b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme @@ -64,6 +64,17 @@ ReferencedContainer = "container:Luma.xcodeproj"> + + + + diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 6e4f2e7..e866a1a 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -282,7 +282,7 @@ if test -f Sources/Shared/Persistence/ChatArchive.swift; then echo "The legacy JSON ChatArchive actor must stay removed" exit 1 fi -grep -q '@Query' Sources/Shared/UI/MainChatView.swift || { +grep -q '@Query' Sources/Shared/UI/MainTabView.swift || { echo "Chat list must be SwiftData @Query-driven" exit 1 } @@ -378,7 +378,7 @@ grep -q 'Удалить чат' Sources/Shared/UI/GroupInfoView.swift || { echo "Group info must offer deleting the chat" exit 1 } -grep -q 'pendingGroupDeletion' Sources/Shared/UI/MainChatView.swift || { +grep -q 'pendingGroupDeletion' Sources/Shared/UI/MainTabView.swift || { echo "Chat list must offer deleting group chats" exit 1 } @@ -410,6 +410,14 @@ grep -q 'biometricUnlockAvailable' Sources/Shared/Models/AppModel.swift || { echo "Biometric availability must be cached off the view body" exit 1 } +grep -q 'Звонки' Sources/Shared/UI/MainTabView.swift || { + echo "The main screen must offer the Calls tab" + exit 1 +} +grep -q 'Настройки' Sources/Shared/UI/MainTabView.swift || { + echo "The main screen must offer the Settings tab" + exit 1 +} grep -q 'case .background:' Sources/App/LumaApp.swift || { echo "The app lock must trigger on background only, not on inactive" exit 1 @@ -770,7 +778,7 @@ grep -q 'failedDrafts' Sources/Shared/UI/AttachmentPreviewView.swift || { echo "Failed media drafts must remain available for retry" exit 1 } -grep -q 'rosterContactJIDs' Sources/Shared/UI/MainChatView.swift || { +grep -q 'rosterContactJIDs' Sources/Shared/UI/MainTabView.swift || { echo "Prosody roster contacts screen is missing" exit 1 } diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index f050dcc..f9d770d 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -251,6 +251,16 @@ final class AppModel: ObservableObject { ) } + /// Aggregated call history for the «Звонки» tab, newest first. + var callHistoryMessages: [ChatMessage] { + messages + .filter { $0.callHistory != nil } + .sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.clientID < rhs.clientID + } + } + var selectedMessages: [ChatMessage] { guard let selectedConversationID else { return [] } if selectedMessagesCacheConversationID == selectedConversationID { diff --git a/Sources/Shared/UI/MainChatView.swift b/Sources/Shared/UI/MainChatView.swift deleted file mode 100644 index be341de..0000000 --- a/Sources/Shared/UI/MainChatView.swift +++ /dev/null @@ -1,314 +0,0 @@ -import Foundation -import SwiftData -import SwiftUI - -struct MainChatView: View { - private enum SidebarMode: String, CaseIterable { - case chats - case contacts - - var title: String { - switch self { - case .chats: return "Чаты" - case .contacts: return "Контакты" - } - } - } - - @ObservedObject var model: AppModel - @State private var searchText = "" - @State private var sidebarMode: SidebarMode = .chats - @State private var showingNewChat = false - @State private var showingNewGroup = false - @State private var showingSettings = false - @State private var pendingGroupDeletion: Conversation? - - /// SwiftData-backed chat list. The query orders by last activity; - /// `filteredConversations` applies the pinned-first ordering that matches - /// `AppModel.sortConversations` (`Bool` is not `Comparable`, so pinning - /// cannot be a `SortDescriptor`). - @Query( - sort: [ - SortDescriptor(\Conversation.lastActivity, order: .reverse), - ] - ) - private var conversations: [Conversation] - - private var filteredConversations: [Conversation] { - let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch) - return source.sorted(by: conversationSort) - } - - private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { - if lhs.isPinned != rhs.isPinned { return lhs.isPinned } - if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity } - return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) - == .orderedAscending - } - - private var filteredRosterContacts: [Conversation] { - conversations - .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) } - .filter { searchText.isEmpty || matchesSearch($0) } - .sorted(by: contactSort) - } - - private var filteredGroupContacts: [Conversation] { - conversations - .filter { $0.isGroup } - .filter { searchText.isEmpty || matchesSearch($0) } - .sorted(by: contactSort) - } - - private var hasStoredContacts: Bool { - !model.rosterContactJIDs.isEmpty || conversations.contains { $0.isGroup } - } - - var body: some View { - NavigationSplitView { - VStack(spacing: 0) { - ConnectionBanner(model: model) - - Picker("Раздел", selection: $sidebarMode) { - Label("Чаты", systemImage: "bubble.left.and.bubble.right.fill") - .tag(SidebarMode.chats) - Label("Контакты", systemImage: "person.2.fill") - .tag(SidebarMode.contacts) - } - .pickerStyle(.segmented) - .padding(.horizontal, 12) - .padding(.vertical, 8) - - switch sidebarMode { - case .chats: - chatList - case .contacts: - contactsList - } - } - .navigationTitle(sidebarMode.title) - .searchable( - text: $searchText, - prompt: sidebarMode == .chats ? "Поиск чатов" : "Поиск контактов" - ) - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button { showingSettings = true } label: { - Image(systemName: "gearshape") - } - Button { showingNewGroup = true } label: { - Image(systemName: "person.3.fill") - } - .accessibilityLabel("Новый групповой чат") - .help("Новый групповой чат") - Button { showingNewChat = true } label: { - Image(systemName: "square.and.pencil") - } - .accessibilityLabel("Новый личный чат") - .help("Новый личный чат") - } - } - } detail: { - if let conversation = model.selectedConversation { - ChatView(model: model, conversation: conversation) - .id(conversation.jid) - } else { - WelcomeDetailView() - } - } - .navigationSplitViewStyle(.balanced) - .onChange(of: model.selectedConversationID) { _, id in - guard let id else { return } - model.selectConversation(id: id) - } - .sheet(isPresented: $showingNewChat) { - NewChatView(model: model) - } - .sheet(isPresented: $showingNewGroup) { - NewGroupView(model: model) - } - .sheet(isPresented: $showingSettings) { - SettingsView(model: model) - } - .alert( - "Удалить групповой чат?", - isPresented: Binding( - get: { pendingGroupDeletion != nil }, - set: { if !$0 { pendingGroupDeletion = nil } } - ) - ) { - Button("Удалить", role: .destructive) { - if let pendingGroupDeletion { - model.deleteGroupChat(jid: pendingGroupDeletion.jid) - } - pendingGroupDeletion = nil - } - Button("Отмена", role: .cancel) { pendingGroupDeletion = nil } - } message: { - Text("Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней.") - } - } - - private var chatList: some View { - List(selection: $model.selectedConversationID) { - ForEach(filteredConversations) { conversation in - ConversationRow( - conversation: conversation, - imageData: model.avatarData(for: conversation.jid), - isEncrypted: model.encryptionEnabled(for: conversation.jid) - ) - .tag(conversation.jid) - .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if conversation.isGroup { - Button(role: .destructive) { - pendingGroupDeletion = conversation - } label: { - Label("Удалить", systemImage: "trash") - } - } - } - } - } - .listStyle(.plain) - .overlay { - if filteredConversations.isEmpty { - ContentUnavailableView( - searchText.isEmpty ? "Нет чатов" : "Ничего не найдено", - systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass", - description: Text( - searchText.isEmpty - ? "Создайте личный или групповой чат." - : "Попробуйте другой JID или имя." - ) - ) - } - } - } - - private var contactsList: some View { - List(selection: $model.selectedConversationID) { - if !filteredRosterContacts.isEmpty { - Section { - ForEach(filteredRosterContacts) { conversation in - ContactRow( - conversation: conversation, - imageData: model.avatarData(for: conversation.jid) - ) - .tag(conversation.jid) - .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)) - } - } header: { - Label("Люди из roster", systemImage: "person.2.fill") - } - } - - if !filteredGroupContacts.isEmpty { - Section { - ForEach(filteredGroupContacts) { conversation in - ContactRow( - conversation: conversation, - imageData: model.avatarData(for: conversation.jid) - ) - .tag(conversation.jid) - .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)) - } - } header: { - Label("Групповые чаты", systemImage: "person.3.fill") - } - } - } - .listStyle(.plain) - .overlay { - if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty { - ContentUnavailableView( - searchText.isEmpty ? "Нет контактов" : "Ничего не найдено", - systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass", - description: Text(contactListDescription) - ) - } - } - } - - private var contactListDescription: String { - if !searchText.isEmpty { - return "Попробуйте другой JID или имя." - } - if !hasStoredContacts, model.connectionStatus != .connected { - return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты." - } - return "Добавьте человека в roster или создайте групповой чат." - } - - private func matchesSearch(_ conversation: Conversation) -> Bool { - conversation.displayName.localizedCaseInsensitiveContains(searchText) - || conversation.jid.localizedCaseInsensitiveContains(searchText) - } - - private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { - lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending - } -} - -private struct ContactRow: View { - let conversation: Conversation - let imageData: Data? - - var body: some View { - HStack(spacing: 12) { - AvatarView(conversation: conversation, imageData: imageData, size: 42) - - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text(conversation.displayName) - .font(.headline) - .lineLimit(1) - if !conversation.isGroup, conversation.isOnline { - Circle() - .fill(Color.green) - .frame(width: 7, height: 7) - .accessibilityLabel("В сети") - } - } - - if conversation.isGroup { - HStack(spacing: 4) { - Image(systemName: "person.3.fill") - Text(groupSubtitle) - } - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text(conversation.jid) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.caption.bold()) - .foregroundStyle(.tertiary) - } - .contentShape(Rectangle()) - } - - private var groupSubtitle: String { - guard conversation.occupantCount > 0 else { return conversation.jid } - return "\(conversation.occupantCount) участн. • \(conversation.jid)" - } -} - -private struct WelcomeDetailView: View { - var body: some View { - ZStack { - Color.secondary.opacity(0.04).ignoresSafeArea() - ContentUnavailableView { - Label("Luma", systemImage: "bubble.left.and.bubble.right.fill") - } description: { - Text("Выберите чат или начните новый защищённый диалог.") - } - } - } -} diff --git a/Sources/Shared/UI/MainTabView.swift b/Sources/Shared/UI/MainTabView.swift new file mode 100644 index 0000000..86d1256 --- /dev/null +++ b/Sources/Shared/UI/MainTabView.swift @@ -0,0 +1,484 @@ +import Foundation +import SwiftData +import SwiftUI + +/// Telegram-style main screen: a custom bottom tab bar (Контакты / Звонки / +/// Чаты / Настройки) shared by iOS and macOS, with a search field above the +/// chat and contact lists. +struct MainTabView: View { + enum Tab: String, CaseIterable, Identifiable { + case contacts + case calls + case chats + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .contacts: return "Контакты" + case .calls: return "Звонки" + case .chats: return "Чаты" + case .settings: return "Настройки" + } + } + + 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" + } + } + + var selectedIcon: String { + switch self { + case .contacts: return "person.2.fill" + case .calls: return "phone.fill" + case .chats: return "bubble.left.and.bubble.right.fill" + case .settings: return "gearshape.fill" + } + } + } + + @ObservedObject var model: AppModel + @State private var selectedTab: Tab = .chats + + var body: some View { + VStack(spacing: 0) { + Group { + switch selectedTab { + case .contacts: + ContactsTab(model: model) + case .calls: + CallsTab(model: model) + case .chats: + ChatsTab(model: model) + case .settings: + NavigationStack { + SettingsView(model: model, presentedAsTab: true) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + tabBar + } + .ignoresSafeArea(.keyboard, edges: .bottom) + } + + private var tabBar: some View { + HStack(spacing: 0) { + ForEach(Tab.allCases) { tab in + Button { + selectedTab = tab + } label: { + VStack(spacing: 3) { + Image(systemName: selectedTab == tab ? tab.selectedIcon : tab.icon) + .font(.system(size: 21, weight: .medium)) + Text(tab.title) + .font(.system(size: 10, weight: selectedTab == tab ? .semibold : .regular)) + } + .foregroundStyle(selectedTab == tab ? Color.accentColor : Color.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(tab.title) + .help(tab.title) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 2) + .background(.bar) + } +} + +// MARK: - Chats + +private struct ChatsTab: View { + @ObservedObject var model: AppModel + @State private var searchText = "" + @State private var showingNewChat = false + @State private var showingNewGroup = false + @State private var pendingGroupDeletion: Conversation? + + /// SwiftData-backed chat list. The query orders by last activity; + /// `filteredConversations` applies the pinned-first ordering that matches + /// `AppModel.sortConversations` (`Bool` is not `Comparable`, so pinning + /// cannot be a `SortDescriptor`). + @Query( + sort: [ + SortDescriptor(\Conversation.lastActivity, order: .reverse), + ] + ) + private var conversations: [Conversation] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + ConnectionBanner(model: model) +#if os(macOS) + searchField +#endif + chatList + } + .navigationTitle("Чаты") +#if os(iOS) + .searchable(text: $searchText, prompt: "Поиск чатов") +#endif + .toolbar { + 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) + } + .sheet(isPresented: $showingNewGroup) { + NewGroupView(model: model) + } + .alert( + "Удалить групповой чат?", + isPresented: Binding( + get: { pendingGroupDeletion != nil }, + set: { if !$0 { pendingGroupDeletion = nil } } + ) + ) { + Button("Удалить", role: .destructive) { + if let pendingGroupDeletion { + model.deleteGroupChat(jid: pendingGroupDeletion.jid) + } + pendingGroupDeletion = nil + } + Button("Отмена", role: .cancel) { pendingGroupDeletion = nil } + } message: { + Text("Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней.") + } + } + } + + private var filteredConversations: [Conversation] { + let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch) + return source.sorted(by: conversationSort) + } + + private var chatList: some View { + List { + ForEach(filteredConversations) { conversation in + NavigationLink { + ChatView(model: model, conversation: conversation) + .id(conversation.jid) + .onAppear { model.selectConversation(id: conversation.jid) } + } label: { + ConversationRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid), + isEncrypted: model.encryptionEnabled(for: conversation.jid) + ) + } + .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if conversation.isGroup { + Button(role: .destructive) { + pendingGroupDeletion = conversation + } label: { + Label("Удалить", systemImage: "trash") + } + } + } + } + } + .listStyle(.plain) + .overlay { + if filteredConversations.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет чатов" : "Ничего не найдено", + systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass", + description: Text( + searchText.isEmpty + ? "Создайте личный или групповой чат." + : "Попробуйте другой JID или имя." + ) + ) + } + } + } + +#if os(macOS) + private var searchField: some View { + TextField("Поиск чатов", text: $searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } +#endif + + private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { + if lhs.isPinned != rhs.isPinned { return lhs.isPinned } + if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + == .orderedAscending + } + + private func matchesSearch(_ conversation: Conversation) -> Bool { + conversation.displayName.localizedCaseInsensitiveContains(searchText) + || conversation.jid.localizedCaseInsensitiveContains(searchText) + } +} + +// MARK: - Contacts + +private struct ContactsTab: View { + @ObservedObject var model: AppModel + @State private var searchText = "" + + @Query( + sort: [ + SortDescriptor(\Conversation.lastActivity, order: .reverse), + ] + ) + private var conversations: [Conversation] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + ConnectionBanner(model: model) +#if os(macOS) + TextField("Поиск контактов", text: $searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal, 12) + .padding(.vertical, 8) +#endif + contactsList + } + .navigationTitle("Контакты") +#if os(iOS) + .searchable(text: $searchText, prompt: "Поиск контактов") +#endif + } + } + + private var contactsList: some View { + List { + if !filteredRosterContacts.isEmpty { + Section { + ForEach(filteredRosterContacts) { conversation in + contactLink(conversation) + .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)) + } + } header: { + Label("Люди из roster", systemImage: "person.2.fill") + } + } + + if !filteredGroupContacts.isEmpty { + Section { + ForEach(filteredGroupContacts) { conversation in + contactLink(conversation) + .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)) + } + } header: { + Label("Групповые чаты", systemImage: "person.3.fill") + } + } + } + .listStyle(.plain) + .overlay { + if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет контактов" : "Ничего не найдено", + systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass", + description: Text(contactListDescription), + ) + } + } + } + + @ViewBuilder + private func contactLink(_ conversation: Conversation) -> some View { + NavigationLink { + ChatView(model: model, conversation: conversation) + .id(conversation.jid) + .onAppear { model.selectConversation(id: conversation.jid) } + } label: { + ContactRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid) + ) + } + } + + private var filteredRosterContacts: [Conversation] { + conversations + .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) } + .filter { searchText.isEmpty || matchesSearch($0) } + .sorted(by: contactSort) + } + + private var filteredGroupContacts: [Conversation] { + conversations + .filter { $0.isGroup } + .filter { searchText.isEmpty || matchesSearch($0) } + .sorted(by: contactSort) + } + + private var hasStoredContacts: Bool { + !model.rosterContactJIDs.isEmpty || conversations.contains { $0.isGroup } + } + + private var contactListDescription: String { + if !searchText.isEmpty { + return "Попробуйте другой JID или имя." + } + if !hasStoredContacts, model.connectionStatus != .connected { + return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты." + } + return "Добавьте человека в roster или создайте групповой чат." + } + + private func matchesSearch(_ conversation: Conversation) -> Bool { + conversation.displayName.localizedCaseInsensitiveContains(searchText) + || conversation.jid.localizedCaseInsensitiveContains(searchText) + } + + private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } +} + +// MARK: - Calls + +private struct CallsTab: View { + @ObservedObject var model: AppModel + + var body: some View { + NavigationStack { + Group { + if model.callHistoryMessages.isEmpty { + ContentUnavailableView( + "Нет звонков", + systemImage: "phone", + description: Text("История звонков появится здесь после первого вызова.") + ) + } else { + List(model.callHistoryMessages) { message in + if let conversation = model.conversations.first(where: { + $0.jid == message.conversationID + }) { + NavigationLink { + ChatView(model: model, conversation: conversation) + .id(conversation.jid) + .onAppear { model.selectConversation(id: conversation.jid) } + } label: { + CallHistoryRow(model: model, message: message, conversation: conversation) + } + .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)) + } else { + CallHistoryRow(model: model, message: message, conversation: nil) + .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)) + } + } + .listStyle(.plain) + } + } + .navigationTitle("Звонки") + } + } +} + +private struct CallHistoryRow: View { + @ObservedObject var model: AppModel + let message: ChatMessage + let conversation: Conversation? + + var body: some View { + HStack(spacing: 12) { + Image(systemName: message.callHistory?.isVideo == true ? "video.fill" : "phone.fill") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(message.direction == .incoming && message.callHistory?.outcome == .missed + ? Color.red : Color.accentColor) + .frame(width: 34, height: 34) + .background(Color.secondary.opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(conversation?.displayName ?? message.conversationID) + .font(.headline) + .lineLimit(1) + Text(message.callTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 8) + Text(message.timestamp, format: .dateTime.hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } +} + +private struct ContactRow: View { + let conversation: Conversation + let imageData: Data? + + var body: some View { + HStack(spacing: 12) { + AvatarView(conversation: conversation, imageData: imageData, size: 42) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(conversation.displayName) + .font(.headline) + .lineLimit(1) + if !conversation.isGroup, conversation.isOnline { + Circle() + .fill(Color.green) + .frame(width: 7, height: 7) + .accessibilityLabel("В сети") + } + } + + if conversation.isGroup { + HStack(spacing: 4) { + Image(systemName: "person.3.fill") + Text(groupSubtitle) + } + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(conversation.jid) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.caption.bold()) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + + private var groupSubtitle: String { + guard conversation.occupantCount > 0 else { return conversation.jid } + return "\(conversation.occupantCount) участн. • \(conversation.jid)" + } +} + diff --git a/Sources/Shared/UI/RootView.swift b/Sources/Shared/UI/RootView.swift index a239c22..d3b06a9 100644 --- a/Sources/Shared/UI/RootView.swift +++ b/Sources/Shared/UI/RootView.swift @@ -16,7 +16,7 @@ struct RootView: View { if model.account == nil { LoginView(model: model) } else { - MainChatView(model: model) + MainTabView(model: model) .environment(\.modelContext, model.modelContext) } } diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift index 439e318..f3dc125 100644 --- a/Sources/Shared/UI/SettingsView.swift +++ b/Sources/Shared/UI/SettingsView.swift @@ -4,6 +4,9 @@ import SwiftUI struct SettingsView: View { @ObservedObject var model: AppModel + /// When embedded as the «Настройки» tab there is no sheet to dismiss, + /// so the confirmation toolbar button is hidden. + var presentedAsTab: Bool = false @Environment(\.dismiss) private var dismiss @State private var showingSignOutConfirmation = false @State private var forgetHistory = false @@ -152,8 +155,10 @@ struct SettingsView: View { } .navigationTitle("Настройки") .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Готово") { dismiss() } + if !presentedAsTab { + ToolbarItem(placement: .confirmationAction) { + Button("Готово") { dismiss() } + } } } .confirmationDialog( diff --git a/project.yml b/project.yml index b946c08..7894880 100644 --- a/project.yml +++ b/project.yml @@ -213,8 +213,7 @@ schemes: config: Debug targets: - LumaTests - # LumaUITests are opt-in (flaky under parallel CI load): run them with - # xcodebuild test -only-testing:LumaUITests + - LumaUITests archive: config: Release LumaMac: