field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

commit bc2bb9145e26626e81c43289441e9b28ff020eda
parent f476611d115f0c9030a8c45ca9da0a15efb6da0f
Author: triesap <tyson@radroots.org>
Date:   Sun, 15 Feb 2026 16:23:04 +0000

app: add market trade listing flows and rhi config

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 20++++++++++++++++----
MRadroots/Views/HomeView.swift | 76++++++++++++----------------------------------------------------------------
ARadroots/Views/MarketView.swift | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Views/PostFeedView.swift | 72++++++++++++++++++++++++++++++++++++++++++++----------------------------
MRadroots/Views/PostFeedViewModel.swift | 55++++++++++++++++++++++---------------------------------
MRadroots/Views/SettingsView.swift | 26++++++++++++++++++++++++++
MRadroots/Views/SetupView.swift | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
ARadroots/Views/TradeListingCreateView.swift | 285+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Views/TradeListingDetailView.swift | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Views/TradeOrderRequestView.swift | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadrootsKit/Sources/RadrootsKit/BuildConfig.swift | 1+
MRadrootsKit/Sources/RadrootsKit/Nostr.swift | 2+-
ARadrootsKit/Sources/RadrootsKit/TradeListing.swift | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadrootsKit/Sources/RadrootsKit/TradeSettings.swift | 24++++++++++++++++++++++++
14 files changed, 1216 insertions(+), 172 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -12,8 +12,11 @@ 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; }; 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; }; 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; }; + 33A800AA701C354099623B24 /* MarketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C0B870E44C7B152A7FABE0 /* MarketView.swift */; }; 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; }; 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */; }; + 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */; }; + 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */; }; 5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */; }; 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.swift */; }; 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE0EB327C10171444553378 /* PostFeedView.swift */; }; @@ -30,25 +33,28 @@ EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */; }; F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */; }; F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */; }; + FD9B01F8F5DD1F05A64FD556 /* TradeOrderRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 08C0B870E44C7B152A7FABE0 /* MarketView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketView.swift; sourceTree = "<group>"; }; 08FA88664E5E3ED3A24D56CC /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; 0A0274A0260D1C04F40C71AF /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; 138AA7BAA021EE13E829390B /* Bundle+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Build.swift"; sourceTree = "<group>"; }; 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; }; 227028B4EBDC6703999FB9DA /* ToastModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastModifier.swift; sourceTree = "<group>"; }; + 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingDetailView.swift; sourceTree = "<group>"; }; 2818363B157125491FB84A1E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; 2FE790CA1CD31208947913B9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; }; 3FADD6E2563CC9AF9F935DCE /* RadrootsKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = RadrootsKit; path = RadrootsKit; sourceTree = SOURCE_ROOT; }; 41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; }; + 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; }; 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; }; - 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.git.xcconfig; sourceTree = "<group>"; }; 676B89EB116B60AE8C2B4313 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; }; - 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.local.xcconfig; sourceTree = "<group>"; }; 7C294E8EF50F5E1E73F5C135 /* Common.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = "<group>"; }; 93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93D729E070C32490545FA837 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; + 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingCreateView.swift; sourceTree = "<group>"; }; 9AE0EB327C10171444553378 /* PostFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedView.swift; sourceTree = "<group>"; }; A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Nav.swift"; sourceTree = "<group>"; }; ADE61264E2C98E73828E8680 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; }; @@ -101,8 +107,6 @@ 5FD6379AE27C57D02E8C7EE1 /* Radroots */ = { isa = PBXGroup; children = ( - 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */, - 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */, 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */, 23C2D7FF63B61CD356979E82 /* App */, 579F407D96CCAFD4000EF363 /* Config */, @@ -162,6 +166,7 @@ isa = PBXGroup; children = ( 0A0274A0260D1C04F40C71AF /* HomeView.swift */, + 08C0B870E44C7B152A7FABE0 /* MarketView.swift */, CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */, F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */, 9AE0EB327C10171444553378 /* PostFeedView.swift */, @@ -170,6 +175,9 @@ C1D9496F9F05A4E79E73A247 /* RelaysView.swift */, E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */, 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */, + 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */, + 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */, + 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */, ); path = Views; sourceTree = "<group>"; @@ -306,6 +314,7 @@ 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, + 33A800AA701C354099623B24 /* MarketView.swift in Sources */, F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */, EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */, 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */, @@ -317,6 +326,9 @@ 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */, 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */, D62E9461833A0AA5E622A1E6 /* ToastModifier.swift in Sources */, + 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */, + 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */, + FD9B01F8F5DD1F05A64FD556 /* TradeOrderRequestView.swift in Sources */, B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Radroots/Views/HomeView.swift b/Radroots/Views/HomeView.swift @@ -1,21 +1,27 @@ import SwiftUI -import RadrootsKit private enum HomeTab: Hashable { - case home + case feed + case market case settings } struct HomeView: View { - @State private var selection: HomeTab = .home + @State private var selection: HomeTab = .feed var body: some View { TabView(selection: $selection) { NavigationStack { - HomeDashboardView() + PostFeedView() } - .tabItem { Label("Home", systemImage: "house.fill") } - .tag(HomeTab.home) + .tabItem { Label("Feed", systemImage: "text.bubble.fill") } + .tag(HomeTab.feed) + + NavigationStack { + MarketView() + } + .tabItem { Label("Market", systemImage: "leaf") } + .tag(HomeTab.market) NavigationStack { SettingsView() @@ -25,61 +31,3 @@ struct HomeView: View { } } } - -private struct HomeDashboardView: View { - @EnvironmentObject private var app: AppState - - var body: some View { - List { - Section("Your Identity") { - NavigationLink { - ProfileView() - } label: { - HStack { - Text("Profile") - Spacer() - Text(app.npub ?? "—") - .font(.callout.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - } - - Section("Relays") { - NavigationLink { - RelaysView() - } label: { - HStack { - Text("Relays") - Spacer() - if app.relayConnectedCount > 0 { - Label("\(app.relayConnectedCount)", systemImage: "dot.radiowaves.left.and.right") - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - } - } - } - } - - Section("Compose") { - NavigationLink { - PostCreateView() - } label: { - Label("New Post", systemImage: "square.and.pencil") - } - } - - Section("Explore") { - NavigationLink { - PostFeedView() - } label: { - Label("Public Feed", systemImage: "text.bubble.fill") - } - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Home") - } -} diff --git a/Radroots/Views/MarketView.swift b/Radroots/Views/MarketView.swift @@ -0,0 +1,189 @@ +import SwiftUI +import RadrootsKit + +@MainActor +final class TradeListingsViewModel: ObservableObject { + @Published var listings: [TradeListingSummary] = [] + @Published var isLoading = false + @Published var errorMessage: String? + @Published var searchText: String = "" + + func loadIfNeeded(app: AppState) async { + if listings.isEmpty { + await refresh(app: app) + } + } + + func refresh(app: AppState) async { + guard let rt = app.radroots.runtime else { return } + isLoading = true + errorMessage = nil + + let result: Result<[TradeListingSummary], Error> = await Task.detached { @Sendable in + do { + return .success(try rt.tradeListingsFetch(limit: 60, sinceUnix: nil)) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success(let items): + listings = items + isLoading = false + case .failure(let error): + errorMessage = String(describing: error) + isLoading = false + } + } +} + +struct MarketView: View { + @EnvironmentObject private var app: AppState + @StateObject private var vm = TradeListingsViewModel() + @State private var showCreate = false + + var body: some View { + List { + if let error = vm.errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + .font(.footnote) + } + } + + if filteredListings.isEmpty { + if vm.isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowBackground(Color.clear) + } else { + ContentUnavailableView( + "No Listings Yet", + systemImage: "leaf", + description: Text("Connect to relays and pull listings from the network.") + ) + .listRowBackground(Color.clear) + } + } else { + Section { + ForEach(filteredListings) { listing in + NavigationLink { + TradeListingDetailView(listing: listing) + } label: { + TradeListingRow(listing: listing) + } + } + } + } + } + .listStyle(.plain) + .navigationTitle("Market") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if vm.isLoading { + ProgressView() + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreate = true + } label: { + Image(systemName: "plus") + } + } + } + .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always)) + .task { await vm.loadIfNeeded(app: app) } + .refreshable { await vm.refresh(app: app) } + .sheet(isPresented: $showCreate) { + NavigationStack { + TradeListingCreateView { + Task { await vm.refresh(app: app) } + } + } + } + } + + private var filteredListings: [TradeListingSummary] { + let trimmed = vm.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return vm.listings } + let needle = trimmed.lowercased() + return vm.listings.filter { listing in + let haystack = [ + listing.title, + listing.description, + listing.productType, + listing.location, + listing.sellerPubkey + ] + .joined(separator: " ") + .lowercased() + return haystack.contains(needle) + } + } +} + +private struct TradeListingRow: View { + let listing: TradeListingSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(listing.title) + .font(.headline) + .foregroundStyle(.primary) + Spacer() + Text(listing.availability.capitalized) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + if !listing.description.isEmpty { + Text(listing.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + HStack { + Text(priceLine) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Spacer() + Text("Inventory \(listing.inventoryAvailable)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Text(binLine) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Label(listing.deliveryMethod.capitalized, systemImage: "truck.box") + Label(listing.location, systemImage: "mappin.and.ellipse") + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.vertical, 4) + } + + private var priceLine: String { + "\(listing.unitPriceAmount) \(listing.unitPriceCurrency) / \(listing.unitPriceUnit)" + } + + private var binLine: String { + let label = listing.binDisplayLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + let base = "Bin \(listing.binDisplayAmount) \(listing.binDisplayUnit)" + if let label, !label.isEmpty { + return "\(base) \(label)" + } + return base + } +} diff --git a/Radroots/Views/PostFeedView.swift b/Radroots/Views/PostFeedView.swift @@ -18,35 +18,44 @@ struct PostFeedView: View { } } - Section { - ForEach(vm.posts, id: \.id) { item in - FeedPostRow( - post: item, - isExpanded: vm.expandedReplyFor == item.id, - onToggleReply: { vm.toggleReply(for: item.id) } - ) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - - if vm.expandedReplyFor == item.id { - InlineReplyComposer( - text: vm.bindingForReply(item.id), - isSending: vm.sendingReplyFor.contains(item.id), - onCancel: { vm.expandedReplyFor = nil }, - onSend: { - vm.sendReply(app: app, to: item) { title, message in - resultTitle = title - resultMessage = message - showResult = true - } - } + if vm.posts.isEmpty && vm.errorMessage == nil && !vm.isLoading { + ContentUnavailableView( + "No Posts Yet", + systemImage: "text.bubble", + description: Text("Connect to relays to load the public feed.") + ) + .listRowBackground(Color.clear) + } else { + Section { + ForEach(vm.posts, id: \.id) { item in + FeedPostRow( + post: item, + isExpanded: vm.expandedReplyFor == item.id, + onToggleReply: { vm.toggleReply(for: item.id) } ) - .listRowInsets(EdgeInsets(top: 6, leading: 64, bottom: 12, trailing: 16)) - .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + + if vm.expandedReplyFor == item.id { + InlineReplyComposer( + text: vm.bindingForReply(item.id), + isSending: vm.sendingReplyFor.contains(item.id), + onCancel: { vm.expandedReplyFor = nil }, + onSend: { + vm.sendReply(app: app, to: item) { title, message in + resultTitle = title + resultMessage = message + showResult = true + } + } + ) + .listRowInsets(EdgeInsets(top: 6, leading: 64, bottom: 12, trailing: 16)) + .listRowSeparator(.hidden) + } + } + } footer: { + if app.relayConnectedCount == 0 { + Text("No relays connected. Configure and connect to load posts.") } - } - } footer: { - if app.relayConnectedCount == 0 { - Text("No relays connected. Configure and connect to load posts.") } } } @@ -57,6 +66,13 @@ struct PostFeedView: View { if vm.isLoading { ProgressView() } } ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + PostCreateView() + } label: { + Image(systemName: "square.and.pencil") + } + } + ToolbarItem(placement: .topBarTrailing) { Button { Task { await vm.refresh(app: app) } } label: { Image(systemName: "arrow.clockwise") } @@ -64,7 +80,7 @@ struct PostFeedView: View { } } .task { vm.onAppear(app: app) } - .onDisappear { vm.onDisappear() } + .onDisappear { vm.onDisappear(app: app) } .refreshable { await vm.refresh(app: app) } .alert(resultTitle, isPresented: $showResult) { Button("OK", role: .cancel) { } diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift @@ -16,12 +16,13 @@ final class PostFeedViewModel: ObservableObject { if posts.isEmpty { Task { await load(app: app) } } - startLiveLoop(app: app) + startStream(app: app) } - func onDisappear() { + func onDisappear(app: AppState) { liveTask?.cancel() liveTask = nil + Task { try? app.radroots.nostrStopPostStream() } } func load(app: AppState) async { @@ -98,51 +99,39 @@ final class PostFeedViewModel: ObservableObject { } - private func startLiveLoop(app: AppState) { + private func startStream(app: AppState) { guard liveTask == nil else { return } liveTask = Task { @MainActor [weak self] in guard let self else { return } var knownIds = Set(posts.map(\.id)) - var since = posts.map(\.publishedAt).max() + let since = posts.map(\.publishedAt).max() + do { + try app.radroots.nostrStartPostStream(sinceUnix: since) + } catch { + errorMessage = String(describing: error) + } while !Task.isCancelled { if app.relayConnectedCount == 0 { - try? await Task.sleep(for: .seconds(2)) + try? await Task.sleep(for: .seconds(1)) continue } - guard let rt = app.radroots.runtime else { - try? await Task.sleep(for: .seconds(2)) - continue + + if knownIds.count != posts.count { + knownIds = Set(posts.map(\.id)) } - let currentSince = since - let fetchResult: Result<[NostrPostEventMetadata], Error> = await Task.detached { @Sendable in - do { - let items = try rt.nostrFetchTextNotes(limit: 50, sinceUnix: currentSince) - return .success(items) - } catch { - return .failure(error) - } - }.value - - if Task.isCancelled { break } - - switch fetchResult { - case .failure: - break - case .success(let fetched): - let newOnes = fetched.filter { !knownIds.contains($0.id) } - if !newOnes.isEmpty { - knownIds.formUnion(newOnes.map(\.id)) - let maxTs = newOnes.map(\.publishedAt).max() - posts = (newOnes + posts).sorted { $0.publishedAt > $1.publishedAt } - if let m = maxTs { - since = max(since ?? 0, m) + if let event = app.radroots.nostrNextPostStreamEvent() { + if knownIds.insert(event.id).inserted { + posts.insert(event, at: 0) + posts.sort { $0.publishedAt > $1.publishedAt } + if posts.count > 200 { + posts = Array(posts.prefix(200)) } } + } else { + try? await Task.sleep(for: .milliseconds(300)) } - - try? await Task.sleep(for: .seconds(3)) } } } diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -15,6 +15,29 @@ struct SettingsView: View { Text("No key configured") .foregroundStyle(.secondary) } + + NavigationLink { + ProfileView() + } label: { + Label("Profile", systemImage: "person.crop.circle") + } + } + + Section("Network") { + NavigationLink { + RelaysView() + } label: { + Label("Relays", systemImage: "dot.radiowaves.left.and.right") + } + } + + Section("Trade") { + if let rhi = TradeSettings.rhiPubkeyOptional { + CopyRow(title: "RHI Pubkey", value: rhi) + } else { + Text("Set RR_TRADE_RHI_PUBKEY to enable trade flows.") + .foregroundStyle(.secondary) + } } if app.hasKey { @@ -24,6 +47,8 @@ struct SettingsView: View { } label: { Label("Export Secret Hex (Danger)", systemImage: "square.and.arrow.up") } + } header: { + Text("Security") } footer: { if let exportError { Text(exportError).foregroundStyle(.red) @@ -34,6 +59,7 @@ struct SettingsView: View { } } .listStyle(.insetGrouped) + .inlineNavigationTitle("Settings") } private func exportSecretHex() { diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift @@ -8,57 +8,33 @@ struct SetupView: View { var onSuccess: (() -> Void)? = nil + @State private var step: Step = .welcome @State private var isWorking = false @State private var errorMessage: String? var body: some View { - VStack(spacing: 24) { - Image(systemName: "key.fill") - .font(.system(size: 60, weight: .bold)) - Text("Set up your Nostr Identity") - .font(.title2.weight(.semibold)) - - if let errorMessage { - Text(errorMessage) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - VStack(spacing: 12) { - Button { - generateKey() - } label: { - HStack { - if isWorking { ProgressView().padding(.trailing, 8) } - Text("Generate New Key") - .fontWeight(.semibold) + ZStack { + if step == .welcome { + SetupWelcomeView { + withAnimation(.easeInOut(duration: 0.25)) { + step = .keySetup } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(isWorking) - - Button { - importFromClipboard() - } label: { - Text("Import Secret Hex from Clipboard") - .frame(maxWidth: .infinity) } - .buttonStyle(.bordered) - .disabled(isWorking) + .transition(.opacity.combined(with: .move(edge: .leading))) } - .padding(.top, 8) - Spacer() - - Text("Your private key is stored securely in the iOS Keychain.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + if step == .keySetup { + SetupKeyView( + isWorking: isWorking, + errorMessage: errorMessage, + onGenerate: generateKey, + onImport: importFromClipboard + ) + .transition(.opacity.combined(with: .move(edge: .trailing))) + } } - .padding() - .inlineNavigationTitle("Setup") + .animation(.easeInOut(duration: 0.25), value: step) + .toolbar(.hidden, for: .navigationBar) } private func generateKey() { @@ -103,3 +79,125 @@ struct SetupView: View { } } } + +private enum Step { + case welcome + case keySetup +} + +private struct SetupWelcomeView: View { + let onContinue: () -> Void + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Circle() + .fill(Color(.systemGray5)) + .frame(width: 120, height: 120) + .overlay( + Circle() + .strokeBorder(Color(.systemGray4), lineWidth: 1) + ) + + Text(Ls.setupGreetingHeader) + .font(.title.weight(.semibold)) + .multilineTextAlignment(.center) + + Text(Ls.setupGreetingHeaderSub) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Spacer() + + Button { + onContinue() + } label: { + Text("Continue") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding() + } +} + +private struct SetupKeyView: View { + let isWorking: Bool + let errorMessage: String? + let onGenerate: () -> Void + let onImport: () -> Void + + var body: some View { + VStack(spacing: 20) { + VStack(spacing: 10) { + Image(systemName: "key.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(.secondary) + + Text("Set up your Nostr identity") + .font(.title2.weight(.semibold)) + .multilineTextAlignment(.center) + + Text("Generate a new key or import an existing secret to get started.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + if isWorking { + ProgressView() + .controlSize(.large) + } + + VStack(spacing: 12) { + Button { + onGenerate() + } label: { + HStack(spacing: 10) { + Image(systemName: "sparkles") + Text("Generate New Key") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isWorking) + + Button { + onImport() + } label: { + HStack(spacing: 10) { + Image(systemName: "doc.on.clipboard") + Text("Import Secret Hex") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isWorking) + } + .padding(.top, 4) + + Spacer() + + Text("Your private key is stored securely in the iOS Keychain.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } +} diff --git a/Radroots/Views/TradeListingCreateView.swift b/Radroots/Views/TradeListingCreateView.swift @@ -0,0 +1,285 @@ +import SwiftUI +import RadrootsKit + +struct TradeListingCreateView: View { + @EnvironmentObject private var app: AppState + @Environment(\.dismiss) private var dismiss + private var onCreated: (() -> Void)? + + @State private var draft = ListingDraftState() + @State private var isPosting = false + @State private var errorMessage: String? + @FocusState private var focusedField: Field? + + init(onCreated: (() -> Void)? = nil) { + self.onCreated = onCreated + } + + var body: some View { + Form { + Section("Farm") { + TextField("Farm pubkey", text: $draft.farmPubkey) + .textInputAutocapitalization(.none) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focusedField, equals: .farmPubkey) + .onSubmit { focusedField = .farmDTag } + + TextField("Farm id", text: $draft.farmDTag) + .textInputAutocapitalization(.none) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focusedField, equals: .farmDTag) + .onSubmit { focusedField = .title } + } + + Section("Listing") { + TextField("Title", text: $draft.title) + .textInputAutocapitalization(.words) + .submitLabel(.next) + .focused($focusedField, equals: .title) + .onSubmit { focusedField = .description } + + TextEditor(text: $draft.description) + .frame(minHeight: 120) + .focused($focusedField, equals: .description) + } + + Section("Product") { + TextField("Category", text: $draft.category) + .textInputAutocapitalization(.words) + .submitLabel(.next) + .focused($focusedField, equals: .category) + .onSubmit { focusedField = .unitPrice } + } + + Section("Pricing") { + TextField("Unit price", text: $draft.unitPrice) + .keyboardType(.decimalPad) + .focused($focusedField, equals: .unitPrice) + + TextField("Currency", text: $draft.currency) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .focused($focusedField, equals: .currency) + + HStack { + TextField("Bin size", text: $draft.binDisplayAmount) + .keyboardType(.decimalPad) + .focused($focusedField, equals: .binDisplayAmount) + Picker("Unit", selection: $draft.binDisplayUnit) { + ForEach(ListingDraftState.UnitOption.allCases, id: \.self) { unit in + Text(unit.label).tag(unit) + } + } + .pickerStyle(.menu) + } + + TextField("Bin label (optional)", text: $draft.binLabel) + .textInputAutocapitalization(.words) + .focused($focusedField, equals: .binLabel) + + TextField("Inventory available", text: $draft.inventory) + .keyboardType(.decimalPad) + .focused($focusedField, equals: .inventory) + } + + Section("Delivery") { + Picker("Method", selection: $draft.deliveryMethod) { + ForEach(ListingDraftState.DeliveryMethod.allCases, id: \.self) { method in + Text(method.label).tag(method) + } + } + + TextField("Location", text: $draft.locationPrimary) + .textInputAutocapitalization(.words) + .focused($focusedField, equals: .locationPrimary) + + TextField("City", text: $draft.locationCity) + .textInputAutocapitalization(.words) + .focused($focusedField, equals: .locationCity) + + TextField("Region", text: $draft.locationRegion) + .textInputAutocapitalization(.words) + .focused($focusedField, equals: .locationRegion) + + TextField("Country", text: $draft.locationCountry) + .textInputAutocapitalization(.characters) + .focused($focusedField, equals: .locationCountry) + } + + Section { + SectionWideButton("Publish Listing", enabled: canPublish, isProminent: true) { + publish() + } + } footer: { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } else if app.relayConnectedCount == 0 { + Text("No relays connected. Configure relays before publishing.") + .foregroundStyle(.secondary) + } + } + } + .scrollDismissesKeyboard(.interactively) + .inlineNavigationTitle("New Listing") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if isPosting { ProgressView() } + } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { focusedField = nil } + } + } + } + + private var canPublish: Bool { + app.relayConnectedCount > 0 && !isPosting && draft.isValid + } + + private func publish() { + guard let rt = app.radroots.runtime else { return } + errorMessage = nil + isPosting = true + let draftValue = draft.toTradeListingDraft() + + Task { @MainActor in + let result: Result<String, Error> = await Task.detached { @Sendable in + do { + return .success(try rt.tradeListingPublish(draft: draftValue)) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success: + isPosting = false + onCreated?() + dismiss() + case .failure(let error): + isPosting = false + errorMessage = String(describing: error) + } + } + } +} + +private enum Field: Hashable { + case farmPubkey + case farmDTag + case title + case description + case category + case unitPrice + case currency + case binDisplayAmount + case binLabel + case inventory + case locationPrimary + case locationCity + case locationRegion + case locationCountry +} + +private struct ListingDraftState { + enum UnitOption: String, CaseIterable { + case each + case lb + case oz + case g + case kg + case l + case ml + + var label: String { + switch self { + case .each: return "Each" + case .lb: return "lb" + case .oz: return "oz" + case .g: return "g" + case .kg: return "kg" + case .l: return "L" + case .ml: return "mL" + } + } + } + + enum DeliveryMethod: String, CaseIterable { + case pickup + case localDelivery + case shipping + + var label: String { + switch self { + case .pickup: return "Pickup" + case .localDelivery: return "Local delivery" + case .shipping: return "Shipping" + } + } + + var rawValueString: String { + switch self { + case .pickup: return "pickup" + case .localDelivery: return "local_delivery" + case .shipping: return "shipping" + } + } + } + + var title: String = "" + var description: String = "" + var category: String = "" + var farmPubkey: String = "" + var farmDTag: String = "" + var binDisplayUnit: UnitOption = .lb + var binDisplayAmount: String = "1" + var unitPrice: String = "" + var currency: String = "USD" + var binLabel: String = "" + var inventory: String = "" + var deliveryMethod: DeliveryMethod = .shipping + var locationPrimary: String = "" + var locationCity: String = "" + var locationRegion: String = "" + var locationCountry: String = "" + + var isValid: Bool { + !farmPubkey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !farmDTag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !category.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !binDisplayAmount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !unitPrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !currency.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !inventory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !locationPrimary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func toTradeListingDraft() -> TradeListingDraft { + let trimmedLabel = binLabel.trimmingCharacters(in: .whitespacesAndNewlines) + TradeListingDraft( + listingId: nil, + farmPubkey: farmPubkey, + farmDTag: farmDTag, + title: title, + description: description, + category: category, + binDisplayAmount: binDisplayAmount, + binDisplayUnit: binDisplayUnit.rawValue, + unitPrice: unitPrice, + currency: currency, + binLabel: trimmedLabel.isEmpty ? nil : trimmedLabel, + binId: nil, + inventory: inventory, + deliveryMethod: deliveryMethod.rawValueString, + locationPrimary: locationPrimary, + locationCity: locationCity.isEmpty ? nil : locationCity, + locationRegion: locationRegion.isEmpty ? nil : locationRegion, + locationCountry: locationCountry.isEmpty ? nil : locationCountry + ) + } + +} diff --git a/Radroots/Views/TradeListingDetailView.swift b/Radroots/Views/TradeListingDetailView.swift @@ -0,0 +1,248 @@ +import SwiftUI +import RadrootsKit + +@MainActor +final class TradeListingDetailViewModel: ObservableObject { + let listing: TradeListingSummary + @Published var messages: [TradeListingMessageSummary] = [] + @Published var isLoading = false + @Published var errorMessage: String? + @Published var orderId: String? + + init(listing: TradeListingSummary) { + self.listing = listing + } + + func refresh(app: AppState) async { + guard let rt = app.radroots.runtime else { return } + isLoading = true + errorMessage = nil + + let listingAddr = listing.listingAddr + let orderId = self.orderId + + let result: Result<[TradeListingMessageSummary], Error> = await Task.detached { @Sendable in + do { + return .success( + try rt.tradeListingFetchMessages( + listingAddr: listingAddr, + orderId: orderId, + limit: 80, + sinceUnix: nil + ) + ) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success(let items): + messages = items + isLoading = false + case .failure(let error): + errorMessage = String(describing: error) + isLoading = false + } + } +} + +struct TradeListingDetailView: View { + @EnvironmentObject private var app: AppState + let listing: TradeListingSummary + @StateObject private var vm: TradeListingDetailViewModel + @State private var showOrderSheet = false + @State private var showValidationAlert = false + @State private var validationMessage = "" + + init(listing: TradeListingSummary) { + self.listing = listing + _vm = StateObject(wrappedValue: TradeListingDetailViewModel(listing: listing)) + } + + var body: some View { + List { + Section("Listing") { + LabeledContent("Title", value: listing.title) + if !listing.description.isEmpty { + LabeledContent("Description", value: listing.description) + } + LabeledContent("Category", value: listing.productType) + if !listing.availability.isEmpty { + LabeledContent("Availability", value: listing.availability.capitalized) + } + } + + Section("Pricing") { + LabeledContent("Unit price", value: priceLine) + LabeledContent("Bin size", value: binLine) + LabeledContent("Inventory", value: listing.inventoryAvailable) + } + + Section("Delivery") { + LabeledContent("Method", value: listing.deliveryMethod.capitalized) + LabeledContent("Location", value: listing.location) + } + + Section { + SectionWideButton("Validate Listing", enabled: canUseTrade) { + sendValidationRequest() + } + + SectionWideButton("Request Order", enabled: canUseTrade, isProminent: true) { + showOrderSheet = true + } + + if let orderId = vm.orderId { + LabeledContent("Order ID", value: orderId) + .font(.footnote) + .foregroundStyle(.secondary) + } + } header: { + Text("Actions") + } footer: { + if !canUseTrade { + Text(tradeDisabledMessage) + .foregroundStyle(.secondary) + } + } + + Section("Activity") { + if let error = vm.errorMessage { + Text(error) + .foregroundStyle(.red) + .font(.footnote) + } + + if vm.messages.isEmpty { + ContentUnavailableView( + "No Activity Yet", + systemImage: "bubble.left.and.text.bubble.right", + description: Text("Validation and order updates appear here.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(vm.messages) { message in + TradeListingMessageRow(message: message) + } + } + } + } + .listStyle(.insetGrouped) + .inlineNavigationTitle(listing.title) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if vm.isLoading { ProgressView() } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await vm.refresh(app: app) } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + .task { await vm.refresh(app: app) } + .refreshable { await vm.refresh(app: app) } + .sheet(isPresented: $showOrderSheet) { + NavigationStack { + TradeOrderRequestView(listing: listing) { result in + vm.orderId = result.orderId + Task { await vm.refresh(app: app) } + } + } + } + .alert("Validation Request", isPresented: $showValidationAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(validationMessage) + } + } + + private var priceLine: String { + "\(listing.unitPriceAmount) \(listing.unitPriceCurrency) / \(listing.unitPriceUnit)" + } + + private var binLine: String { + let label = listing.binDisplayLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + let base = "\(listing.binDisplayAmount) \(listing.binDisplayUnit)" + if let label, !label.isEmpty { + return "\(base) \(label)" + } + return base + } + + private var canUseTrade: Bool { + app.relayConnectedCount > 0 && TradeSettings.rhiPubkeyOptional != nil + } + + private var tradeDisabledMessage: String { + if app.relayConnectedCount == 0 { + return "Connect to relays to use trade flows." + } + return "Set RR_TRADE_RHI_PUBKEY to enable trade requests." + } + + private func sendValidationRequest() { + guard let rt = app.radroots.runtime else { return } + guard let rhiPubkey = TradeSettings.rhiPubkeyOptional else { + validationMessage = "Set RR_TRADE_RHI_PUBKEY to enable validation." + showValidationAlert = true + return + } + + Task { @MainActor in + let result: Result<String, Error> = await Task.detached { @Sendable in + do { + return .success( + try rt.tradeListingSendValidationRequest( + listingEventId: listing.eventId, + sellerPubkey: listing.sellerPubkey, + listingId: listing.listingId, + recipientPubkey: rhiPubkey + ) + ) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success(let id): + validationMessage = "Validation request sent: \(id)" + case .failure(let error): + validationMessage = "Validation failed: \(error)" + } + showValidationAlert = true + } + } +} + +private struct TradeListingMessageRow: View { + let message: TradeListingMessageSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(message.summary) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + HStack { + Text(message.messageType.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(relativeTime(message.publishedAt)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private func relativeTime(_ unix: UInt64) -> String { + let d = Date(timeIntervalSince1970: TimeInterval(unix)) + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f.localizedString(for: d, relativeTo: Date()) + } +} diff --git a/Radroots/Views/TradeOrderRequestView.swift b/Radroots/Views/TradeOrderRequestView.swift @@ -0,0 +1,149 @@ +import SwiftUI +import Foundation +import RadrootsKit + +struct TradeOrderRequestView: View { + @EnvironmentObject private var app: AppState + @Environment(\.dismiss) private var dismiss + let listing: TradeListingSummary + private let onComplete: (TradeOrderSendResult) -> Void + + @State private var binCount: String + @State private var notes: String = "" + @State private var isSending = false + @State private var errorMessage: String? + @FocusState private var focused: Bool + + init(listing: TradeListingSummary, onComplete: @escaping (TradeOrderSendResult) -> Void) { + self.listing = listing + self.onComplete = onComplete + _binCount = State(initialValue: "1") + } + + var body: some View { + Form { + Section("Order") { + TextField("Bin count", text: $binCount) + .keyboardType(.numberPad) + .focused($focused) + LabeledContent("Bin size", value: binLine) + LabeledContent("Unit price", value: unitPriceLine) + if let total = totalPriceLine { + LabeledContent("Estimated total", value: total) + } + } + + Section("Notes") { + TextEditor(text: $notes) + .frame(minHeight: 100) + } + + Section { + SectionWideButton("Send Order Request", enabled: canSend, isProminent: true) { + sendOrder() + } + } footer: { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red) + } else if TradeSettings.rhiPubkeyOptional == nil { + Text("Set RR_TRADE_RHI_PUBKEY to enable order requests.") + .foregroundStyle(.secondary) + } + } + } + .scrollDismissesKeyboard(.interactively) + .inlineNavigationTitle("Order Request") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if isSending { ProgressView() } + } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { focused = false } + } + } + .onAppear { focused = true } + } + + private var unitPriceLine: String { + "\(listing.unitPriceAmount) \(listing.unitPriceCurrency) / \(listing.unitPriceUnit)" + } + + private var binLine: String { + let label = listing.binDisplayLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + let base = "\(listing.binDisplayAmount) \(listing.binDisplayUnit)" + if let label, !label.isEmpty { + return "\(base) \(label)" + } + return base + } + + private var canSend: Bool { + app.relayConnectedCount > 0 && + !isSending && + TradeSettings.rhiPubkeyOptional != nil && + parsedBinCount != nil + } + + private var totalPriceLine: String? { + guard let countValue = parsedBinCount, + let unitPrice = Decimal(string: listing.unitPriceAmount), + let binAmount = Decimal(string: listing.binDisplayAmount) else { + return nil + } + let count = Decimal(Int(countValue)) + let total = count * unitPrice * binAmount + return "\(total) \(listing.unitPriceCurrency)" + } + + private func sendOrder() { + guard let rt = app.radroots.runtime else { return } + guard let rhiPubkey = TradeSettings.rhiPubkeyOptional else { + errorMessage = "Missing RHI pubkey." + return + } + errorMessage = nil + isSending = true + + let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines) + guard let countValue = parsedBinCount else { + errorMessage = "Bin count must be a whole number." + isSending = false + return + } + let trimmedCount = String(countValue) + let draft = TradeOrderDraft( + listingAddr: listing.listingAddr, + sellerPubkey: listing.sellerPubkey, + binId: listing.primaryBinId, + binCount: trimmedCount, + notes: trimmedNotes.isEmpty ? nil : trimmedNotes, + orderId: nil, + recipientPubkey: rhiPubkey + ) + + Task { @MainActor in + let result: Result<TradeOrderSendResult, Error> = await Task.detached { @Sendable in + do { + return .success(try rt.tradeListingSendOrderRequest(draft: draft)) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success(let out): + isSending = false + onComplete(out) + dismiss() + case .failure(let error): + isSending = false + errorMessage = String(describing: error) + } + } + } + + private var parsedBinCount: UInt32? { + UInt32(binCount.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} diff --git a/RadrootsKit/Sources/RadrootsKit/BuildConfig.swift b/RadrootsKit/Sources/RadrootsKit/BuildConfig.swift @@ -6,6 +6,7 @@ enum BuildConfigKey: String { case logFileEnabled = "RR_LOG_FILE_ENABLED" case logFileName = "RR_LOG_FILE_NAME" case nostrRelays = "NOSTR_RELAYS" + case tradeRhiPubkey = "RR_TRADE_RHI_PUBKEY" } enum BuildConfig { diff --git a/RadrootsKit/Sources/RadrootsKit/Nostr.swift b/RadrootsKit/Sources/RadrootsKit/Nostr.swift @@ -85,7 +85,7 @@ public extension Radroots { } @MainActor -private extension Radroots { +extension Radroots { func requireRuntime() throws -> RadrootsRuntime { guard let rt = runtime else { throw RadrootsRuntimeError.runtimeNotStarted } return rt diff --git a/RadrootsKit/Sources/RadrootsKit/TradeListing.swift b/RadrootsKit/Sources/RadrootsKit/TradeListing.swift @@ -0,0 +1,59 @@ +import Foundation + +@MainActor +public extension Radroots { + func tradeListingPublish(draft: TradeListingDraft) throws -> NostrEventId { + let rt = try requireRuntime() + let id = try rt.tradeListingPublish(draft: draft) + return NostrEventId(id) + } + + func tradeListingsFetch(limit: UInt16, sinceUnix: UInt64? = nil) throws -> [TradeListingSummary] { + let rt = try requireRuntime() + return try rt.tradeListingsFetch(limit: limit, sinceUnix: sinceUnix) + } + + func tradeListingSendValidationRequest( + listingEventId: String, + sellerPubkey: String, + listingId: String, + recipientPubkey: String + ) throws -> NostrEventId { + let rt = try requireRuntime() + let id = try rt.tradeListingSendValidationRequest( + listingEventId: listingEventId, + sellerPubkey: sellerPubkey, + listingId: listingId, + recipientPubkey: recipientPubkey + ) + return NostrEventId(id) + } + + func tradeListingSendOrderRequest(draft: TradeOrderDraft) throws -> TradeOrderSendResult { + let rt = try requireRuntime() + return try rt.tradeListingSendOrderRequest(draft: draft) + } + + func tradeListingFetchMessages( + listingAddr: String, + orderId: String? = nil, + limit: UInt16, + sinceUnix: UInt64? = nil + ) throws -> [TradeListingMessageSummary] { + let rt = try requireRuntime() + return try rt.tradeListingFetchMessages( + listingAddr: listingAddr, + orderId: orderId, + limit: limit, + sinceUnix: sinceUnix + ) + } +} + +extension TradeListingSummary: Identifiable { + public var id: String { eventId } +} + +extension TradeListingMessageSummary: Identifiable { + public var id: String { eventId } +} diff --git a/RadrootsKit/Sources/RadrootsKit/TradeSettings.swift b/RadrootsKit/Sources/RadrootsKit/TradeSettings.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum TradeSettingsError: LocalizedError { + case noRhiPubkeyConfigured + + public var errorDescription: String? { + "No trade RHI pubkey configured. Set build setting 'RR_TRADE_RHI_PUBKEY'." + } +} + +public enum TradeSettings { + public static func rhiPubkey() throws -> String { + guard let value = rhiPubkeyOptional else { + throw TradeSettingsError.noRhiPubkeyConfigured + } + return value + } + + public static var rhiPubkeyOptional: String? { + BuildConfig.string(.tradeRhiPubkey) + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap { $0.isEmpty ? nil : $0 } + } +}