0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct WatchChatListView: View {
|
||||
@ObservedObject var model: WatchSessionModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if model.chats.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Нет чатов",
|
||||
systemImage: "iphone.and.arrow.forward",
|
||||
description: Text("Откройте Luma на iPhone для синхронизации.")
|
||||
)
|
||||
} else {
|
||||
List(model.chats) { chat in
|
||||
NavigationLink(value: chat) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(chat.name).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
if chat.unread > 0 {
|
||||
Text("\(chat.unread)")
|
||||
.font(.caption2.bold())
|
||||
.padding(5)
|
||||
.background(.blue, in: Circle())
|
||||
}
|
||||
}
|
||||
Text(chat.messages.last?.body ?? chat.jid)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Luma")
|
||||
.navigationDestination(for: WatchSnapshot.Chat.self) { chat in
|
||||
WatchChatView(model: model, chat: chat)
|
||||
}
|
||||
}
|
||||
.alert("Luma", isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK") { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "Ошибка")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchChatView: View {
|
||||
@ObservedObject var model: WatchSessionModel
|
||||
let chat: WatchSnapshot.Chat
|
||||
@StateObject private var voiceRecorder = WatchVoiceRecorder()
|
||||
@State private var reply = ""
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(chat.messages) { message in
|
||||
HStack {
|
||||
if message.outgoing { Spacer(minLength: 16) }
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
if message.encrypted {
|
||||
Image(systemName: "lock.fill").font(.caption2)
|
||||
}
|
||||
Text(message.body)
|
||||
}
|
||||
Text(message.timestamp, format: .dateTime.hour().minute())
|
||||
.font(.caption2)
|
||||
.opacity(0.7)
|
||||
}
|
||||
.padding(7)
|
||||
.background(message.outgoing ? Color.blue : Color.secondary.opacity(0.25), in: RoundedRectangle(cornerRadius: 11))
|
||||
if !message.outgoing { Spacer(minLength: 16) }
|
||||
}
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Ответ", text: $reply)
|
||||
Button("Отправить") {
|
||||
model.send(text: reply, to: chat.jid)
|
||||
reply = ""
|
||||
}
|
||||
.disabled(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
Section("Голосовое") {
|
||||
voiceComposer
|
||||
|
||||
if let status = model.voiceStatus(for: chat.jid) {
|
||||
Label(status, systemImage: "iphone.and.arrow.forward")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Быстрый ответ") {
|
||||
ForEach(["Хорошо", "Спасибо!", "Отвечу позже"], id: \.self) { text in
|
||||
Button(text) { model.send(text: text, to: chat.jid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(chat.name)
|
||||
.onAppear {
|
||||
if let id = chat.messages.last?.id { proxy.scrollTo(id, anchor: .bottom) }
|
||||
}
|
||||
.onDisappear {
|
||||
voiceRecorder.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var voiceComposer: some View {
|
||||
if voiceRecorder.isRecording {
|
||||
VStack(spacing: 8) {
|
||||
Label(
|
||||
formatDuration(voiceRecorder.elapsed),
|
||||
systemImage: "waveform"
|
||||
)
|
||||
.font(.headline.monospacedDigit())
|
||||
.foregroundStyle(.red)
|
||||
|
||||
ProgressView(
|
||||
value: voiceRecorder.elapsed,
|
||||
total: WatchVoiceRecorder.maximumDuration
|
||||
)
|
||||
.tint(.red)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(role: .destructive) {
|
||||
voiceRecorder.cancelRecording()
|
||||
} label: {
|
||||
Label("Отмена", systemImage: "xmark")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
Button {
|
||||
finishVoiceRecording()
|
||||
} label: {
|
||||
Label("Готово", systemImage: "stop.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
} else if let recording = voiceRecorder.recording {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Button {
|
||||
do {
|
||||
try voiceRecorder.togglePlayback()
|
||||
} catch {
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: voiceRecorder.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel(voiceRecorder.isPlaying ? "Пауза" : "Прослушать")
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Предпрослушивание")
|
||||
.font(.caption)
|
||||
Text(formatDuration(recording.duration))
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
if model.sendVoice(
|
||||
fileURL: recording.url,
|
||||
duration: recording.duration,
|
||||
to: chat.jid
|
||||
) {
|
||||
_ = voiceRecorder.detachRecording()
|
||||
}
|
||||
} label: {
|
||||
Label("Отправить голосовое", systemImage: "paperplane.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(role: .destructive) {
|
||||
voiceRecorder.discardRecording()
|
||||
} label: {
|
||||
Label("Удалить запись", systemImage: "trash")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
try await voiceRecorder.start()
|
||||
} catch {
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Записать голосовое", systemImage: "mic.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishVoiceRecording() {
|
||||
do {
|
||||
try voiceRecorder.finish()
|
||||
} catch {
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(_ value: TimeInterval) -> String {
|
||||
let seconds = max(0, Int(value.rounded(.down)))
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user