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.
This commit is contained in:
wt committed 2026-08-30 01:39:54 +07:00
1 parent 6ed5cb3ce9
commit efe3986ba5
11 files changed
+539 -332

No files matched your search

-314
View File
@@ -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("Выберите чат или начните новый защищённый диалог.")
}
}
}
}
+484
View File
@@ -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)"
}
}
+1 -1
View File
@@ -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)
}
}
+7 -2
View File
@@ -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(