Files
Luma/Sources/Shared/UI/MainTabView.swift
T
wt efe3986ba5 add a telegram-style bottom tab bar to the main screen
- 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.
2026-08-30 01:39:54 +07:00

485 lines
18 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: 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)"
}
}