Compare commits
3 Commits
cc32d7acad
...
ed91efacf5
| Author | SHA1 | Date | |
|---|---|---|---|
| ed91efacf5 | |||
| 3658d5a963 | |||
| a6f399bb08 |
51
yobble/Components/NewMessageBannerView.swift
Normal file
51
yobble/Components/NewMessageBannerView.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct IncomingMessageBanner: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let message: MessageItem
|
||||||
|
let senderName: String
|
||||||
|
let messagePreview: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewMessageBannerView: View {
|
||||||
|
let banner: IncomingMessageBanner
|
||||||
|
let onOpen: () -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.imageScale(.medium)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(banner.senderName)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(banner.messagePreview)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Button(action: onDismiss) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.secondary.opacity(0.15), in: Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
.shadow(color: Color.black.opacity(0.12), radius: 12, y: 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ struct TopBarView: View {
|
|||||||
var accounts: [String]
|
var accounts: [String]
|
||||||
// var viewModel: LoginViewModel
|
// var viewModel: LoginViewModel
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
|
@Binding var isSettingsPresented: Bool
|
||||||
|
|
||||||
// Привязка для управления боковым меню
|
// Привязка для управления боковым меню
|
||||||
@Binding var isSideMenuPresented: Bool
|
@Binding var isSideMenuPresented: Bool
|
||||||
@ -101,7 +102,9 @@ struct TopBarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isProfileTab {
|
} else if isProfileTab {
|
||||||
NavigationLink(destination: SettingsView(viewModel: viewModel)) {
|
NavigationLink(isActive: $isSettingsPresented) {
|
||||||
|
SettingsView(viewModel: viewModel)
|
||||||
|
} label: {
|
||||||
Image(systemName: "wrench")
|
Image(systemName: "wrench")
|
||||||
.imageScale(.large)
|
.imageScale(.large)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
@ -213,6 +216,7 @@ struct TopBarView_Previews: PreviewProvider {
|
|||||||
@State private var revealProgress: CGFloat = 1
|
@State private var revealProgress: CGFloat = 1
|
||||||
@StateObject private var viewModel = LoginViewModel()
|
@StateObject private var viewModel = LoginViewModel()
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
|
@State private var isSettingsPresented = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TopBarView(
|
TopBarView(
|
||||||
@ -220,6 +224,7 @@ struct TopBarView_Previews: PreviewProvider {
|
|||||||
selectedAccount: $selectedAccount,
|
selectedAccount: $selectedAccount,
|
||||||
accounts: [selectedAccount],
|
accounts: [selectedAccount],
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
|
isSettingsPresented: $isSettingsPresented,
|
||||||
isSideMenuPresented: $isSideMenuPresented,
|
isSideMenuPresented: $isSideMenuPresented,
|
||||||
chatSearchRevealProgress: $revealProgress,
|
chatSearchRevealProgress: $revealProgress,
|
||||||
chatSearchText: $searchText
|
chatSearchText: $searchText
|
||||||
|
|||||||
@ -195,6 +195,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Аудио" : {
|
||||||
|
"comment" : "Audio message placeholder"
|
||||||
|
},
|
||||||
"Без звука (скоро)" : {
|
"Без звука (скоро)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -232,6 +235,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Видео" : {
|
||||||
|
"comment" : "Video message placeholder"
|
||||||
|
},
|
||||||
"Видимость и контент" : {
|
"Видимость и контент" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -524,6 +530,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Изображение" : {
|
||||||
|
"comment" : "Image message placeholder"
|
||||||
|
},
|
||||||
"Инвайт-код (необязательно)" : {
|
"Инвайт-код (необязательно)" : {
|
||||||
"comment" : "Инвайт-код",
|
"comment" : "Инвайт-код",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1141,6 +1150,9 @@
|
|||||||
"Ничего не найдено" : {
|
"Ничего не найдено" : {
|
||||||
"comment" : "Global search empty state"
|
"comment" : "Global search empty state"
|
||||||
},
|
},
|
||||||
|
"Новое сообщение" : {
|
||||||
|
"comment" : "Default banner subtitle"
|
||||||
|
},
|
||||||
"Новый пароль" : {
|
"Новый пароль" : {
|
||||||
"comment" : "Новый пароль",
|
"comment" : "Новый пароль",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
105
yobble/Services/IncomingMessageCenter.swift
Normal file
105
yobble/Services/IncomingMessageCenter.swift
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
final class IncomingMessageCenter: ObservableObject {
|
||||||
|
@Published private(set) var banner: IncomingMessageBanner?
|
||||||
|
@Published var presentedChat: PrivateChatListItem?
|
||||||
|
var currentUserId: String?
|
||||||
|
|
||||||
|
private var dismissWorkItem: DispatchWorkItem?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private let notificationCenter: NotificationCenter
|
||||||
|
|
||||||
|
init(notificationCenter: NotificationCenter = .default) {
|
||||||
|
self.notificationCenter = notificationCenter
|
||||||
|
notificationCenter.publisher(for: .socketDidReceivePrivateMessage)
|
||||||
|
.compactMap { $0.object as? MessageItem }
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] message in
|
||||||
|
self?.handleIncoming(message)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissBanner() {
|
||||||
|
dismissWorkItem?.cancel()
|
||||||
|
dismissWorkItem = nil
|
||||||
|
banner = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openCurrentChat() {
|
||||||
|
guard let banner else { return }
|
||||||
|
presentedChat = makeChatItem(from: banner.message)
|
||||||
|
dismissBanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleIncoming(_ message: MessageItem) {
|
||||||
|
if let currentUserId, message.senderId == currentUserId {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
banner = buildBanner(from: message)
|
||||||
|
scheduleDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildBanner(from message: MessageItem) -> IncomingMessageBanner {
|
||||||
|
let sender = senderName(for: message)
|
||||||
|
let preview = messagePreview(for: message)
|
||||||
|
return IncomingMessageBanner(message: message, senderName: sender, messagePreview: preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func senderName(for message: MessageItem) -> String {
|
||||||
|
let candidates: [String?] = [
|
||||||
|
message.senderData?.customName,
|
||||||
|
message.senderData?.fullName,
|
||||||
|
message.senderData?.login.map { "@\($0)" }
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.senderId
|
||||||
|
}
|
||||||
|
|
||||||
|
private func messagePreview(for message: MessageItem) -> String {
|
||||||
|
if let content = message.content?.trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
switch message.messageType.lowercased() {
|
||||||
|
case "image":
|
||||||
|
return NSLocalizedString("Изображение", comment: "Image message placeholder")
|
||||||
|
case "audio":
|
||||||
|
return NSLocalizedString("Аудио", comment: "Audio message placeholder")
|
||||||
|
case "video":
|
||||||
|
return NSLocalizedString("Видео", comment: "Video message placeholder")
|
||||||
|
default:
|
||||||
|
return NSLocalizedString("Новое сообщение", comment: "Default banner subtitle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChatItem(from message: MessageItem) -> PrivateChatListItem {
|
||||||
|
let profile = message.senderData
|
||||||
|
return PrivateChatListItem(
|
||||||
|
chatId: message.chatId,
|
||||||
|
chatType: .privateChat,
|
||||||
|
chatData: profile,
|
||||||
|
lastMessage: message,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
unreadCount: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleDismiss(after delay: TimeInterval = 5) {
|
||||||
|
dismissWorkItem?.cancel()
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
self?.banner = nil
|
||||||
|
self?.dismissWorkItem = nil
|
||||||
|
}
|
||||||
|
dismissWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,12 @@ final class SocketService {
|
|||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var newPrivateMessagePublisher: AnyPublisher<MessageItem, Never> {
|
||||||
|
privateMessageSubject
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
var currentConnectionState: ConnectionState {
|
var currentConnectionState: ConnectionState {
|
||||||
connectionStateSubject.value
|
connectionStateSubject.value
|
||||||
}
|
}
|
||||||
@ -42,6 +48,7 @@ final class SocketService {
|
|||||||
private var lastHeartbeatSentAt: Date?
|
private var lastHeartbeatSentAt: Date?
|
||||||
private var consecutiveHeartbeatMisses = 0
|
private var consecutiveHeartbeatMisses = 0
|
||||||
private var reconnectWorkItem: DispatchWorkItem?
|
private var reconnectWorkItem: DispatchWorkItem?
|
||||||
|
private let privateMessageSubject = PassthroughSubject<MessageItem, Never>()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@ -204,13 +211,17 @@ final class SocketService {
|
|||||||
socket.on(clientEvent: .disconnect) { data, _ in
|
socket.on(clientEvent: .disconnect) { data, _ in
|
||||||
if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
|
if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
|
||||||
self.updateConnectionState(.disconnected)
|
self.updateConnectionState(.disconnected)
|
||||||
self.scheduleReconnect()
|
if self.currentToken != nil {
|
||||||
|
self.scheduleReconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on(clientEvent: .error) { data, _ in
|
socket.on(clientEvent: .error) { data, _ in
|
||||||
if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
|
if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
|
||||||
self.updateConnectionState(.disconnected)
|
self.updateConnectionState(.disconnected)
|
||||||
self.scheduleReconnect()
|
if self.currentToken != nil {
|
||||||
|
self.scheduleReconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on("pong") { [weak self] _, _ in
|
socket.on("pong") { [weak self] _, _ in
|
||||||
@ -221,6 +232,10 @@ final class SocketService {
|
|||||||
self?.handleMessageEvent(data)
|
self?.handleMessageEvent(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
socket.on("chat_private:new_message") { [weak self] data, _ in
|
||||||
|
self?.handleNewPrivateMessage(data)
|
||||||
|
}
|
||||||
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.socket = socket
|
self.socket = socket
|
||||||
}
|
}
|
||||||
@ -322,6 +337,42 @@ final class SocketService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleNewPrivateMessage(_ data: [Any]) {
|
||||||
|
guard let payload = data.first else { return }
|
||||||
|
|
||||||
|
let messageData: Data
|
||||||
|
if let dictionary = payload as? [String: Any],
|
||||||
|
JSONSerialization.isValidJSONObject(dictionary),
|
||||||
|
let json = try? JSONSerialization.data(withJSONObject: dictionary, options: []) {
|
||||||
|
messageData = json
|
||||||
|
} else if let string = payload as? String,
|
||||||
|
let data = string.data(using: .utf8) {
|
||||||
|
messageData = data
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
decoder.dateDecodingStrategy = .custom(Self.decodeServerDate)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let message = try decoder.decode(MessageItem.self, from: messageData)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: .socketDidReceivePrivateMessage, object: message)
|
||||||
|
self.privateMessageSubject.send(message)
|
||||||
|
NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if AppConfig.DEBUG {
|
||||||
|
print("[SocketService] Failed to decode new message: \(error)")
|
||||||
|
if let payloadString = String(data: messageData, encoding: .utf8) {
|
||||||
|
print("[SocketService] payload=\(payloadString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleHeartbeatSuccess() {
|
private func handleHeartbeatSuccess() {
|
||||||
consecutiveHeartbeatMisses = 0
|
consecutiveHeartbeatMisses = 0
|
||||||
heartbeatAckInFlight = false
|
heartbeatAckInFlight = false
|
||||||
@ -367,7 +418,38 @@ final class SocketService {
|
|||||||
reconnectWorkItem?.cancel()
|
reconnectWorkItem?.cancel()
|
||||||
reconnectWorkItem = nil
|
reconnectWorkItem = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func decodeServerDate(from decoder: Decoder) throws -> Date {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let string = try container.decode(String.self)
|
||||||
|
if let date = iso8601WithFractionalSeconds.date(from: string) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
if let date = iso8601Simple.date(from: string) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
in: container,
|
||||||
|
debugDescription: "Unable to decode date: \(string)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let iso8601Simple: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
#else
|
#else
|
||||||
private func disconnectInternal() { }
|
private func disconnectInternal() { }
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let socketDidReceivePrivateMessage = Notification.Name("socketDidReceivePrivateMessage")
|
||||||
|
}
|
||||||
|
|||||||
@ -15,12 +15,32 @@ final class PrivateChatViewModel: ObservableObject {
|
|||||||
private let maxMessageLength: Int = 4096
|
private let maxMessageLength: Int = 4096
|
||||||
private var hasMore: Bool = true
|
private var hasMore: Bool = true
|
||||||
private var didLoadInitially: Bool = false
|
private var didLoadInitially: Bool = false
|
||||||
|
private var messageObserver: NSObjectProtocol?
|
||||||
|
|
||||||
init(chatId: String, currentUserId: String?, chatService: ChatService = ChatService(), pageSize: Int = 30) {
|
init(chatId: String, currentUserId: String?, chatService: ChatService = ChatService(), pageSize: Int = 30) {
|
||||||
self.chatId = chatId
|
self.chatId = chatId
|
||||||
self.currentUserId = currentUserId
|
self.currentUserId = currentUserId
|
||||||
self.chatService = chatService
|
self.chatService = chatService
|
||||||
self.pageSize = pageSize
|
self.pageSize = pageSize
|
||||||
|
|
||||||
|
messageObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .socketDidReceivePrivateMessage,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
guard
|
||||||
|
let self,
|
||||||
|
let message = notification.object as? MessageItem,
|
||||||
|
message.chatId == self.chatId
|
||||||
|
else { return }
|
||||||
|
self.messages = Self.merge(existing: self.messages, newMessages: [message])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let observer = messageObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadInitialHistory(force: Bool = false) {
|
func loadInitialHistory(force: Bool = false) {
|
||||||
|
|||||||
@ -38,7 +38,11 @@ struct ChatsTab: View {
|
|||||||
return userId.isEmpty ? nil : userId
|
return userId.isEmpty ? nil : userId
|
||||||
}
|
}
|
||||||
|
|
||||||
init(loginViewModel: LoginViewModel, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
|
init(
|
||||||
|
loginViewModel: LoginViewModel,
|
||||||
|
searchRevealProgress: Binding<CGFloat>,
|
||||||
|
searchText: Binding<String>
|
||||||
|
) {
|
||||||
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
|
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
|
||||||
self._searchRevealProgress = searchRevealProgress
|
self._searchRevealProgress = searchRevealProgress
|
||||||
self._searchText = searchText
|
self._searchText = searchText
|
||||||
@ -52,9 +56,15 @@ struct ChatsTab: View {
|
|||||||
handleSearchQueryChange(searchText)
|
handleSearchQueryChange(searchText)
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
|
||||||
|
if loginViewModel.chatLoadingState != .loading {
|
||||||
|
loginViewModel.chatLoadingState = .loading
|
||||||
|
}
|
||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in
|
||||||
|
if loginViewModel.chatLoadingState != .loading {
|
||||||
|
loginViewModel.chatLoadingState = .loading
|
||||||
|
}
|
||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
}
|
}
|
||||||
.onChange(of: searchText) { newValue in
|
.onChange(of: searchText) { newValue in
|
||||||
@ -116,11 +126,11 @@ struct ChatsTab: View {
|
|||||||
Text(message)
|
Text(message)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
// Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
// Button(action: triggerChatsReload) {
|
Button(action: triggerChatsReload) {
|
||||||
// Text(NSLocalizedString("Обновить", comment: ""))
|
Text(NSLocalizedString("Обновить", comment: ""))
|
||||||
// .font(.subheadline)
|
.font(.subheadline)
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,8 @@ struct MainView: View {
|
|||||||
@State private var menuOffset: CGFloat = 0
|
@State private var menuOffset: CGFloat = 0
|
||||||
@State private var chatSearchRevealProgress: CGFloat = 0
|
@State private var chatSearchRevealProgress: CGFloat = 0
|
||||||
@State private var chatSearchText: String = ""
|
@State private var chatSearchText: String = ""
|
||||||
|
@State private var isSettingsPresented = false
|
||||||
|
|
||||||
private var tabTitle: String {
|
private var tabTitle: String {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case 0: return "Home"
|
case 0: return "Home"
|
||||||
@ -32,66 +33,69 @@ struct MainView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю
|
ZStack(alignment: .top) {
|
||||||
// Основной контент
|
ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю
|
||||||
VStack(spacing: 0) {
|
// Основной контент
|
||||||
TopBarView(
|
VStack(spacing: 0) {
|
||||||
title: tabTitle,
|
TopBarView(
|
||||||
selectedAccount: $selectedAccount,
|
title: tabTitle,
|
||||||
accounts: accounts,
|
selectedAccount: $selectedAccount,
|
||||||
viewModel: viewModel,
|
accounts: accounts,
|
||||||
isSideMenuPresented: $isSideMenuPresented,
|
viewModel: viewModel,
|
||||||
chatSearchRevealProgress: $chatSearchRevealProgress,
|
isSettingsPresented: $isSettingsPresented,
|
||||||
chatSearchText: $chatSearchText
|
isSideMenuPresented: $isSideMenuPresented,
|
||||||
)
|
chatSearchRevealProgress: $chatSearchRevealProgress,
|
||||||
|
chatSearchText: $chatSearchText
|
||||||
ZStack {
|
|
||||||
NewHomeTab()
|
|
||||||
.opacity(selectedTab == 0 ? 1 : 0)
|
|
||||||
|
|
||||||
ConceptTab()
|
|
||||||
.opacity(selectedTab == 1 ? 1 : 0)
|
|
||||||
|
|
||||||
ChatsTab(
|
|
||||||
loginViewModel: viewModel,
|
|
||||||
searchRevealProgress: $chatSearchRevealProgress,
|
|
||||||
searchText: $chatSearchText
|
|
||||||
)
|
)
|
||||||
.opacity(selectedTab == 2 ? 1 : 0)
|
|
||||||
.allowsHitTesting(selectedTab == 2)
|
|
||||||
|
|
||||||
ProfileTab()
|
ZStack {
|
||||||
.opacity(selectedTab == 3 ? 1 : 0)
|
NewHomeTab()
|
||||||
}
|
.opacity(selectedTab == 0 ? 1 : 0)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
|
ConceptTab()
|
||||||
CustomTabBar(selectedTab: $selectedTab) {
|
.opacity(selectedTab == 1 ? 1 : 0)
|
||||||
print("Create button tapped")
|
|
||||||
}
|
ChatsTab(
|
||||||
}
|
loginViewModel: viewModel,
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity) // Убедимся, что основной контент занимает все пространство
|
searchRevealProgress: $chatSearchRevealProgress,
|
||||||
.ignoresSafeArea(edges: .bottom)
|
searchText: $chatSearchText
|
||||||
.navigationBarHidden(true)
|
)
|
||||||
// .sheet(item: $sheetType) { type in
|
.opacity(selectedTab == 2 ? 1 : 0)
|
||||||
// // ... sheet presentation logic
|
.allowsHitTesting(selectedTab == 2)
|
||||||
// }
|
|
||||||
|
ProfileTab()
|
||||||
// Затемнение и закрытие по тапу
|
.opacity(selectedTab == 3 ? 1 : 0)
|
||||||
Color.black
|
}
|
||||||
.opacity(Double(menuOffset / menuWidth) * 0.4)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.ignoresSafeArea()
|
|
||||||
.onTapGesture {
|
CustomTabBar(selectedTab: $selectedTab) {
|
||||||
withAnimation(.easeInOut) {
|
print("Create button tapped")
|
||||||
isSideMenuPresented = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.allowsHitTesting(menuOffset > 0)
|
.frame(maxWidth: .infinity, maxHeight: .infinity) // Убедимся, что основной контент занимает все пространство
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
// .sheet(item: $sheetType) { type in
|
||||||
|
// // ... sheet presentation logic
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Затемнение и закрытие по тапу
|
||||||
|
Color.black
|
||||||
|
.opacity(Double(menuOffset / menuWidth) * 0.4)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.easeInOut) {
|
||||||
|
isSideMenuPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.allowsHitTesting(menuOffset > 0)
|
||||||
|
|
||||||
// Боковое меню
|
// Боковое меню
|
||||||
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
|
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
|
||||||
.frame(width: menuWidth)
|
.frame(width: menuWidth)
|
||||||
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
||||||
.ignoresSafeArea(edges: .vertical)
|
.ignoresSafeArea(edges: .vertical)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture()
|
DragGesture()
|
||||||
|
|||||||
@ -12,22 +12,60 @@ import CoreData
|
|||||||
struct yobbleApp: App {
|
struct yobbleApp: App {
|
||||||
@StateObject private var themeManager = ThemeManager()
|
@StateObject private var themeManager = ThemeManager()
|
||||||
@StateObject private var viewModel = LoginViewModel()
|
@StateObject private var viewModel = LoginViewModel()
|
||||||
|
@StateObject private var messageCenter = IncomingMessageCenter()
|
||||||
private let persistenceController = PersistenceController.shared
|
private let persistenceController = PersistenceController.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
ZStack(alignment: .top) {
|
||||||
if viewModel.isLoading {
|
Group {
|
||||||
SplashScreenView()
|
if viewModel.isLoading {
|
||||||
} else if viewModel.isLoggedIn {
|
SplashScreenView()
|
||||||
MainView(viewModel: viewModel)
|
} else if viewModel.isLoggedIn {
|
||||||
} else {
|
MainView(viewModel: viewModel)
|
||||||
LoginView(viewModel: viewModel)
|
} else {
|
||||||
|
LoginView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let banner = messageCenter.banner {
|
||||||
|
NewMessageBannerView(
|
||||||
|
banner: banner,
|
||||||
|
onOpen: { messageCenter.openCurrentChat() },
|
||||||
|
onDismiss: { messageCenter.dismissBanner() }
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
.zIndex(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
|
||||||
|
.sheet(item: $messageCenter.presentedChat) { chatItem in
|
||||||
|
NavigationView {
|
||||||
|
PrivateChatView(
|
||||||
|
chat: chatItem,
|
||||||
|
currentUserId: messageCenter.currentUserId
|
||||||
|
)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(NSLocalizedString("Закрыть", comment: "")) {
|
||||||
|
messageCenter.presentedChat = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environmentObject(messageCenter)
|
||||||
.environmentObject(themeManager)
|
.environmentObject(themeManager)
|
||||||
.preferredColorScheme(themeManager.theme.colorScheme)
|
.preferredColorScheme(themeManager.theme.colorScheme)
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||||
|
.onAppear {
|
||||||
|
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.userId) { newValue in
|
||||||
|
messageCenter.currentUserId = newValue.isEmpty ? nil : newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user