mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-08 22:33:26 +01:00
277 lines
9.5 KiB
Swift
277 lines
9.5 KiB
Swift
|
|
//
|
||
|
|
// 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<Permission: AppPermissionProtocol>: 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(.title2)
|
||
|
|
.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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|