Files
Commilitia-Drop/ios/CDrop/Sources/RootView.swift
T
admin 349d94aa8f iOS 原生客户端:libwebrtc 数据面 + UX 改造 + 品牌/字体 + 全平台平均速度
- 原生数据面 libwebrtc(PLAN 决策 F):新增 Engine/LibWebRtcEngine.swift(RTCPeerConnection,端口 engine.go/session.go 线协议 + 水位背压 + ack 完成 + 不冲突落盘);NativeTransfer.swift 的 P2PEventsBridge 改 conform P2PEngineDelegate(去 gomobile);EngineController 4 个 p2p RPC 改调新引擎(completion-based + resolveOnMain)。stasel/WebRTC M149 经 SPM(exactVersion 149.0.0)。环回端到端 20MB 逐字节测过(Tests/)
- UX 改造(PARITY 残余项 1/2):新增 SendComposeSheet(发送预览=文件名 + 选设备,手动多选与分享共用);分享直入预览 + scenePhase .active 兜底(修“分享后必须重启 app”);重写 MessagesView(按设备会话 + 每设备未读红点 + 删单条 / 删整设备历史);EngineController 加 conversations / unread 模型
- 品牌 logo:新增 Shared/Brand.swift(BrandMark / Wordmark / BrandLockup / BrandHero)+ Brand.xcassets/LogoMark;登录页 / 传输空态 / 设置头 / 控制中心控件接 logo;Theme 加 cdropAmber
- 字体(对齐 web,仅主 app):新增 BrandFonts.swift(Orbit Gothic + Maple Mono woff2 运行期 CTFontManager 注册 + UIKit 外观代理 + .oXxx / .maple 助手);Fredoka SemiBold 字标;7 视图 .font sweep。字体不入库——.gitignore 排除 + just ios-fonts 构建前从 CDN / Google Fonts 拉取,见 Sources/Fonts/README.md
- 分享引导:新增 Share/ShareGuideView(吉祥物卡 + “打开 Commilitia Drop”);ShareViewController 健壮 staging(文件 / 数据双路)+ 响应链 IMP 唤起宿主
- 全平台平均速度:传输完成态显平均速度(总字节 / 总耗时)而非末次瞬时——web store/slices/transfer.ts + TransferRow.tsx + iOS RootView 卡片 / 详情;i18n 加 transfer.avgRate
- 工程:project.yml 接 SPM WebRTC + CDropTests + Fredoka UIAppFonts + Share i18n;Justfile 加 ios-sim-build + ios-fonts / ios-fonts-fredoka 配方
- 文档:ios/PLAN.md(决策 F + §10 UX + §11 品牌/字体 + §12 平均速度)、ios/PARITY.md(字体分叉条目更新 + 2026-06-28 漂移行)
2026-06-28 19:33:08 +08:00

