Files
openclaw/apps/ios/Sources/Permissions/DevicePermissionRow.swift
Peter Steinberger d2b382528e feat(ios): guide first launch through pairing explainer and permissions (#106129)
* feat(ios): guide first launch through pairing explainer and permissions

* fix(ios): keep first-run flow resumable and surface limited grants in onboarding

* chore(ios): regenerate native i18n after rebase

* chore(android): project refreshed native translations into strings.xml

* fix(ios): move i18n wrapper contract to shared permission row and sync catalog
2026-07-13 00:35:31 -07:00

143 lines
5.5 KiB
Swift

import SwiftUI
/// Capsule action used by permission rows: filled for the initial "Allow",
/// bordered for repair actions like "Open Settings" or "Upgrade".
struct DevicePermissionActionButtonStyle: ButtonStyle {
let prominent: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(OpenClawType.footnoteSemiBold)
.padding(.horizontal, 14)
.frame(height: 32)
.foregroundStyle(
self.prominent
? OpenClawBrand.activationPrimaryActionText
: OpenClawBrand.activationPrimaryAction)
.background {
if self.prominent {
Capsule(style: .continuous)
.fill(OpenClawBrand.activationPrimaryGradient)
} else {
Capsule(style: .continuous)
.fill(OpenClawBrand.activationNeutralSurface)
}
}
.overlay {
Capsule(style: .continuous)
.stroke(
self.prominent
? Color.white.opacity(0.26)
: OpenClawBrand.activationNeutralStroke,
lineWidth: 0.5)
}
.opacity(configuration.isPressed ? 0.86 : 1)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.smooth(duration: 0.12), value: configuration.isPressed)
}
}
/// One device permission with an icon tile, explanation, and a single clear
/// affordance: Allow when unset, a green check when granted, a repair action otherwise.
struct DevicePermissionRow: View {
let identifierPrefix: String
let identifier: String
let symbol: String
let tint: Color
let title: LocalizedStringResource
let detail: LocalizedStringResource
let grant: DevicePermissionGrant
var isRequesting: Bool = false
var statusLabel: LocalizedStringResource?
var actionTitle: LocalizedStringResource?
var action: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
self.iconTile
VStack(alignment: .leading, spacing: 2) {
Text(self.title)
.font(OpenClawType.subheadSemiBold)
Text(self.detail)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
self.trailingControl
}
.padding(.vertical, 6)
}
private var isGrantedTile: Bool {
self.grant == .granted || self.grant == .limited
}
private var iconTile: some View {
Image(systemName: self.symbol)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(self.isGrantedTile ? Color.white : self.tint)
.frame(width: 36, height: 36)
.background {
RoundedRectangle(cornerRadius: 11, style: .continuous)
.fill(self.isGrantedTile ? AnyShapeStyle(self.tint) : AnyShapeStyle(self.tint.opacity(0.14)))
}
.accessibilityHidden(true)
}
@ViewBuilder
private var trailingControl: some View {
if self.isRequesting {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
.frame(height: 32)
} else {
VStack(alignment: .trailing, spacing: 4) {
if let actionTitle = self.actionTitle, let action = self.action {
Button(action: action) {
Text(actionTitle)
.font(OpenClawType.footnoteSemiBold)
}
.buttonStyle(DevicePermissionActionButtonStyle(prominent: self.grant == .notRequested))
.accessibilityIdentifier("\(self.identifierPrefix)-\(self.identifier)-action")
} else if self.grant == .granted || self.grant == .limited {
Image(systemName: "checkmark.circle.fill")
.font(OpenClawType.title3SemiBold)
.foregroundStyle(OpenClawBrand.ok)
.accessibilityLabel(Text(self.statusLabel ?? LocalizedStringResource("Allowed")))
.accessibilityIdentifier("\(self.identifierPrefix)-\(self.identifier)-status")
}
if let statusCaption {
Text(statusCaption)
.font(OpenClawType.caption2Medium)
.foregroundStyle(self.statusCaptionColor)
.accessibilityIdentifier("\(self.identifierPrefix)-\(self.identifier)-status")
}
}
}
}
/// Shown under the action for partial or blocked states so the row explains
/// why a repair action (Upgrade / Open Settings) is offered.
private var statusCaption: LocalizedStringResource? {
guard self.actionTitle != nil else { return nil }
switch self.grant {
case .limited:
return self.statusLabel ?? LocalizedStringResource("Limited")
case .denied:
return self.statusLabel ?? LocalizedStringResource("Off in Settings")
case .granted, .notRequested:
return nil
}
}
private var statusCaptionColor: Color {
self.grant == .denied ? OpenClawBrand.danger : OpenClawBrand.warn
}
}