feat(apple): contacts screen and consent prompts

Device list, detail, and block list under Settings, plus the two sheets:
an incoming offer and a device asking to be remembered. Both are
answer-only, since swiping away would leave the sender waiting.

Accepting an offer routes the released ticket into the ordinary receive
path, so the platform still chooses the destination. The prompt does not
ask a second time; it only falls back to the review sheet when the
destination is unusable.
This commit is contained in:
2026-08-06 17:57:54 +02:00
parent 256ff89423
commit 1369df4578
7 changed files with 535 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ enum SettingsSection: Hashable {
case appearance
case notifications
case network
case contacts
case storage
case about
case bugReport
@@ -19,6 +20,7 @@ enum SettingsSection: Hashable {
case .appearance: return L10n.Appearance.title
case .notifications: return L10n.Notifications.title
case .network: return L10n.Settings.networkTitle
case .contacts: return L10n.Contacts.title
case .storage: return L10n.Storage.title
case .about: return L10n.About.title
case .bugReport: return L10n.About.bugReport

View File

@@ -5,6 +5,7 @@ import SFSafeSymbols
/// navigation. The model stays the source of truth via a derived path binding.
struct SettingsScreen: View {
@ObservedObject var model: SettingsModel
@ObservedObject var contacts: ContactsModel
let windowClass: WindowClass
@State private var showBugReport = false
@@ -54,6 +55,15 @@ struct SettingsScreen: View {
NavigationLink(value: SettingsSection.storage) {
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
}
NavigationLink(value: SettingsSection.contacts) {
SettingsRow(
icon: .laptopcomputerAndIphone,
title: String(localized: L10n.Contacts.title),
value: contacts.state.contacts.isEmpty
? nil
: String(contacts.state.contacts.count)
)
}
}
Section(String(localized: L10n.Settings.advancedTitle)) {
NavigationLink(value: SettingsSection.network) {
@@ -80,6 +90,17 @@ struct SettingsScreen: View {
@ViewBuilder
private func sectionForm(_ section: SettingsSection) -> some View {
// Contacts brings its own Form and push destination, so it is not wrapped
// in the shared section chrome.
if section == .contacts {
ContactsScreen(model: contacts)
} else {
settingsSectionForm(section)
}
}
@ViewBuilder
private func settingsSectionForm(_ section: SettingsSection) -> some View {
let content = Form {
SettingsSectionContent(model: model, section: section)
}
@@ -130,6 +151,9 @@ private struct SettingsSectionContent: View {
NetworkSettings(model: model)
case .storage:
StorageSettings(model: model)
case .contacts:
// Rendered by SettingsScreen itself, which owns the contacts model.
EmptyView()
case .about:
AboutSettings(model: model)
case .bugReport: