7f6e35908c
- 移除自定义 UI 字体,界面统一系统原生:删 BrandFonts.swift(Orbit/Maple woff2 运行期 CTFontManager 注册 + UIKit 外观代理)+ AppDelegate 的 register 调用 + CDropApp 全局 .environment(\.font, .oBody);9 视图 .font(.o语义) → .font(.语义) 系统语义(日志 LogView 消息行本就 .caption.monospaced() 原生等宽);删 Sources/Fonts/(Orbit/Maple woff2 ~20MB)。仅留品牌字标 Fredoka(logo Wordmark),Justfile ios-fonts 改 Fredoka-only、.gitignore 收窄、新增 Shared/Fonts/README.md - iPad 适配:RootView TabView 加 .tabViewStyle(.sidebarAdaptable)(iPad 常规宽度侧边栏 / iPhone 底部标签栏)+ ConversationView 消息线程 maxWidth 680 居中;project.yml 补方向键 UISupportedInterfaceOrientations(iPhone 竖 + 横)/ ~ipad(全 4 向),消除 iPad 多任务方向构建告警 - 登录页内容约束 maxWidth 420 居中,避免 iPad 宽屏主登录按钮 / 二维码区横向拉满(iPhone 不受影响) - 文档:ios/PLAN.md §11.2 改系统原生字体 + 新增 §13 iPad;ios/PARITY.md 字体分叉条目回退 + 导航条目加 iPad + 2026-07-07 漂移行 - 已装 iPhone 15 Pro C + iPad Pro 13" M4 双端、用户确认
244 lines
9.4 KiB
Swift
244 lines
9.4 KiB
Swift
import SwiftUI
|
||
|
||
// 消息(按设备聚合,社交软件式):会话列表(每个对端设备一行)→ 点进 ConversationView 聊天线程。
|
||
// 会话由引擎 conversations() 合成——有消息历史的设备 ∪ 当前可达设备(在线 / 可唤醒,供发起新会话)。
|
||
// 可达设备可发(在线即时达、离线 iOS 走 APNs 唤醒);不可达的离线设备(离线桌面 / 浏览器)仅可查看
|
||
// 历史、撰写栏禁用。删除:单条消息(线程内长按)+ 整段会话(列表滑动 / 长按)——避免废会话堆积。
|
||
struct MessagesView: View
|
||
{
|
||
@Environment(EngineController.self) private var engine
|
||
@State private var showClearConfirm = false
|
||
|
||
var body: some View
|
||
{
|
||
Group
|
||
{
|
||
let convos = engine.conversations()
|
||
if convos.isEmpty
|
||
{
|
||
ContentUnavailableView(t("ios.messages.empty"), systemImage: "message")
|
||
}
|
||
else
|
||
{
|
||
List
|
||
{
|
||
ForEach(convos)
|
||
{ convo in
|
||
NavigationLink(value: convo.peerName)
|
||
{
|
||
conversationRow(convo)
|
||
}
|
||
// 长条行统一:滑动 + 长按都能删整段会话(清掉该设备全部消息记录)。
|
||
.swipeActions(edge: .trailing)
|
||
{
|
||
Button(role: .destructive) { engine.deleteConversation(convo.peerName) }
|
||
label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") }
|
||
}
|
||
.contextMenu
|
||
{
|
||
Button(role: .destructive) { engine.deleteConversation(convo.peerName) }
|
||
label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") }
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
}
|
||
}
|
||
.navigationTitle(t("ios.tab.messages"))
|
||
.navigationDestination(for: String.self) { peer in ConversationView(peerName: peer) }
|
||
.toolbar
|
||
{
|
||
if !engine.messages.isEmpty
|
||
{
|
||
ToolbarItem(placement: .topBarTrailing)
|
||
{
|
||
Button(role: .destructive) { showClearConfirm = true }
|
||
label: { Label(t("ios.records.clear"), systemImage: "trash") }
|
||
}
|
||
}
|
||
}
|
||
.confirmationDialog(t("ios.records.clearConfirm"), isPresented: $showClearConfirm, titleVisibility: .visible)
|
||
{
|
||
Button(t("ios.records.clear"), role: .destructive) { engine.clearMessages() }
|
||
Button(t("common.cancel"), role: .cancel) { }
|
||
}
|
||
}
|
||
|
||
private func conversationRow(_ convo: ConversationItem) -> some View
|
||
{
|
||
HStack(spacing: 12)
|
||
{
|
||
Image(systemName: deviceSymbol(convo.deviceType))
|
||
.font(.title3)
|
||
.foregroundStyle(convo.online ? Color.green : Color.secondary)
|
||
.frame(width: 28)
|
||
VStack(alignment: .leading, spacing: 3)
|
||
{
|
||
Text(convo.peerName)
|
||
.font(.body)
|
||
.lineLimit(1)
|
||
Text(previewText(convo))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
Spacer()
|
||
VStack(alignment: .trailing, spacing: 5)
|
||
{
|
||
if convo.lastAt > 0
|
||
{
|
||
Text(relativeTime(convo.lastAt))
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
// 该设备未读红点(社交软件式逐设备计数);消息 tab 徽标=各设备未读总和(engine.unreadMessages)。
|
||
let unread = engine.unread(for: convo.peerName)
|
||
if unread > 0
|
||
{
|
||
Text("\(unread)")
|
||
.font(.caption2)
|
||
.fontWeight(.bold)
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 2)
|
||
.background(Color.cdropAccent, in: Capsule())
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
// 会话副行预览:有历史显示最近一条(本端发出的加「你:」前缀);无历史(仅可达)显示发起提示。
|
||
private func previewText(_ convo: ConversationItem) -> String
|
||
{
|
||
if convo.lastText.isEmpty
|
||
{
|
||
return convo.reachable ? t("ios.messages.startHint") : t("ios.messages.viewOnly")
|
||
}
|
||
return convo.lastOutgoing ? "\(t("ios.messages.youPrefix"))\(convo.lastText)" : convo.lastText
|
||
}
|
||
}
|
||
|
||
// 单个设备的聊天线程:自上而下旧→新 + 底部撰写栏。可达设备(在线 / 可唤醒)才显示撰写栏;
|
||
// 不可达离线设备仅查看历史、底部显示「仅查看」提示。长按消息可删单条。
|
||
struct ConversationView: View
|
||
{
|
||
@Environment(EngineController.self) private var engine
|
||
let peerName: String
|
||
@State private var draft = ""
|
||
|
||
private var thread: [MessageItem] { engine.thread(with: peerName) }
|
||
private var reachable: Bool { engine.canMessage(peerName) }
|
||
|
||
var body: some View
|
||
{
|
||
VStack(spacing: 0)
|
||
{
|
||
if thread.isEmpty
|
||
{
|
||
ContentUnavailableView(t("ios.messages.empty"), systemImage: "message")
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
else
|
||
{
|
||
ScrollViewReader
|
||
{ proxy in
|
||
ScrollView
|
||
{
|
||
LazyVStack(alignment: .leading, spacing: 10)
|
||
{
|
||
ForEach(thread)
|
||
{ msg in
|
||
messageRow(msg)
|
||
.id(msg.id)
|
||
.contextMenu
|
||
{
|
||
Button(role: .destructive) { engine.deleteMessage(msg.id) }
|
||
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
// iPad:把气泡列约束到可读宽度并居中,避免在宽内容区里横向拉满(呼应 iMessage)。
|
||
.frame(maxWidth: 680)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.onChange(of: thread.count) { scrollToLast(proxy) }
|
||
.onAppear { scrollToLast(proxy) }
|
||
}
|
||
}
|
||
composeBar
|
||
}
|
||
.navigationTitle(peerName)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
// 进入会话:记为当前会话(其后到达的消息不计未读)+ 清该设备已积累未读;离开置空。
|
||
.onAppear
|
||
{
|
||
engine.setActiveConversation(peerName)
|
||
engine.markConversationRead(peerName)
|
||
}
|
||
.onDisappear { engine.setActiveConversation(nil) }
|
||
}
|
||
|
||
private func scrollToLast(_ proxy: ScrollViewProxy)
|
||
{
|
||
guard let last = thread.last else { return }
|
||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||
}
|
||
|
||
private func messageRow(_ msg: MessageItem) -> some View
|
||
{
|
||
let outgoing = msg.direction == "outgoing"
|
||
return HStack
|
||
{
|
||
if outgoing { Spacer(minLength: 48) }
|
||
Text(msg.text)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.background(outgoing ? Color.cdropAccent.opacity(0.18) : Color(.secondarySystemBackground),
|
||
in: RoundedRectangle(cornerRadius: 14))
|
||
.textSelection(.enabled)
|
||
if !outgoing { Spacer(minLength: 48) }
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var composeBar: some View
|
||
{
|
||
if reachable
|
||
{
|
||
HStack(spacing: 10)
|
||
{
|
||
TextField(t("ios.messages.placeholder"), text: $draft, axis: .vertical)
|
||
.textFieldStyle(.roundedBorder)
|
||
.lineLimit(1 ... 4)
|
||
.submitLabel(.send)
|
||
.onSubmit { send() }
|
||
Button { send() }
|
||
label: { Image(systemName: "paperplane.fill").font(.title3) }
|
||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
}
|
||
.padding()
|
||
.background(.bar)
|
||
}
|
||
else
|
||
{
|
||
Text(t("ios.messages.viewOnly"))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
.padding()
|
||
.background(.bar)
|
||
}
|
||
}
|
||
|
||
private func send()
|
||
{
|
||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else { return }
|
||
engine.sendMessage(to: peerName, text: text)
|
||
draft = ""
|
||
}
|
||
}
|