Files
Luma/Sources/Shared/UI/MainTabView.swift
T
wt e3fcee3351
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
updated ui for macos and ios
2026-08-31 19:11:38 +07:00

1036 lines
37 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import SwiftData
import SwiftUI
/// Telegram-style main screen. On iOS the root is a bottom tab bar
/// (Контакты / Звонки / Чаты / Настройки) with an inline search field at
/// the top of the chat and contact lists. On macOS the root is a Telegram
/// Desktop-style split view: a sidebar (menu, search, list) plus a detail
/// pane with the selected chat.
struct MainTabView: View {
@ObservedObject var model: AppModel
var body: some View {
#if os(macOS)
MainSplitView(model: model)
#else
MainTabBarView(model: model)
#endif
}
}
// MARK: - Shared search field
/// Inline rounded search field, placed explicitly above the list it
/// filters. Used on both platforms so the position never depends on
/// navigation-bar search placement.
private struct SearchField: View {
@Binding var text: String
let prompt: String
var body: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 13))
.foregroundStyle(.secondary)
TextField(prompt, text: $text)
.textFieldStyle(.plain)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
if !text.isEmpty {
Button {
text = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Очистить поиск")
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.glassEffect()
// .background(
// Color.secondary.opacity(0.14),
// in: RoundedRectangle(cornerRadius: 10)
// )
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
}
// MARK: - iOS: bottom tab bar
#if os(iOS)
private struct MainTabBarView: 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.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.circle.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 {
// Native TabView: on iOS 26 it renders the system floating
// translucent tab bar (Liquid Glass).
TabView(selection: $selectedTab) {
ForEach(Tab.allCases) { tab in
tabContent(for: tab)
.tabItem {
Label(
tab.title,
systemImage: selectedTab == tab
? tab.selectedIcon : tab.icon
)
}
.tag(tab)
}
}
}
@ViewBuilder
private func tabContent(for tab: Tab) -> some View {
switch tab {
case .contacts:
ContactsTab(model: model)
case .calls:
CallsTab(model: model)
case .chats:
ChatsTab(model: model)
case .settings:
NavigationStack {
SettingsView(model: model, presentedAsTab: true)
}
}
}
}
#endif
// 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?
@FocusState private var searchFocused: Bool
/// 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) {
chatList
}
.navigationTitle("")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Поиск чатов"
)
#endif
.toolbar {
ToolbarItem(placement: .principal) {
ConnectionBanner(model: model, text: "Чаты")
}
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 или имя."
)
)
}
}
}
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) {
contactsList
}
.navigationTitle("")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Поиск контактов"
)
#endif
.toolbar {
ToolbarItem(placement: .principal) {
ConnectionBanner(model: model, text: "Контакты")
}
}
}
}
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
)
)
}
}
}
if !filteredGroupContacts.isEmpty {
Section {
ForEach(filteredGroupContacts) { conversation in
contactLink(conversation)
.listRowInsets(
EdgeInsets(
top: 7,
leading: 12,
bottom: 7,
trailing: 12
)
)
}
}
}
}
.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("")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .principal) {
Text("Звонки")
.font(.headline)
}
}
}
}
}
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)"
}
}
// MARK: - macOS: Telegram Desktop-style split view
#if os(macOS)
private struct MainSplitView: View {
private enum Mode: String, CaseIterable, Identifiable {
case contacts
case calls
case chats
case settings
var id: String { rawValue }
var title: String {
switch self {
case .chats: return "Чаты"
case .contacts: return "Контакты"
case .calls: return "Звонки"
case .settings: return "Настройки"
}
}
var icon: String {
switch self {
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"
}
}
var searchPrompt: String {
switch self {
case .chats: return "Поиск чатов"
case .contacts: return "Поиск контактов"
case .calls: return "Поиск звонков"
case .settings: return "Поиск"
}
}
}
@ObservedObject var model: AppModel
@State private var mode: Mode = .chats
@State private var selectedJID: String?
@State private var searchText = ""
@State private var showingNewChat = false
@State private var showingNewGroup = false
@State private var pendingGroupDeletion: Conversation?
/// SwiftData-backed chat list; pinned-first ordering is applied in
/// `sortedChats` because `Bool` is not `Comparable` and cannot be a
/// `SortDescriptor`.
@Query(
sort: [
SortDescriptor(\Conversation.lastActivity, order: .reverse)
]
)
private var conversations: [Conversation]
var body: some View {
NavigationSplitView {
sidebar
.navigationSplitViewColumnWidth(
min: 240,
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)
.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 выйдет из комнаты, если вы в ней."
)
}
.onAppear {
ensureSelection()
}
}
// MARK: Sidebar
private var sidebar: some View {
VStack(spacing: 0) {
if mode != .settings {
ConnectionBanner(model: model, text: "")
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))
}
}
private var sidebarList: some View {
List(selection: $selectedJID) {
switch mode {
case .chats:
chatRows
case .contacts:
contactRows
case .calls:
callRows
case .settings:
// NavigationStack {
SettingsView(model: model, presentedAsTab: true)
// }
// EmptyView()
}
}
.listStyle(.sidebar)
.overlay {
if mode == .chats && filteredChats.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет чатов" : "Ничего не найдено",
systemImage: searchText.isEmpty
? "bubble.left" : "magnifyingglass",
description: Text(
searchText.isEmpty
? "Создайте личный или групповой чат."
: "Попробуйте другой JID или имя."
)
)
}
if mode == .contacts && filteredRoster.isEmpty
&& filteredGroups.isEmpty
{
ContentUnavailableView(
searchText.isEmpty
? "Нет контактов" : "Ничего не найдено",
systemImage: searchText.isEmpty
? "person.2" : "magnifyingglass",
description: Text(
searchText.isEmpty
? "Подключитесь к серверу, чтобы загрузить roster, или создайте групповой чат."
: "Попробуйте другой JID или имя."
)
)
}
}
}
@ViewBuilder
private var chatRows: some View {
ForEach(filteredChats) { conversation in
ConversationRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
isEncrypted: model.encryptionEnabled(for: conversation.jid)
)
.tag(conversation.jid)
.contextMenu {
if conversation.isGroup {
Button("Удалить", role: .destructive) {
pendingGroupDeletion = conversation
}
}
}
}
}
@ViewBuilder
private var contactRows: some View {
if !filteredRoster.isEmpty {
Section {
ForEach(filteredRoster) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.jid)
}
}
}
if !filteredGroups.isEmpty {
Section {
ForEach(filteredGroups) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.jid)
}
}
}
}
@ViewBuilder
private var callRows: some View {
ForEach(filteredCalls) { message in
if let conversation = conversations.first(where: {
$0.jid == message.conversationID
}) {
CallHistoryRow(
model: model,
message: message,
conversation: conversation
)
.tag(message.clientID)
} else {
CallHistoryRow(
model: model,
message: message,
conversation: nil
)
.tag(message.clientID)
}
}
}
// MARK: Detail pane
@ViewBuilder
private var detailPane: some View {
if mode == .settings {
ContentUnavailableView(
emptyTitle,
systemImage: "gearshape.fill",
description: Text("Выберите пункт из списка слева.")
)
} else if let conversation = selectedConversation
?? defaultConversation
{
ChatView(model: model, conversation: conversation)
.id(conversation.jid)
.onAppear {
model.selectConversation(id: conversation.jid)
}
} else {
ContentUnavailableView(
emptyTitle,
systemImage: "bubble.left.and.bubble.right",
description: Text("Выберите пункт из списка слева.")
)
}
}
private var emptyTitle: String {
switch mode {
case .chats: return "Выберите чат"
case .contacts: return "Выберите контакт"
case .calls: return "Выберите звонок"
case .settings: return "Настройки"
}
}
private var selectedConversation: Conversation? {
guard let selectedJID else { return nil }
if mode == .calls {
guard
let message = model.callHistoryMessages.first(where: {
$0.clientID == selectedJID
})
else {
return nil
}
return conversations.first(where: {
$0.jid == message.conversationID
})
}
return conversations.first(where: { $0.jid == selectedJID })
}
private var defaultConversation: Conversation? {
mode == .chats ? sortedChats.first : nil
}
// MARK: Filtering and sorting
private var sortedChats: [Conversation] {
conversations.sorted(by: conversationSort)
}
private var filteredChats: [Conversation] {
searchText.isEmpty ? sortedChats : sortedChats.filter(matchesChat)
}
private func matchesChat(_ conversation: Conversation) -> Bool {
conversation.displayName.localizedCaseInsensitiveContains(
searchText
)
|| conversation.jid.localizedCaseInsensitiveContains(searchText)
}
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 filteredRoster: [Conversation] {
conversations
.filter {
!$0.isGroup && model.rosterContactJIDs.contains($0.jid)
}
.filter { searchText.isEmpty || matchesChat($0) }
.sorted(by: contactSort)
}
private var filteredGroups: [Conversation] {
conversations
.filter { $0.isGroup }
.filter { searchText.isEmpty || matchesChat($0) }
.sorted(by: contactSort)
}
private func contactSort(_ lhs: Conversation, _ rhs: Conversation)
-> Bool
{
lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
== .orderedAscending
}
private var filteredCalls: [ChatMessage] {
guard !searchText.isEmpty else { return model.callHistoryMessages }
return model.callHistoryMessages.filter { message in
message.conversationID.localizedCaseInsensitiveContains(
searchText
)
|| (conversations.first(where: {
$0.jid == message.conversationID
})?.displayName
.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
// MARK: Selection
private func switchMode(_ newMode: Mode) {
mode = newMode
selectedJID = nil
searchText = ""
if newMode == .chats {
selectedJID = sortedChats.first?.jid
}
}
private func ensureSelection() {
if mode == .chats, selectedJID == nil {
selectedJID = sortedChats.first?.jid
}
}
}
#endif