1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
import SwiftUI
/// Covers the create sheet while a gallery is going out.
///
/// The old flow put an indeterminate spinner in the toolbar and any error in a
/// caption at the bottom of a long scrolling form — which is a good part of why
/// a failure could go unnoticed until a duplicate showed up in the feed. This
/// takes over the screen instead: it says which photo is uploading, it can't be
/// tapped through, and a failure arrives with the two things worth knowing —
/// that nothing was lost, and what to do next.
struct GalleryPublishOverlay: View {
let stage: GalleryUploadCenter.Stage
var onRetry: () -> Void
var onPostLater: () -> Void
var body: some View {
ZStack {
Color.black.opacity(0.4)
.ignoresSafeArea()
VStack(spacing: 16) {
if case let .failed(message) = stage {
failureContent(message: message)
} else {
progressContent
}
}
.padding(24)
.frame(maxWidth: 320)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20))
.shadow(radius: 24, y: 8)
}
.transition(.opacity)
}
private var progressContent: some View {
VStack(spacing: 14) {
if let fraction = stage.fraction {
ProgressView(value: fraction)
.progressViewStyle(.linear)
} else {
ProgressView()
}
Text(stage.label)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.contentTransition(.numericText())
.animation(.smooth, value: stage.label)
}
}
private func failureContent(message: String) -> some View {
VStack(spacing: 14) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundStyle(.orange)
Text(message)
.font(.subheadline)
.multilineTextAlignment(.center)
VStack(spacing: 8) {
Button(action: onRetry) {
Text("Try again")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button(action: onPostLater) {
Text("Post later")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.padding(.top, 4)
Text("Your photos stay on this device until the gallery posts.")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
}
/// A slim bar above the tab bar for a gallery that's finishing in the background.
///
/// This is what "post later" leads to. Without it an auto-resumed publish would
/// be entirely invisible — including a second failure.
struct PendingGalleryBar: View {
let stage: GalleryUploadCenter.Stage
let pendingCount: Int
var onRetry: () -> Void
var onDiscard: () -> Void
var body: some View {
if let content {
HStack(spacing: 12) {
if stage.isBusy {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "exclamationmark.circle")
.foregroundStyle(.orange)
}
Text(content)
.font(.footnote)
.lineLimit(2)
Spacer(minLength: 0)
if !stage.isBusy {
Button("Retry", action: onRetry)
.font(.footnote.weight(.semibold))
Button("Discard", role: .destructive, action: onDiscard)
.font(.footnote)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.bar)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
private var content: String? {
if stage.isBusy {
return "Finishing your gallery — \(stage.label.lowercased())"
}
if case .failed = stage, pendingCount > 0 {
return "A gallery didn't finish posting."
}
if pendingCount > 0 {
return "A gallery is waiting to finish posting."
}
return nil
}
}
#Preview("Uploading") {
GalleryPublishOverlay(
stage: .uploading(completed: 3, total: 8),
onRetry: {},
onPostLater: {}
)
}
#Preview("Failed") {
GalleryPublishOverlay(
stage: .failed(message: "The connection dropped. Nothing was lost — trying again picks up where it stopped."),
onRetry: {},
onPostLater: {}
)
}