952 lines
38 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 PhotosUI
import SwiftUI
import UIKit
import UniformTypeIdentifiers
// + iOS 26 SDK WebView
//
// ios/PLAN.md §8.1 i18nt()web catalog /
// presence / EngineControllerarch A
struct RootView: View
{
@Environment(EngineController.self) private var engine
// auth AppRoot .environment(auth) EngineWebView.makeUIView
// boot missing injected session#11
@Environment(AuthManager.self) private var auth
// CDROP_TAB
@State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer"
var body: some View
{
TabView(selection: $selection)
{
// #1
// / #5
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
{
NavigationStack
{
TransferListView()
}
}
.badge(engine.unreadTransfers)
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
{
NavigationStack
{
ReceivedFilesView()
}
}
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
{
NavigationStack
{
MessagesView()
}
}
.badge(engine.unreadMessages)
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
{
NavigationStack
{
DeviceListView()
}
}
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
{
NavigationStack
{
SettingsView()
}
}
}
.tabBarMinimizeBehavior(.onScrollDown)
.tint(.cdropAccent)
// #2
.overlay(alignment: .top)
{
NoticeOverlay()
}
// #5
.onChange(of: selection) { engine.setActiveTab(selection) }
.background
{
EngineWebView(controller: engine, auth: auth)
.frame(width: 0, height: 0)
.opacity(0)
.allowsHitTesting(false)
}
// Share Extension cdrop://share /
// SendComposeSheetonSend sendShares
// move pendingShareFiles
.onAppear { engine.loadPendingShares() }
.sheet(isPresented: Binding(
get: { !engine.pendingShareFiles.isEmpty },
set: { if !$0 { engine.dismissShares() } }))
{
SendComposeSheet(
files: engine.pendingShareFiles.map { PreviewFile(name: $0.lastPathComponent, size: fileByteSize($0)) },
devices: engine.sendableDevices(),
sendLabel: engine.sendLabel,
onSend: { device in engine.sendShares(to: device) },
onCancel: { engine.dismissShares() })
}
}
}
struct TransferListView: View
{
@Environment(EngineController.self) private var engine
@State private var showImporter = false
@State private var showPhotoPicker = false
@State private var photoItems: [PhotosPickerItem] = []
// /
// SendComposeSheet
@State private var pickedURLs: [URL] = []
@State private var showCompose = false
@State private var showNoDevices = false
@State private var showClearConfirm = false
// List NavigationLink chevron
// navigationDestination(item:) sessionIdString Hashable
@State private var detailSession: String?
// 线 EngineController.sendableDevices
private var sendableDevices: [DeviceItem]
{
engine.sendableDevices()
}
var body: some View
{
Group
{
if engine.transfers.isEmpty && engine.history.isEmpty
{
// hero + + drop
BrandHero(tagline: t("ios.transfer.empty"))
}
else
{
transferList
}
}
.navigationTitle(t("ios.tab.transfer"))
.navigationDestination(item: $detailSession) { sid in TransferDetailView(sessionId: sid) }
.toolbar
{
if !engine.history.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.clearHistory() }
Button(t("common.cancel"), role: .cancel) { }
}
.overlay(alignment: .bottomTrailing)
{
// Files / Photos PhotosPicker
// #14
Menu
{
Button { showImporter = true }
label: { Label(t("ios.send.fromFiles"), systemImage: "folder") }
Button { showPhotoPicker = true }
label: { Label(t("ios.send.fromPhotos"), systemImage: "photo.on.rectangle") }
}
label:
{
Image(systemName: "paperplane.fill")
.font(.oTitle2)
.padding(18)
}
.buttonStyle(.glassProminent)
.padding(24)
}
// / SendComposeSheet
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ],
allowsMultipleSelection: true)
{ result in
if case .success(let urls) = result, !urls.isEmpty { present(urls) }
}
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItems,
matching: .any(of: [ .images, .videos ]))
.onChange(of: photoItems) { loadPhotos() }
.sheet(isPresented: $showCompose, onDismiss: clearPicked)
{
SendComposeSheet(
files: pickedURLs.map { PreviewFile(name: $0.lastPathComponent, size: fileByteSize($0)) },
devices: sendableDevices,
sendLabel: engine.sendLabel,
onSend: { device in sendPicked(to: device) },
onCancel: { showCompose = false })
}
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
// List ScrollView / +
// .plain + + 线 body
private var transferList: some View
{
List
{
if !engine.transfers.isEmpty
{
Section
{
ForEach(engine.transfers) { transferRow($0, history: false) }
}
header: { sectionHeader(t("home.transfer.active")) }
}
if !engine.history.isEmpty
{
Section
{
ForEach(engine.history) { transferRow($0, history: true) }
}
header: { sectionHeader(t("home.transfer.history")) }
}
}
.listStyle(.plain)
}
private func sectionHeader(_ title: String) -> some View
{
HStack
{
Text(title)
.font(.oSubheadline)
.foregroundStyle(.secondary)
Spacer()
}
}
// + /
//
// / transferDone
// history /
@ViewBuilder
private func transferRow(_ item: TransferItem, history: Bool) -> some View
{
let card = TransferCardView(item: item)
.contentShape(Rectangle())
.onTapGesture { detailSession = item.sessionId }
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
if history
{
card
.swipeActions(edge: .trailing)
{
Button(role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
.contextMenu
{
Button(role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
else
{
card
.swipeActions(edge: .trailing)
{
Button(role: .destructive) { engine.cancelTransfer(item.sessionId) }
label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") }
}
.contextMenu
{
Button(role: .destructive) { engine.cancelTransfer(item.sessionId) }
label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") }
if item.mode != "relay"
{
Button { engine.switchToRelay(item.sessionId) }
label: { Label(t("ios.transfer.forceRelay"),
systemImage: "antenna.radiowaves.left.and.right") }
}
}
}
}
// /
private func present(_ urls: [URL])
{
pickedURLs = urls
if sendableDevices.isEmpty { showNoDevices = true }
else { showCompose = true }
}
//
private func sendPicked(to device: String)
{
for url in pickedURLs { engine.sendFile(to: device, fileURL: url) }
showCompose = false
}
// / 使
private func clearPicked()
{
pickedURLs = []
photoItems = []
}
// #14
private func loadPhotos()
{
let items = photoItems
guard !items.isEmpty else { return }
Task
{
var urls: [URL] = []
for item in items
{
if let data = try? await item.loadTransferable(type: Data.self),
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
{
urls.append(url)
}
}
guard !urls.isEmpty else { return }
await MainActor.run { present(urls) }
}
}
// jpg mov
private func suggestedPhotoName(_ item: PhotosPickerItem) -> String
{
let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg"
return "photo-\(UUID().uuidString.prefix(8)).\(ext)"
}
}
// / / /
// transfer.* catalog
struct TransferCardView: View
{
let item: TransferItem
var body: some View
{
VStack(alignment: .leading, spacing: 8)
{
HStack(spacing: 6)
{
Image(systemName: item.direction == "incoming"
? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.secondary)
Text(item.fileName)
.font(.oHeadline)
.lineLimit(1)
Spacer()
}
if isActive(item)
{
ProgressView(value: progressOf(item))
}
Text(detailLine)
.font(.oCaption)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
//
private var detailLine: String
{
var parts: [String] = []
if isActive(item), let phase = item.phase
{
parts.append(t("transfer.phase.\(phase)"))
}
else
{
parts.append(t("transfer.state.\(item.state)"))
}
parts.append(formatBytes(item.fileSize))
if isActive(item), let rate = item.bytesPerSec, rate > 0
{
parts.append(t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
}
else if item.state == "DONE", let rate = item.bytesPerSec, rate > 0
{
// / store completeTransfer bytesPerSec
parts.append(t("transfer.avgRate", [ "rate": formatBytes(Int(rate)) ]))
}
return parts.joined(separator: " · ")
}
}
// TODO #5 sessionId /
// transfers history sessionId
struct TransferDetailView: View
{
@Environment(EngineController.self) private var engine
let sessionId: String
@State private var previewItem: PreviewItem?
private var item: TransferItem?
{
engine.transfers.first { $0.sessionId == sessionId }
?? engine.history.first { $0.sessionId == sessionId }
}
var body: some View
{
Group
{
if let item
{
List
{
// / O
if isActive(item)
{
Section
{
Button(role: .destructive) { engine.cancelTransfer(item.sessionId) }
label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") }
if item.mode != "relay"
{
Button { engine.switchToRelay(item.sessionId) }
label: { Label(t("ios.transfer.forceRelay"),
systemImage: "antenna.radiowaves.left.and.right") }
}
}
}
Section
{
LabeledContent(t("ios.detail.direction"),
value: item.direction == "incoming"
? t("ios.transfer.incoming") : t("ios.transfer.outgoing"))
LabeledContent(t("ios.detail.peer"), value: item.peerName)
LabeledContent(t("ios.detail.state"), value: t("transfer.state.\(item.state)"))
if let phase = item.phase
{
LabeledContent(t("ios.detail.phase"), value: t("transfer.phase.\(phase)"))
}
if let mode = item.mode, !mode.isEmpty
{
LabeledContent(t("ios.detail.channel"), value: t("transfer.mode.\(mode)"))
}
}
Section
{
LabeledContent(t("ios.detail.size"), value: formatBytes(item.fileSize))
if let sent = item.bytesTransferred
{
LabeledContent(t("ios.detail.progress"),
value: "\(formatBytes(sent)) / \(formatBytes(item.fileSize))")
}
if let rate = item.bytesPerSec, rate > 0
{
// store bytesPerSec
LabeledContent(t("ios.detail.speed"),
value: t(item.state == "DONE" ? "transfer.avgRate" : "transfer.bytesRate",
[ "rate": formatBytes(Int(rate)) ]))
}
ProgressView(value: progressOf(item))
}
//
if item.direction == "incoming", item.state == "DONE",
let url = engine.receivedFile(matching: item.fileName)
{
Section
{
Button { previewItem = PreviewItem(url: url) }
label: { Label(t("ios.detail.openFile"), systemImage: "doc") }
}
}
// ICE "relay" P2P TURN
if item.iceConn != nil || item.iceLocal != nil
{
Section
{
if let conn = item.iceConn
{
LabeledContent(t("transfer.debug.iceConnection"), value: conn)
}
if let local = item.iceLocal, let remote = item.iceRemote
{
LabeledContent(t("transfer.debug.selectedPair"), value: "\(local)\(remote)")
}
}
}
}
}
else
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
}
}
.navigationTitle(item?.fileName ?? t("ios.detail.title"))
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $previewItem) { item in FilePreviewSheet(item: item) }
}
}
struct DeviceListView: View
{
@Environment(EngineController.self) private var engine
@State private var revokeTarget: DeviceItem?
@State private var showRevoke = false
@State private var showScanner = false
//
private var allDevices: [DeviceItem]
{
engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) }
}
// device_idselfDeviceID
private func isSelf(_ dev: DeviceItem) -> Bool
{
let selfID = engine.selfDeviceID
if !selfID.isEmpty, !dev.deviceID.isEmpty { return dev.deviceID == selfID }
return dev.name == engine.deviceName
}
var body: some View
{
Group
{
if engine.devices.isEmpty
{
ContentUnavailableView(t("ios.devices.empty"), systemImage: "laptopcomputer.slash")
}
else
{
List
{
ForEach(allDevices)
{ dev in
deviceRow(dev)
.contentShape(Rectangle())
// +
.swipeActions(edge: .trailing)
{
if !isSelf(dev)
{
// destructive
// +
// 线
Button { revokeTarget = dev; showRevoke = true }
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
.tint(.red)
}
}
.contextMenu
{
if !isSelf(dev)
{
Button(role: .destructive) { revokeTarget = dev; showRevoke = true }
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
}
}
}
}
}
}
.navigationTitle(t("ios.tab.devices"))
.toolbar
{
// #8
ToolbarItem(placement: .topBarTrailing)
{
Button { showScanner = true }
label: { Label(t("ios.scan.entry"), systemImage: "qrcode.viewfinder") }
}
}
.sheet(isPresented: $showScanner)
{
ScanSheet { requestId, code in engine.fetchLoginRequest(requestId: requestId, code: code) }
}
.confirmationDialog(revokeTarget?.name ?? "", isPresented: $showRevoke, titleVisibility: .visible)
{
Button(t("ios.devices.remove"), role: .destructive)
{
if let target = revokeTarget { engine.revokeDevice(target.deviceID) }
}
Button(t("common.cancel"), role: .cancel) { }
}
// #8 / / IP
.confirmationDialog(
t("ios.scan.approveTitle"),
isPresented: Binding(
get: { engine.pendingLoginRequest != nil },
set: { if !$0 { engine.cancelLoginRequest() } }),
titleVisibility: .visible)
{
Button(t("ios.scan.approve")) { engine.approveLogin() }
Button(t("ios.scan.deny"), role: .destructive) { engine.denyLogin() }
Button(t("common.cancel"), role: .cancel) { engine.cancelLoginRequest() }
}
message:
{
if let req = engine.pendingLoginRequest
{
Text(loginRequestInfo(req))
}
}
}
// · IP
private func loginRequestInfo(_ req: LoginRequestItem) -> String
{
var head: [String] = []
if !req.deviceName.isEmpty { head.append(req.deviceName) }
if !req.deviceType.isEmpty { head.append(req.deviceType) }
var s = head.joined(separator: " · ")
if !req.requestIp.isEmpty
{
if !s.isEmpty { s += "\n" }
s += "\(t("ios.scan.requestIp")): \(req.requestIp)"
}
return s
}
private func deviceRow(_ dev: DeviceItem) -> some View
{
Label
{
VStack(alignment: .leading, spacing: 2)
{
HStack(spacing: 6)
{
Text(dev.name)
if isSelf(dev)
{
Text(t("ios.devices.thisDevice"))
.font(.oCaption2)
.foregroundStyle(Color.cdropAccent)
}
}
Text(dev.online
? t("settings.online")
: t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ]))
.font(.oCaption)
.foregroundStyle(.secondary)
}
}
icon:
{
Image(systemName: deviceSymbol(dev.type))
.foregroundStyle(dev.online ? Color.green : Color.secondary)
}
}
}
struct SettingsView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
// UserDefaults
@State private var deviceNameDraft = DeviceNameStore.value
var body: some View
{
List
{
// + logo
Section
{
VStack(spacing: 6)
{
BrandLockup(markSize: 40, wordSize: 24)
if !appVersion.isEmpty
{
Text(appVersion)
.font(.oCaption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.listRowBackground(Color.clear)
}
if let user = auth.session?.user
{
Section
{
LabeledContent(t("ios.settings.user"), value: user.name)
// PATCH /api/devices/{device_id}
// token EngineController.renameSelf
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
.submitLabel(.done)
.onSubmit { commitRename() }
Button(role: .destructive) { logout() }
label:
{
Text(t("ios.settings.logout"))
}
}
header:
{
Text(t("ios.settings.account"))
}
footer:
{
Text(t("ios.settings.deviceNameNote"))
}
}
Section(t("ios.settings.engine"))
{
LabeledContent(t("ios.settings.status"), value: engine.status)
LabeledContent(t("ios.settings.device"),
value: engine.deviceName.isEmpty ? "" : engine.deviceName)
LabeledContent(t("ios.settings.signaling"), value: signalingState)
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
// #3 N
NavigationLink
{
LogView()
}
label:
{
VStack(alignment: .leading, spacing: 2)
{
Text(t("ios.settings.viewLogs"))
if !engine.lastEngineLog.isEmpty
{
Text(engine.lastEngineLog)
.font(.oCaption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
Section
{
Button { engine.uploadClipboard() }
label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") }
Button { engine.pullClipboard() }
label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") }
}
header:
{
Text(t("ios.settings.clipboard"))
}
footer:
{
Text(t("ios.settings.clipboardNote"))
}
}
.navigationTitle(t("ios.tab.settings"))
}
// / / 便
private var signalingState: String
{
if engine.hubConnected { return t("app.connected") }
if engine.hubReconnecting { return t("ios.settings.reconnecting") }
return t("ios.engine.disconnected")
}
// vX.Y (build)
private var appVersion: String
{
let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
guard !v.isEmpty else { return "" }
let b = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
return b.isEmpty ? "v\(v)" : "v\(v) (\(b))"
}
// 稿PATCH /api/devices/{device_id} /
// renamed + Keychain + engine.deviceName
private func commitRename()
{
let trimmed = deviceNameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != (auth.session?.deviceName ?? "") else { return }
engine.renameSelf(to: trimmed)
}
// SSE + WebView Keychain auth
private func logout()
{
engine.reset()
auth.logout()
}
}
// TODO #2 + send Documents
// QuickLook / / 线 / Info.plist
// UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace Files app iPhone
// engine.sendFile
struct ReceivedFilesView: View
{
@Environment(EngineController.self) private var engine
@State private var files: [URL] = []
@State private var previewItem: PreviewItem?
@State private var pickedURL: URL?
@State private var showDevicePicker = false
@State private var showNoDevices = false
var body: some View
{
Group
{
if files.isEmpty
{
ContentUnavailableView(t("ios.files.empty"), systemImage: "tray")
}
else
{
List
{
ForEach(files, id: \.self)
{ url in
Button { previewItem = PreviewItem(url: url) }
label: { fileRow(url) }
.buttonStyle(.plain)
.swipeActions(edge: .leading)
{
Button { startSend(url) }
label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") }
.tint(.cdropAccent)
}
.contextMenu
{
ShareLink(item: url)
{ Label(t("ios.files.share"), systemImage: "square.and.arrow.up") }
Button { startSend(url) }
label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") }
Button(role: .destructive) { delete(url) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
.onDelete { offsets in deleteAt(offsets) } // + EditButton
}
}
}
.navigationTitle(t("ios.files.title"))
.toolbar
{
if !files.isEmpty
{
ToolbarItem(placement: .topBarTrailing) { EditButton() }
}
}
.sheet(item: $previewItem) { item in FilePreviewSheet(item: item) }
.onAppear { reload() }
.onChange(of: engine.history.count) { reload() }
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
{
ForEach(engine.sendableDevices())
{ dev in
Button(engine.sendLabel(dev)) { send(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { }
}
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
private func fileRow(_ url: URL) -> some View
{
HStack(spacing: 10)
{
Image(systemName: "doc")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2)
{
Text(url.lastPathComponent)
.lineLimit(1)
Text(fileSizeString(url))
.font(.oCaption)
.foregroundStyle(.secondary)
}
}
}
private func reload() { files = engine.receivedFiles() }
private func delete(_ url: URL)
{
engine.deleteReceivedFile(url)
reload()
}
private func deleteAt(_ offsets: IndexSet)
{
for i in offsets { engine.deleteReceivedFile(files[i]) }
reload()
}
private func startSend(_ url: URL)
{
pickedURL = url
if engine.sendableDevices().isEmpty { showNoDevices = true }
else { showDevicePicker = true }
}
private func send(to device: String)
{
guard let url = pickedURL else { return }
engine.sendFile(to: device, fileURL: url)
pickedURL = nil
}
}
// MARK: -
// /
private func isActive(_ item: TransferItem) -> Bool
{
return item.state != "DONE" && item.state != "FAILED" && item.state != "CANCELLED"
}
private func progressOf(_ item: TransferItem) -> Double
{
guard item.fileSize > 0, let sent = item.bytesTransferred else { return 0 }
return min(1.0, Double(sent) / Double(item.fileSize))
}
func formatBytes(_ bytes: Int) -> String
{
let f = ByteCountFormatter()
f.countStyle = .file
return f.string(fromByteCount: Int64(bytes))
}
private func fileSizeString(_ url: URL) -> String
{
let size = (try? url.resourceValues(forKeys: [ .fileSizeKey ]))?.fileSize ?? 0
return formatBytes(size)
}
// lastSeen epoch devices.last_seennow.Unix()web formatRelative
// settings.lastSeen {{time}}
func relativeTime(_ epochSec: Double) -> String
{
let date = Date(timeIntervalSince1970: epochSec)
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
// SF Symbol web mapKind
func deviceSymbol(_ type: String) -> String
{
let s = type.lowercased()
if s.contains("mac") || s == "darwin" { return "laptopcomputer" }
if s.contains("win") { return "pc" }
if s.contains("linux") { return "terminal" }
if s.contains("ipad") { return "ipad" }
if s.contains("ios") || s.contains("iphone") { return "iphone" }
if s.contains("android") { return "candybarphone" }
if s == "shortcut" { return "bolt" }
if s == "browser" { return "globe" }
return "desktopcomputer"
}
// Color.cdropAccent Shared/Theme.swift app /