Skip to content

Commit 6f67ae5

Browse files
authored
Merge pull request #68 from ably/example-app
[ECO-5466] Example app
2 parents 97fe738 + 54cbfa2 commit 6f67ae5

File tree

7 files changed

+770
-13
lines changed

7 files changed

+770
-13
lines changed

Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,23 @@ import SwiftUI
44

55
@main
66
struct AblyLiveObjectsExampleApp: App {
7-
@State private var realtime = {
7+
private func getRealtime() -> ARTRealtime {
88
let clientOptions = ARTClientOptions(key: Secrets.ablyAPIKey)
99
clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self]
10-
1110
return ARTRealtime(options: clientOptions)
12-
}()
11+
}
1312

1413
var body: some Scene {
1514
WindowGroup {
16-
ContentView(realtime: realtime)
15+
#if os(macOS)
16+
ContentView(realtime1: getRealtime(), realtime2: getRealtime())
17+
.frame(width: 400, height: 700, alignment: .center)
18+
#else
19+
ContentView(realtime1: getRealtime(), realtime2: getRealtime())
20+
#endif
1721
}
22+
#if os(macOS)
23+
.windowResizability(.contentSize)
24+
#endif
1825
}
1926
}

Example/AblyLiveObjectsExample/ContentView.swift

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,49 @@ import AblyLiveObjects
33
import SwiftUI
44

