feat(apple): transfer controls, progress fixes, and project config

- Sender progress: aggregate only in-flight receivers so the bar clears on
  completion and is order-independent ("Sending to N")
- Add Stop sharing and per-receiver Refuse (pending requests) on the sender
- Fix macOS: raise approval modal above the Share sheet; drive foreground
  state off NSApplication so background notifications fire
- Persist app identity (display name, category) and signing team via
  Info.plist / project.yml / gitignored Local.xcconfig
This commit is contained in:
2026-07-19 12:33:58 +02:00
parent ea376a382b
commit ceccfcda71
11 changed files with 278 additions and 68 deletions

View File

@@ -205,6 +205,37 @@ final class SendModel: ObservableObject {
}
}
/// Cancels/refuses a single receiver by responding to its request negatively.
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
/// receivers that are still pending or accepted.
func cancelReceiver(requestId: String) {
Task {
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
switch result {
case .success:
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
_ = await repository.refresh()
case .failure(let error):
messages.error(error)
}
}
}
/// Stops an active outgoing share (interrupts any in-flight receivers). The
/// transfer stays in history as "Stopped". Uses the core's `cancelTransfer`.
func stopSharing(transferId: UInt64) {
Task {
let result = await repository.cancel(transferId: transferId)
switch result {
case .success:
_ = await repository.refresh()
messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info))
case .failure(let error):
messages.error(error)
}
}
}
// MARK: - Invitation results / share
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {

View File

@@ -116,7 +116,7 @@ struct SendScreen: View {
private func progress(for transfer: Transfer) -> TransferProgress? {
switch transfer.status {
case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
case .sharing: return activeSendProgress(events: model.coreState.events, transferId: transfer.transferId, totalSizeHint: transfer.totalSize)
case .sharing: return sendProgressSummary(events: model.coreState.events, transferId: transfer.transferId, totalSizeHint: transfer.totalSize)
default: return nil
}
}
@@ -142,7 +142,7 @@ private struct TransferListItem: View {
Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let progress, transfer.status == .importing || transfer.status == .sharing {
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
.padding(.top, 2)
}
}

View File

@@ -7,6 +7,11 @@ struct TransferDetailsView: View {
@ObservedObject var model: SendModel
let transfer: Transfer
let events: [CoreEventModel]
@State private var showStopConfirmation = false
private var isActiveShare: Bool {
transfer.status == .sharing || transfer.status == .importing
}
private var pendingReceivers: Int {
model.state.receiverHistory.filter { $0.status == .requested || $0.status == .accepted }.count
@@ -45,6 +50,16 @@ struct TransferDetailsView: View {
onTap: model.openShare
)
}
if isActiveShare {
Section {
Button(role: .destructive) {
showStopConfirmation = true
} label: {
Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle")
}
}
}
}
.formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey("send_transfer_details_title")))
@@ -58,6 +73,18 @@ struct TransferDetailsView: View {
}
}
}
.confirmationDialog(
Text(LocalizedStringKey("send_stop_sharing")),
isPresented: $showStopConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "send_stop_sharing"), role: .destructive) {
model.stopSharing(transferId: transfer.transferId)
}
Button(String(localized: "button_cancel"), role: .cancel) {}
} message: {
Text(LocalizedStringKey("send_stop_sharing_description"))
}
}
}
@@ -113,7 +140,8 @@ struct DetailPanelContent: View {
receivers: model.state.receiverHistory,
loading: model.state.isLoadingReceivers,
events: model.coreState.events,
transferTotalSize: transfer.totalSize
transferTotalSize: transfer.totalSize,
onCancel: model.cancelReceiver
)
case .share:
TransferSharePanel(model: model, transfer: transfer)
@@ -163,6 +191,7 @@ struct ReceiverHistoryPanel: View {
let loading: Bool
let events: [CoreEventModel]
let transferTotalSize: UInt64
let onCancel: (String) -> Void
var body: some View {
PanelContainer(title: String(localized: "transfer_receivers_title")) {
@@ -173,7 +202,7 @@ struct ReceiverHistoryPanel: View {
} else {
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
if index > 0 { Divider().overlay(colors.borderDefault) }
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver))
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel)
}
}
}
@@ -193,25 +222,46 @@ private struct ReceiverRow: View {
@Environment(\.vniColors) private var colors
let receiver: ReceiverRequestModel
let sendProgress: TransferProgress?
let onCancel: (String) -> Void
/// Only pending requests can be cancelled per-receiver: the core rejects a
/// negative response to an already-accepted request ("...not approved, or it
/// was refused"). Interrupting an in-flight receiver needs Stop sharing.
private var isCancelable: Bool {
receiver.status == .requested
}
var body: some View {
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device")
let showLive = sendProgress != nil && receiver.status != .completed
&& receiver.status != .refused && receiver.status != .expired
VStack(alignment: .leading, spacing: 6) {
Text(name).font(VniType.bodyLarge).lineLimit(1)
if let deviceName = receiver.receiverDeviceName, deviceName != name {
Text(deviceName).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(name).font(VniType.bodyLarge).lineLimit(1)
if let deviceName = receiver.receiverDeviceName, deviceName != name {
Text(deviceName).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
if showLive, let sendProgress {
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
} else {
Text(LocalizedStringKey(receiver.status.statusTextKey))
.font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(receiver.status.statusColor(colors))
}
if let reason = receiver.reason, !reason.isEmpty {
Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
}
if showLive, let sendProgress {
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail)
} else {
Text(LocalizedStringKey(receiver.status.statusTextKey))
.font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(receiver.status.statusColor(colors))
}
if let reason = receiver.reason, !reason.isEmpty {
Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity, alignment: .leading)
if isCancelable {
Button(role: .destructive) {
onCancel(receiver.id)
} label: {
Text(LocalizedStringKey("button_refuse"))
.font(VniType.bodySmall)
}
.buttonStyle(.borderless)
.tint(.red)
}
}
.frame(maxWidth: .infinity, alignment: .leading)