Files
Commilitia-Drop/ios/CDrop/Sources/MessagesView.swift
T
admin 7f6e35908c iOS 界面字体回退系统原生 + iPad 适配 + 登录列宽
- 移除自定义 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 双端、用户确认
2026-07-07 20:49:06 +08:00

244 lines
9.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = ""
}
}