// // AppPermissionsCard.swift // AltStore // // Created by Riley Testut on 5/4/23. // Copyright © 2023 Riley Testut. All rights reserved. // import SwiftUI import AltStoreCore @available(iOS 16, *) extension AppPermissionsCard { private struct TransitionKey: Hashable { static func name(_ permission: Permission) -> TransitionKey { TransitionKey(key: "name", permission: permission) } static func icon(_ permission: Permission) -> TransitionKey { TransitionKey(key: "icon", permission: permission) } let key: String let permission: Permission private init(key: String, permission: Permission) { self.key = key self.permission = permission } } } @available(iOS 16, *) struct AppPermissionsCard: View { let title: LocalizedStringKey let description: LocalizedStringKey let tintColor: Color let permissions: [Permission] @State private var selectedPermission: Permission? @Namespace private var animation private var isTitleVisible: Bool { if selectedPermission == nil { // Title should always be visible when showing all permissions. return true } // If showing permission details, only show title if there // are more than 2 permissions total to save vertical space. let isTitleVisible = permissions.count > 2 return isTitleVisible } var body: some View { let title = Text(title) .font(.title3) .bold() .minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation. VStack(spacing: 8) { if isTitleVisible { // If title is visible, place _outside_ `content` // to avoid being covered by permissionDetailView. title } let content = VStack(spacing: 8) { if !isTitleVisible { // Place title inside `content` when not visible // so it's covered by permissionDetailView. title } VStack(spacing: 20) { Text(description) .font(.subheadline) .fixedSize(horizontal: false, vertical: true) Grid(verticalSpacing: 15) { ForEach(permissions, id: \.self) { permission in permissionRow(for: permission) } } Text("Tap a permission to learn more.") .font(.subheadline) .fixedSize(horizontal: false, vertical: true) } } if let selectedPermission { // Hide content with overlay to preserve existing size. content.hidden().overlay { permissionDetailView(for: selectedPermission) } } else { content } } .overlay(alignment: .topTrailing) { if selectedPermission != nil { Image(systemName: "xmark.circle.fill") .imageScale(.medium) } } .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(20) .overlay { if selectedPermission != nil { // Make entire view tappable when overlay is visible. SwiftUI.Button(action: hidePermission) { VStack {} .frame(maxWidth: .infinity, maxHeight: .infinity) } } } .foregroundColor(.secondary) // Vibrancy .background(.regularMaterial) // Blur background for auto-legibility correction. .background(tintColor, in: RoundedRectangle(cornerRadius: 30, style: .continuous)) } @ViewBuilder private func permissionRow(for permission: Permission) -> some View { GridRow { SwiftUI.Button(action: { show(permission) }) { HStack { let text = Text(permission.localizedDisplayName) .font(.body) .bold() .minimumScaleFactor(0.33) .lineLimit(.max) // Setting lineLimit to anything fixes text wrapping at large text sizes. let image = Image(systemName: permission.effectiveSymbolName) .gridColumnAlignment(.center) if selectedPermission != nil { Label(title: { text }, icon: { image }) .hidden() } else { Label { text.matchedGeometryEffect(id: TransitionKey.name(permission), in: animation) } icon: { image.matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation) } } Spacer() Image(systemName: "info.circle") .imageScale(.large) } .contentShape(Rectangle()) // Make entire HStack tappable. } } .frame(minHeight: 30) // Make row tall enough to tap. } @ViewBuilder private func permissionDetailView(for permission: Permission) -> some View { VStack(spacing: 15) { Image(systemName: permission.effectiveSymbolName) .font(.largeTitle) .fixedSize(horizontal: false, vertical: true) .matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation) Text(permission.localizedDisplayName) .font(.title2) .bold() .minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation. .matchedGeometryEffect(id: TransitionKey.name(permission), in: animation) if let usageDescription = permission.usageDescription { Text(usageDescription) .font(.subheadline) .minimumScaleFactor(0.75) } } .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, maxHeight: .infinity) } init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission]) { self.init(title: title, description: description, tintColor: tintColor, permissions: permissions, selectedPermission: nil) } fileprivate init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission], selectedPermission: Permission? = nil) { self.title = title self.description = description self.tintColor = tintColor self.permissions = permissions // Set _selectedPermission directly or else the preview won't detect it. self._selectedPermission = State(initialValue: selectedPermission) } } @available(iOS 16, *) private extension AppPermissionsCard { func show(_ permission: Permission) { withAnimation { self.selectedPermission = permission } } func hidePermission() { withAnimation { self.selectedPermission = nil } } } @available(iOS 16, *) struct AppPermissionsCard_Previews: PreviewProvider { static var previews: some View { let appPermissions = [ PreviewAppPermission(permission: ALTAppPrivacyPermission.localNetwork), PreviewAppPermission(permission: ALTAppPrivacyPermission.microphone), PreviewAppPermission(permission: ALTAppPrivacyPermission.photos), PreviewAppPermission(permission: ALTAppPrivacyPermission.camera), PreviewAppPermission(permission: ALTAppPrivacyPermission.faceID), PreviewAppPermission(permission: ALTAppPrivacyPermission.appleMusic), PreviewAppPermission(permission: ALTAppPrivacyPermission.bluetooth), PreviewAppPermission(permission: ALTAppPrivacyPermission.calendars), ] let tintColor = Color(uiColor: .deltaPrimary!) return ForEach(1...8, id: \.self) { index in AppPermissionsCard(title: "Privacy", description: "Delta may request access to the following:", tintColor: tintColor, permissions: Array(appPermissions.prefix(index))) .frame(width: 350) .previewLayout(.sizeThatFits) AppPermissionsCard(title: "Privacy", description: "Delta may request access to the following:", tintColor: tintColor, permissions: Array(appPermissions.prefix(index)), selectedPermission: appPermissions.first) .frame(width: 350) .previewLayout(.sizeThatFits) } } }