feat(apple): add the contacts feature model

MVVM model for the device list, its detail, and the two consent prompts.
Accepting an offer is the only path that returns a ticket, matching the
core: a declined offer hands over nothing.

Grant lifetime lives in preferences because the core holds it in memory
only, so it is pushed back on every start rather than silently reverting to
the default.
This commit is contained in:
2026-08-06 17:48:18 +02:00
parent 3c89c34c6e
commit 256ff89423
3 changed files with 449 additions and 1 deletions

View File

@@ -122,6 +122,8 @@ struct AppPreferences: Equatable {
var themeMode: ThemeMode
var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration
/// Idle lifetime applied to grants this device issues from now on.
var grantLifetime: GrantLifetimeOption
}
struct AppPreferencesDefaults {
@@ -145,6 +147,7 @@ final class AppPreferencesRepository: ObservableObject {
static let themeMode = "theme_mode"
static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration"
static let grantLifetime = "grant_lifetime"
}
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
@@ -158,12 +161,15 @@ final class AppPreferencesRepository: ObservableObject {
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
return AppPreferences(
username: username,
receiveFolder: folder,
themeMode: themeMode,
diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults)
relayConfiguration: resolveRelayConfiguration(defaults),
grantLifetime: grantLifetime
)
}
@@ -209,6 +215,11 @@ final class AppPreferencesRepository: ObservableObject {
setReceiveFolder(fallback.receiveFolder)
}
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
reload()
}
func setThemeMode(_ mode: ThemeMode) {
defaults.set(mode.rawValue, forKey: Key.themeMode)
reload()

View File

@@ -0,0 +1,212 @@
import Combine
import Foundation
struct ContactsState: Equatable {
var contacts: [DeviceContact] = []
var blocked: [String] = []
var pendingPairings: [PendingPairingModel] = []
var pendingOffers: [IncomingOfferModel] = []
var grantLifetime: GrantLifetimeOption = .days90
var isLoading = false
/// Endpoints with an in-flight decision, so a row can disable itself without
/// blocking the rest of the list.
var busyEndpoints: Set<String> = []
var busyOfferIds: Set<String> = []
var selectedEndpointId: String?
var selected: DeviceContact? {
guard let selectedEndpointId else { return nil }
return contacts.first { $0.endpointId == selectedEndpointId }
}
/// One prompt at a time: pairing consent is a modal decision and stacking
/// sheets on top of each other reads as a loop of dialogs.
var currentPairing: PendingPairingModel? { pendingPairings.first }
var currentOffer: IncomingOfferModel? { pendingOffers.first }
}
/// Drives the device-history surfaces: the list, its detail, and the two
/// consent prompts. Ported in the MVVM shape used by the other feature models.
@MainActor
final class ContactsModel: ObservableObject {
@Published private(set) var state = ContactsState()
private let repository: CoreGateway
private let messages: UiMessageController
private let preferences: AppPreferencesRepository
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
messages: UiMessageController,
preferences: AppPreferencesRepository
) {
self.repository = repository
self.messages = messages
self.preferences = preferences
state.grantLifetime = preferences.preferences.grantLifetime
repository.signals
.sink { [weak self] signal in
guard let self else { return }
switch signal {
case .contactsChanged:
Task { await self.refresh() }
case .offersChanged:
Task { await self.refreshOffers() }
case .approvalChanged, .receiverHistoryChanged, .transfersChanged:
break
}
}
.store(in: &cancellables)
repository.statePublisher
.map(\.isInitialized)
.removeDuplicates()
.sink { [weak self] isInitialized in
guard let self, isInitialized else { return }
// The core owns the lifetime; push the stored preference on start
// so a restart does not silently fall back to the default.
Task {
await self.repository.setGrantLifetime(self.state.grantLifetime)
await self.refresh()
}
}
.store(in: &cancellables)
}
// MARK: - Loading
func refresh() async {
state.isLoading = true
defer { state.isLoading = false }
switch await repository.contacts() {
case .success(let contacts):
state.contacts = contacts
case .failure(let error):
messages.error(error)
}
if case .success(let blocked) = await repository.blockedContacts() {
state.blocked = blocked
}
state.pendingPairings = await repository.pendingPairings()
await refreshOffers()
}
func refreshOffers() async {
state.pendingOffers = await repository.pendingOffers()
}
// MARK: - Selection
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
// MARK: - Pairing consent
/// Agree to be reachable by a device, typically right after a transfer.
func allowDeviceToReachMe(endpointId: String, displayName: String?) async {
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
if case .failure(let error) = await repository.allowDeviceToReachMe(
endpointId: endpointId,
displayName: displayName
) {
messages.error(error)
return
}
await refresh()
}
/// Answer a device's offer to be remembered.
func respondToPairing(endpointId: String, accepted: Bool) async {
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
switch await repository.respondToPairing(endpointId: endpointId, accepted: accepted) {
case .success:
// Drop the prompt immediately: the core has already consumed it, and
// leaving it on screen invites a second answer that does nothing.
state.pendingPairings.removeAll { $0.endpointId == endpointId }
if accepted { await refresh() }
case .failure(let error):
messages.error(error)
}
}
// MARK: - Incoming offers
/// Answer an incoming offer. Returns the ticket when accepted so the caller
/// can run the receive with a platform-appropriate destination; the core
/// releases it only on acceptance.
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
state.busyOfferIds.insert(offerId)
defer { state.busyOfferIds.remove(offerId) }
let ticket = await repository.respondToOffer(offerId: offerId, accepted: accepted)
state.pendingOffers.removeAll { $0.offerId == offerId }
return ticket
}
// MARK: - Management
func setLabel(endpointId: String, label: String) async {
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
if case .failure(let error) = await repository.setContactLabel(
endpointId: endpointId,
label: trimmed.isEmpty ? nil : trimmed
) {
messages.error(error)
return
}
await refresh()
}
func forget(endpointId: String) async {
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
if case .failure(let error) = await repository.forgetContact(endpointId: endpointId) {
messages.error(error)
return
}
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
await refresh()
}
func forgetAll() async {
if case .failure(let error) = await repository.forgetAllContacts() {
messages.error(error)
return
}
state.selectedEndpointId = nil
await refresh()
}
func block(endpointId: String) async {
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
if case .failure(let error) = await repository.blockContact(endpointId: endpointId) {
messages.error(error)
return
}
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
await refresh()
}
func unblock(endpointId: String) async {
if case .failure(let error) = await repository.unblockContact(endpointId: endpointId) {
messages.error(error)
return
}
await refresh()
}
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
state.grantLifetime = lifetime
preferences.setGrantLifetime(lifetime)
Task { await repository.setGrantLifetime(lifetime) }
}
}