55
struct ContentView: View {
6-
var realtime: ARTRealtime
6+
@StateObject private var viewModel1: LiveCounterViewModel
7+
@StateObject private var viewModel2: LiveCounterViewModel
8+
@StateObject private var taskViewModel1: TaskBoardViewModel
9+
@StateObject private var taskViewModel2: TaskBoardViewModel
10+
private let realtime1: ARTRealtime
11+
private let realtime2: ARTRealtime
12+
13+
init(realtime1: ARTRealtime, realtime2: ARTRealtime) {
14+
_viewModel1 = StateObject(wrappedValue: LiveCounterViewModel(realtime: realtime1))
15+
_viewModel2 = StateObject(wrappedValue: LiveCounterViewModel(realtime: realtime2))
16+
_taskViewModel1 = StateObject(wrappedValue: TaskBoardViewModel(realtime: realtime1))
17+
_taskViewModel2 = StateObject(wrappedValue: TaskBoardViewModel(realtime: realtime2))
18+
self.realtime1 = realtime1
19+
self.realtime2 = realtime2
20+
}
721

822
var body: some View {
9-
VStack {
10-
Image(systemName: "globe")
11-
.imageScale(.large)
12-
.foregroundStyle(.tint)
13-
Text("Hello, world!")
23+
TabView {
24+
// Live Counter tab
25+
Group {
26+
VStack(spacing: 1) {
27+
LiveCounterView(viewModel: viewModel1, clientTitle: "Client 1")
28+
Divider()
29+
LiveCounterView(viewModel: viewModel2, clientTitle: "Client 2")
30+
}
31+
}
32+
.tabItem {
33+
Image(systemName: "plus.forwardslash.minus")
34+
Text("Live Counter")
35+
}
1436

15-
let channel = realtime.channels.get("myChannel")
16-
Text("`channel.objects`: `\(String(describing: channel.objects))`")
37+
// Task Board tab
38+
Group {
39+
VStack(spacing: 1) {
40+
TaskBoardView(viewModel: taskViewModel1, clientTitle: "Client 1")
41+
Divider()
42+
TaskBoardView(viewModel: taskViewModel2, clientTitle: "Client 2")
43+
}
44+
}
45+
.tabItem {
46+
Image(systemName: "checklist")
47+
Text("Task Board")
48+
}
1749
}
18-
.padding()
1950
}
2051
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Ably
2+
3+
extension ARTRealtimeChannelProtocol {
4+
func attachAsync() async throws(ARTErrorInfo) {
5+
try await withCheckedContinuation { (continuation: CheckedContinuation<Result<Void, ARTErrorInfo>, _>) in
6+
attach { error in
7+
if let error {
8+
continuation.resume(returning: .failure(error))
9+
} else {
10+
continuation.resume(returning: .success(()))
11+
}
12+
}
13+
}.get()
14+
}
15+
16+
func detachAsync() async throws(ARTErrorInfo) {
17+
try await withCheckedContinuation { (continuation: CheckedContinuation<Result<Void, ARTErrorInfo>, _>) in
18+
detach { error in
19+
if let error {
20+
continuation.resume(returning: .failure(error))
21+
} else {
22+
continuation.resume(returning: .success(()))
23+
}
24+
}
25+
}.get()
26+
}
27+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import Ably
2+
import AblyLiveObjects
3+
import SwiftUI
4+
5+
enum VoteColor: String, CaseIterable {
6+
case red
7+
case green
8+
case blue
9+
10+
var displayName: String {
11+
rawValue.capitalized
12+
}
13+
14+
var swiftUIColor: SwiftUI.Color {
15+
switch self {
16+
case .red:
17+
.red
18+
case .green:
19+
.green
20+
case .blue:
21+
.blue
22+
}
23+
}
24+
}
25+
26+
@MainActor
27+
final class LiveCounterViewModel: ObservableObject {
28+
@Published var redCount: Double = 0
29+
@Published var greenCount: Double = 0
30+
@Published var blueCount: Double = 0
31+
@Published var isLoading = true
32+
@Published var errorMessage: String?
33+
34+
private var realtime: ARTRealtime
35+
private var channel: ARTRealtimeChannel
36+
private var objects: any RealtimeObjects
37+
private var root: (any LiveMap)?
38+
39+
private var redCounter: (any LiveCounter)?
40+
private var greenCounter: (any LiveCounter)?
41+
private var blueCounter: (any LiveCounter)?
42+
43+
private var subscribeResponses: [String: any SubscribeResponse] = [:]
44+
45+
init(realtime: ARTRealtime) {
46+
self.realtime = realtime
47+
48+
// Use URL parameters or default channel name
49+
let channelName = "live-objects-counter"
50+
let channelOptions = ARTRealtimeChannelOptions()
51+
channelOptions.modes = [.objectPublish, .objectSubscribe]
52+
channel = realtime.channels.get(channelName, options: channelOptions)
53+
objects = channel.objects
54+
55+
Task {
56+
await initializeCounters()
57+
}
58+
}
59+
60+
deinit {
61+
// Clean up subscriptions
62+
subscribeResponses.values.forEach { $0.unsubscribe() }
63+
subscribeResponses.removeAll()
64+
}
65+
66+
private func initializeCounters() async {
67+
do {
68+
isLoading = true
69+
errorMessage = nil
70+
71+
// Attach channel first
72+
try await channel.attachAsync()
73+
74+
// Get root object
75+
let root = try await objects.getRoot()
76+
self.root = root
77+
78+
// Subscribe to root changes
79+
let rootSubscription = try root.subscribe { [weak self] update, _ in
80+
MainActor.assumeIsolated {
81+
// Handle root updates - this will fire when counters are reset
82+
for (keyName, change) in update.update {
83+
if change == .updated, let color = VoteColor(rawValue: keyName) {
84+
self?.subscribeToCounter(color: color)
85+
}
86+
}
87+
}
88+
}
89+
subscribeResponses["root"] = rootSubscription
90+
91+
// Initialize all color counters
92+
for color in VoteColor.allCases {
93+
await initializeCounter(for: color)
94+
}
95+
96+
isLoading = false
97+
} catch {
98+
errorMessage = "Failed to initialize: \(error.localizedDescription)"
99+
isLoading = false
100+
}
101+
}
102+
103+
private func initializeCounter(for color: VoteColor) async {
104+
do {
105+
guard let root else {
106+
return
107+
}
108+
109+
// Check if counter already exists
110+
if let existingValue = try root.get(key: color.rawValue), let existingCounter = existingValue.liveCounterValue {
111+
// Counter exists, store it
112+
setCounter(existingCounter, for: color)
113+
} else {
114+
// Counter doesn't exist, create it
115+
let newCounter = try await objects.createCounter(count: 0)
116+
try await root.set(key: color.rawValue, value: .liveCounter(newCounter))
117+
setCounter(newCounter, for: color)
118+
}
119+
// Subscribe to it
120+
subscribeToCounter(color: color)
121+
} catch {
122+
errorMessage = "Failed to initialize \(color.rawValue) counter: \(error.localizedDescription)"
123+
}
124+
}
125+
126+
private func setCounter(_ counter: any LiveCounter, for color: VoteColor) {
127+
do {
128+
let value = try counter.value
129+
switch color {
130+
case .red:
131+
redCounter = counter
132+
redCount = value
133+
case .green:
134+
greenCounter = counter
135+
greenCount = value
136+
case .blue:
137+
blueCounter = counter
138+
blueCount = value
139+
}
140+
} catch {
141+
errorMessage = "Error getting \(color.rawValue) counter value: \(error)"
142+
}
143+
}
144+
145+
private func subscribeToCounter(color: VoteColor) {
146+
do {
147+
guard let root,
148+
let value = try root.get(key: color.rawValue),
149+
let counter = value.liveCounterValue else { return }
150+
151+
subscribeResponses[color.rawValue]?.unsubscribe()
152+
153+
subscribeResponses[color.rawValue] = try counter.subscribe { [weak self] _, _ in
154+
MainActor.assumeIsolated {
155+
// Update current value
156+
self?.updateCounterValue(for: color, counter: counter)
157+
}
158+
}
159+
160+
// Set counter with value
161+
setCounter(counter, for: color)
162+
} catch {
163+
errorMessage = "Failed to subscribe to \(color.rawValue) counter: \(error)"
164+
}
165+
}
166+
167+
private func updateCounterValue(for color: VoteColor, counter: any LiveCounter) {
168+
do {
169+
let value = try counter.value
170+
switch color {
171+
case .red:
172+
redCount = value
173+
case .green:
174+
greenCount = value
175+
case .blue:
176+
blueCount = value
177+
}
178+
} catch {
179+
errorMessage = "Error updating \(color.rawValue) counter value: \(error)"
180+
}
181+
}
182+
183+
func vote(for color: VoteColor) {
184+
Task {
185+
do {
186+
let counter: (any LiveCounter)? = switch color {
187+
case .red:
188+
redCounter
189+
case .green:
190+
greenCounter
191+
case .blue:
192+
blueCounter
193+
}
194+
195+
try await counter?.increment(amount: 1)
196+
} catch {
197+
errorMessage = "Failed to vote for \(color.rawValue): \(error.localizedDescription)"
198+
}
199+
}
200+
}
201+
202+
func resetCounter(color: VoteColor) {
203+
Task {
204+
do {
205+
let newCounter = try await objects.createCounter(count: 0)
206+
try await self.root?.set(key: color.rawValue, value: .liveCounter(newCounter))
207+
} catch {
208+
errorMessage = "Failed to reset counters: \(error.localizedDescription)"
209+
}
210+
}
211+
}
212+
213+
func resetAllCounters() {
214+
for color in VoteColor.allCases {
215+
resetCounter(color: color)
216+
}
217+
}
218+
}

0 commit comments

Comments
 (0)