mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
@@ -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>) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user