Files
telegram-tui/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift
2026-05-20 15:43:07 +03:00

371 lines
11 KiB
Swift

import SwiftUI
public struct RootView: View {
@StateObject private var store: SessionStore
@StateObject private var authViewModel: AuthViewModel
@StateObject private var chatListViewModel: ChatListViewModel
public init(store: SessionStore) {
let authViewModel = AuthViewModel(store: store)
let chatListViewModel = ChatListViewModel(bridge: store.bridge)
_store = StateObject(wrappedValue: store)
_authViewModel = StateObject(wrappedValue: authViewModel)
_chatListViewModel = StateObject(wrappedValue: chatListViewModel)
}
public var body: some View {
Group {
switch store.authState {
case .ready:
ChatListView(viewModel: chatListViewModel, bridge: store.bridge)
default:
AuthView(state: store.authState, viewModel: authViewModel)
}
}
.task {
await store.refreshAuthState()
}
}
}
public struct AuthView: View {
public var state: AuthState
@ObservedObject public var viewModel: AuthViewModel
public init(state: AuthState, viewModel: AuthViewModel) {
self.state = state
self.viewModel = viewModel
}
public var body: some View {
VStack(spacing: 16) {
Text("Telegram")
.font(.largeTitle)
.fontWeight(.semibold)
authField
Button(action: {
Task { await viewModel.submitCurrentStep() }
}) {
Text(buttonTitle)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading || !canSubmit)
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding()
}
@ViewBuilder
private var authField: some View {
switch state {
case .waitPhoneNumber, .waitTdlibParameters:
TextField("Phone number", text: $viewModel.phone)
.textContentType(.telephoneNumber)
.textFieldStyle(.roundedBorder)
case .waitCode:
TextField("Code", text: $viewModel.code)
.textContentType(.oneTimeCode)
.textFieldStyle(.roundedBorder)
case .waitPassword:
SecureField("Password", text: $viewModel.password)
.textContentType(.password)
.textFieldStyle(.roundedBorder)
case .ready:
Text("Ready")
case .closed:
Text("Session closed")
case let .error(message):
Text(message)
.foregroundStyle(.red)
}
}
private var buttonTitle: String {
switch state {
case .waitPhoneNumber, .waitTdlibParameters:
"Continue"
case .waitCode:
"Verify"
case .waitPassword:
"Unlock"
default:
"Continue"
}
}
private var canSubmit: Bool {
switch state {
case .waitPhoneNumber, .waitTdlibParameters:
!viewModel.phone.isEmpty
case .waitCode:
!viewModel.code.isEmpty
case .waitPassword:
!viewModel.password.isEmpty
default:
false
}
}
}
public struct ChatListView: View {
@ObservedObject public var viewModel: ChatListViewModel
public let bridge: SessionBridge
@State private var selectedChat: ChatSummary?
@State private var showsAccountSwitcher = false
public init(viewModel: ChatListViewModel, bridge: SessionBridge) {
self.viewModel = viewModel
self.bridge = bridge
}
public var body: some View {
NavigationSplitView {
List(selection: $selectedChat) {
ForEach(viewModel.filteredChats) { chat in
NavigationLink(value: chat) {
ChatRow(chat: chat)
}
}
}
.navigationTitle("Chats")
.searchable(text: $viewModel.searchText)
.toolbar {
ToolbarItem {
Button("Accounts") {
showsAccountSwitcher = true
}
}
ToolbarItem {
folderMenu
}
}
.sheet(isPresented: $showsAccountSwitcher) {
AccountSwitcherView()
}
.task {
await viewModel.load()
}
} detail: {
if let selectedChat {
ChatDetailView(viewModel: ChatViewModel(chat: selectedChat, bridge: bridge), bridge: bridge)
} else {
Text("Select a chat")
}
}
}
private var folderMenu: some View {
Menu("Folders") {
Button("All") {
viewModel.selectedFolderId = nil
Task { await viewModel.load() }
}
ForEach(viewModel.folders) { folder in
Button(folder.name) {
viewModel.selectedFolderId = folder.id
Task { await viewModel.load() }
}
}
}
}
}
public struct ChatRow: View {
public var chat: ChatSummary
public init(chat: ChatSummary) {
self.chat = chat
}
public var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(chat.title)
.font(.headline)
.lineLimit(1)
if chat.isPinned {
Image(systemName: "pin.fill")
.font(.caption)
}
Spacer()
if chat.unreadCount > 0 {
Text("\(chat.unreadCount)")
.font(.caption)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.blue, in: Capsule())
.foregroundStyle(.white)
}
}
Text(chat.draft?.text ?? chat.lastMessage)
.font(.subheadline)
.foregroundStyle(chat.draft == nil ? Color.secondary : Color.red)
.lineLimit(2)
}
.padding(.vertical, 4)
}
}
public struct ChatDetailView: View {
@StateObject public var viewModel: ChatViewModel
public let bridge: SessionBridge
@StateObject private var profileViewModel: ProfileViewModel
@State private var showsProfile = false
public init(viewModel: ChatViewModel, bridge: SessionBridge) {
_viewModel = StateObject(wrappedValue: viewModel)
self.bridge = bridge
_profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge))
}
public var body: some View {
VStack(spacing: 0) {
List(viewModel.messages) { message in
MessageRow(message: message)
.listRowSeparator(.hidden)
}
ComposeBar(text: $viewModel.composeText) {
Task { await viewModel.send() }
}
}
.navigationTitle(viewModel.chat.title)
.toolbar {
Button("Profile") {
showsProfile = true
Task { await profileViewModel.load(chatId: viewModel.chat.id) }
}
}
.sheet(isPresented: $showsProfile) {
ProfileView(viewModel: profileViewModel)
}
.task {
await viewModel.load()
}
}
}
public struct MessageRow: View {
public var message: Message
public init(message: Message) {
self.message = message
}
public var body: some View {
HStack {
if message.isOutgoing {
Spacer(minLength: 48)
}
VStack(alignment: .leading, spacing: 5) {
if !message.isOutgoing {
Text(message.senderName)
.font(.caption)
.foregroundStyle(.secondary)
}
if let replyText = message.replyText {
Text(replyText)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 6)
}
Text(message.text)
.textSelection(.enabled)
if !message.reactions.isEmpty {
Text(message.reactions.map(\.emoji).joined(separator: " "))
.font(.caption)
}
}
.padding(10)
.background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
if !message.isOutgoing {
Spacer(minLength: 48)
}
}
}
}
public struct ComposeBar: View {
@Binding public var text: String
public var send: () -> Void
public init(text: Binding<String>, send: @escaping () -> Void) {
_text = text
self.send = send
}
public var body: some View {
HStack(spacing: 10) {
TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
Button(action: send) {
Image(systemName: "paperplane.fill")
}
.buttonStyle(.borderedProminent)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding()
.background(.bar)
}
}
public struct ProfileView: View {
@ObservedObject public var viewModel: ProfileViewModel
public init(viewModel: ProfileViewModel) {
self.viewModel = viewModel
}
public var body: some View {
NavigationStack {
List {
if let profile = viewModel.profile {
Section {
Text(profile.title)
.font(.title2)
if let username = profile.username {
Text("@\(username)")
.foregroundStyle(.secondary)
}
if let bio = profile.bio {
Text(bio)
}
}
if let memberCount = profile.memberCount {
Section {
Text("\(memberCount) members")
}
}
} else {
ProgressView()
}
}
.navigationTitle("Profile")
}
}
}
public struct AccountSwitcherView: View {
public init() {}
public var body: some View {
NavigationStack {
List {
Section {
Label("Default", systemImage: "person.crop.circle")
Label("Add account", systemImage: "plus.circle")
}
}
.navigationTitle("Accounts")
}
}
}