Skip to content

Commit 52de3ab

Browse files
committed
feat: add top overlay to display restore action result
Add a visual feedback overlay that appears at the top of the screen when users trigger the restore purchases action. The overlay shows success or failure status with appropriate icons and localized messages, then auto-dismisses after 3 seconds or on tap. Changes: - Add StoreRestoreResult enum for tracking restore status - Add RestoreOverlayView component with animated appearance - Update StoreViewModel with restoreResult state and showResult parameter - Integrate overlay into DeveloperSupportStoreView - Add localized strings for success/failure messages (en/uk)
1 parent 823420a commit 52de3ab

5 files changed

Lines changed: 252 additions & 2 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// StoreRestoreResult.swift
3+
//
4+
// Created on 09.01.2026.
5+
// Copyright © 2026 IGR Soft. All rights reserved.
6+
//
7+
8+
import Foundation
9+
10+
/// Represents the result of a restore purchases action.
11+
public enum StoreRestoreResult: Sendable, Equatable {
12+
/// The restore completed successfully.
13+
case success
14+
/// The restore failed with an error.
15+
case failure
16+
}

Sources/DeveloperSupportStore/Resources/Localizable.xcstrings

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,40 @@
107107
}
108108
}
109109
},
110+
"store.restore.failure" : {
111+
"extractionState" : "manual",
112+
"localizations" : {
113+
"en" : {
114+
"stringUnit" : {
115+
"state" : "translated",
116+
"value" : "Unable to restore purchases"
117+
}
118+
},
119+
"uk" : {
120+
"stringUnit" : {
121+
"state" : "translated",
122+
"value" : "Не вдалося відновити покупки"
123+
}
124+
}
125+
}
126+
},
127+
"store.restore.success" : {
128+
"extractionState" : "manual",
129+
"localizations" : {
130+
"en" : {
131+
"stringUnit" : {
132+
"state" : "translated",
133+
"value" : "Purchases restored successfully"
134+
}
135+
},
136+
"uk" : {
137+
"stringUnit" : {
138+
"state" : "translated",
139+
"value" : "Покупки успішно відновлено"
140+
}
141+
}
142+
}
143+
},
110144
"store.title.oneTime" : {
111145
"extractionState" : "manual",
112146
"localizations" : {

Sources/DeveloperSupportStore/Views/DeveloperSupportStoreView.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ public struct DeveloperSupportStoreView: View {
110110
.ignoresSafeArea()
111111
}
112112
}
113+
.overlay {
114+
if let restoreResult = viewModel.restoreResult {
115+
RestoreOverlayView(
116+
result: restoreResult,
117+
colors: colors,
118+
typography: typography,
119+
layout: layout,
120+
onDismiss: viewModel.dismissRestoreResult
121+
)
122+
}
123+
}
113124
}
114125

115126
// MARK: - Header
@@ -338,7 +349,7 @@ public struct DeveloperSupportStoreView: View {
338349
@ViewBuilder
339350
private var restoreButton: some View {
340351
Button {
341-
Task { await viewModel.syncStore() }
352+
Task { await viewModel.syncStore(showResult: true) }
342353
} label: {
343354
Image(systemName: "arrow.counterclockwise")
344355
.font(.system(size: 20, weight: .medium))
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
//
2+
// RestoreOverlayView.swift
3+
//
4+
// Created on 09.01.2026.
5+
// Copyright © 2026 IGR Soft. All rights reserved.
6+
//
7+
8+
import SwiftUI
9+
10+
/// A top overlay view that displays the result of a restore purchases action.
11+
///
12+
/// Shows a banner at the top of the screen with success or failure status,
13+
/// automatically dismissing after a configurable duration.
14+
struct RestoreOverlayView: View {
15+
/// The result to display.
16+
let result: StoreRestoreResult
17+
18+
/// Color configuration.
19+
let colors: StoreColors
20+
21+
/// Typography configuration.
22+
let typography: StoreTypography
23+
24+
/// Layout configuration.
25+
let layout: StoreLayoutConstants
26+
27+
/// Action to dismiss the overlay.
28+
let onDismiss: () -> Void
29+
30+
/// Duration before auto-dismissing (in seconds).
31+
private let autoDismissDelay: TimeInterval = 3.0
32+
33+
@State private var isVisible: Bool = false
34+
35+
var body: some View {
36+
VStack {
37+
bannerContent
38+
.padding(.horizontal, layout.paddingDefault)
39+
.padding(.vertical, layout.paddingSmall)
40+
.background(
41+
RoundedRectangle(cornerRadius: layout.radiusDefault)
42+
.fill(backgroundColor)
43+
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
44+
)
45+
.padding(.horizontal, layout.paddingBig)
46+
.padding(.top, layout.paddingDefault)
47+
.offset(y: isVisible ? 0 : -100)
48+
.opacity(isVisible ? 1 : 0)
49+
50+
Spacer()
51+
}
52+
.onAppear {
53+
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
54+
isVisible = true
55+
}
56+
scheduleAutoDismiss()
57+
}
58+
.onTapGesture {
59+
dismissWithAnimation()
60+
}
61+
}
62+
63+
// MARK: - Banner Content
64+
65+
@ViewBuilder
66+
private var bannerContent: some View {
67+
HStack(spacing: layout.spacingDefault) {
68+
Image(systemName: iconName)
69+
.font(.system(size: 20, weight: .semibold))
70+
.foregroundStyle(iconColor)
71+
72+
Text(messageKey, bundle: .module)
73+
.font(typography.bodyMedium)
74+
.foregroundStyle(colors.primaryText)
75+
76+
Spacer()
77+
78+
Button {
79+
dismissWithAnimation()
80+
} label: {
81+
Image(systemName: "xmark")
82+
.font(.system(size: 12, weight: .semibold))
83+
.foregroundStyle(colors.primaryText.opacity(0.6))
84+
.frame(width: 24, height: 24)
85+
.contentShape(Rectangle())
86+
}
87+
.buttonStyle(.plain)
88+
}
89+
}
90+
91+
// MARK: - Styling
92+
93+
private var backgroundColor: Color {
94+
switch result {
95+
case .success:
96+
return colors.selectedView.opacity(0.9)
97+
case .failure:
98+
return Color.red.opacity(0.9)
99+
}
100+
}
101+
102+
private var iconName: String {
103+
switch result {
104+
case .success:
105+
return "checkmark.circle.fill"
106+
case .failure:
107+
return "exclamationmark.triangle.fill"
108+
}
109+
}
110+
111+
private var iconColor: Color {
112+
colors.primaryText
113+
}
114+
115+
private var messageKey: LocalizedStringKey {
116+
switch result {
117+
case .success:
118+
return "store.restore.success"
119+
case .failure:
120+
return "store.restore.failure"
121+
}
122+
}
123+
124+
// MARK: - Actions
125+
126+
private func scheduleAutoDismiss() {
127+
Task {
128+
try? await Task.sleep(for: .seconds(autoDismissDelay))
129+
dismissWithAnimation()
130+
}
131+
}
132+
133+
private func dismissWithAnimation() {
134+
withAnimation(.easeOut(duration: 0.25)) {
135+
isVisible = false
136+
}
137+
Task {
138+
try? await Task.sleep(for: .milliseconds(250))
139+
onDismiss()
140+
}
141+
}
142+
}
143+
144+
// MARK: - Preview
145+
146+
#Preview("Restore Success") {
147+
ZStack {
148+
Color.black.ignoresSafeArea()
149+
150+
RestoreOverlayView(
151+
result: .success,
152+
colors: .default,
153+
typography: .default,
154+
layout: .default,
155+
onDismiss: {}
156+
)
157+
}
158+
}
159+
160+
#Preview("Restore Failure") {
161+
ZStack {
162+
Color.black.ignoresSafeArea()
163+
164+
RestoreOverlayView(
165+
result: .failure,
166+
colors: .default,
167+
typography: .default,
168+
layout: .default,
169+
onDismiss: {}
170+
)
171+
}
172+
}

Sources/DeveloperSupportStore/Views/StoreViewModel.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ public final class StoreViewModel {
4040
/// Whether an overlay (loading indicator) should be displayed.
4141
public var isLoading: Bool = false
4242

43+
/// The result of the last restore action, shown as an overlay.
44+
public var restoreResult: StoreRestoreResult?
45+
4346
// MARK: - Product Collections
4447

4548
/// Subscription products from StoreHelper.
@@ -100,7 +103,9 @@ public final class StoreViewModel {
100103
///
101104
/// After StoreHelper fetches products from the App Store,
102105
/// this updates the stored properties to trigger UI refresh.
103-
public func syncStore() async {
106+
///
107+
/// - Parameter showResult: Whether to show the restore result overlay. Defaults to `false`.
108+
public func syncStore(showResult: Bool = false) async {
104109
logger.notice("syncStore() called")
105110
isLoading = true
106111
defer { isLoading = false }
@@ -119,11 +124,23 @@ public final class StoreViewModel {
119124
logger.notice("After assignment:")
120125
logger.notice(" subscriptionProducts: \(subscriptionProducts.count)")
121126
logger.notice(" nonConsumableProducts: \(nonConsumableProducts.count)")
127+
128+
if showResult {
129+
restoreResult = .success
130+
}
122131
} catch {
123132
logger.error("Sync error: \(error.localizedDescription)")
133+
if showResult {
134+
restoreResult = .failure
135+
}
124136
}
125137
}
126138

139+
/// Dismisses the restore result overlay.
140+
public func dismissRestoreResult() {
141+
restoreResult = nil
142+
}
143+
127144
/// Gets product info for a product.
128145
///
129146
/// - Parameter product: The product.

0 commit comments

Comments
 (0)