Files
nxnative-easy/desktop/NxNativeEasy/LibraryWorkspace.swift
T

361 lines
28 KiB
Swift

import SwiftUI
import AppKit
struct LibraryWorkspace:View {
@ObservedObject var model:BuildModel
@ObservedObject var library:GameLibrary
@ObservedObject var updater:AppUpdater
@ObservedObject var sideload:SideloadModel
private enum Page {case library,prepare,devices,settings}
@State private var page=Page.library
private var preparing:Bool {page == .prepare}
@State private var query=""
@State private var selectedGameID:String?
@State private var rebuildAdvice:[String:String]=[:]
@State private var adviceGeneration=UUID()
@State private var removing:OwnedGameOutput?
@Environment(\.scenePhase) private var scenePhase
private let accent=Color(red:0.79,green:0.95,blue:0.37)
private var occupied:Bool {model.running || updater.busy || sideload.busy}
private func name(_ game:OwnedGameOutput)->String {model.catalog.first(where:{$0.id==game.titleID})?.name ?? game.folder.lastPathComponent}
private var results:[OwnedGameOutput] {library.games.filter {query.isEmpty || (name($0)+" "+($0.titleID ?? "")+" "+$0.item.folder).localizedCaseInsensitiveContains(query)}}
private var selectedResult:OwnedGameOutput? {results.first(where:{$0.id==selectedGameID}) ?? results.first}
private func needsRebuild(_ game:OwnedGameOutput)->Bool {["Mac","iPhone / iPad"].contains {rebuildAdvice[game.id+$0]=="rebuild"}}
private func configure(_ game:OwnedGameOutput) {
guard !occupied else {return}
model.folder=game.folder;model.defaults.set(game.item.folder,forKey:"lastGameFolder")
model.output=nil;model.assets=nil;model.target=game.macApp != nil ? "mac" : game.iosIPA != nil ? "ios" : ""
if model.target=="ios" {model.splitIOS=game.romfs != nil}
model.restoreOutput();model.preparationStep=0;page = .prepare
}
private func install(_ game:OwnedGameOutput) {
guard !occupied,let ipa=game.iosIPA else {return}
sideload.configure(ipa:ipa,folder:game.folder,romfs:game.romfs);model.showSideload=true
}
private func add() {
guard !occupied else {return}
model.folder=nil;model.target="";model.failed=false;model.preparationStep=0;page = .prepare
}
private func remember() {
guard let folder=model.folder else {return}
do {try library.add(folder)}catch{library.error="No pudimos añadir esa carpeta a la biblioteca. Tus archivos siguen intactos."}
}
private func checkBuildAdvice(_ snapshot:[OwnedGameOutput]?=nil) {
let generation=UUID();adviceGeneration=generation
let games=snapshot ?? library.games,repo=model.repo
Task {
let advice=await Task.detached(priority:.utility) { ()->[String:String] in
var result:[String:String]=[:]
for game in games {
guard let id=game.titleID else {continue}
for (platform,revision,ready) in [("Mac",game.macRevision,game.macApp != nil),("iPhone / iPad",game.iosRevision,game.iosIPA != nil)] where ready {
let value=BuildProvenance.recommendation(repo:repo,revision:revision,titleID:id)
if value==true {result[game.id+platform]="rebuild"}
else if value==nil {result[game.id+platform]="unknown"}
}
}
return result
}.value
if adviceGeneration==generation {rebuildAdvice=advice}
}
}
var body:some View {
VStack(spacing:0) {
HStack(spacing:0) {
VStack(alignment:.leading,spacing:22) {
HStack(spacing:8) {Image(nsImage:AppArtwork.icon).resizable().frame(width:30,height:30);Text("nxnative\neasy").font(.headline).lineSpacing(-2)}.foregroundStyle(accent).padding(.bottom,12)
nav("Mi biblioteca",icon:"square.grid.2x2",selected:page == .library){page = .library}
nav("Preparar juego",icon:"hammer",selected:preparing){page = .prepare}
Divider()
nav("Dispositivos",icon:"ipad.and.iphone",selected:page == .devices){page = .devices}
nav("Compatibilidad",icon:"checklist",selected:false){model.openCatalog()}
Spacer()
Divider()
nav("Configuración",icon:"gearshape",selected:page == .settings){page = .settings}
}.padding(20).frame(width:190).frame(maxHeight:.infinity).background(.white.opacity(0.025))
Divider()
ZStack {
// Keep one preparation model/view alive: its task, logs, cancellation,
// update checks and sheets continue when browsing the library.
ContentView(updater:updater,sideload:sideload,model:model)
.opacity(preparing ? 1 : 0).allowsHitTesting(preparing).accessibilityHidden(!preparing)
if page == .settings {WorkspaceSettingsView(model:model,sideload:sideload)}
if page == .devices {
SideloadLibraryView(model:sideload,text:model.text,canInstall:!model.running && !updater.busy,prepareGame:{
page = .prepare;model.folder=sideload.hasPendingUpdate ? sideload.gameFolder : nil;model.failed=false;model.preparationStep=0;model.target="ios"
},embedded:true,artwork:{app in
guard let id=sideload.titleID(for:app) else {return nil}
return GameArtworkCache.image(model.catalog.first(where:{$0.id==id})?.artworkURL)
})
}
if page == .library {
VStack(alignment:.leading,spacing:20) {
HStack(alignment:.top) {
VStack(alignment:.leading,spacing:7) {Text(model.text("Mi biblioteca")).font(.system(size:30,weight:.bold,design:.rounded));Text(model.text("Tus juegos. Tus archivos. Todo en un lugar.")).foregroundStyle(.secondary)}
Spacer();Button {add()} label:{Label(model.text("Añadir juego"),systemImage:"plus")}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(occupied)
}
HStack {
TextField(model.text("Buscar en mis juegos"),text:$query).textFieldStyle(.roundedBorder)
Text(String(library.games.count)+" "+model.text(library.games.count==1 ? "juego" : "juegos")).font(.caption).foregroundStyle(.secondary)
Button {library.refresh()} label:{Image(systemName:"arrow.clockwise")}.help(model.text("Actualizar biblioteca")).accessibilityLabel(model.text("Actualizar biblioteca"))
}
if updater.visible {
Button {page = .prepare} label:{Label(model.text(updater.busy ? updater.phaseText : "Hay una nueva versión"),systemImage:"arrow.down.circle")}.buttonStyle(.link)
}
if let error=library.error {Text(model.text(error)).font(.callout).foregroundStyle(.orange).textSelection(.enabled)}
if sideload.showsRefreshNotice {Button(model.text("Revisar renovación")){page = .devices}.disabled(sideload.busy)}
if library.games.isEmpty {
VStack(spacing:16) {
Image(systemName:"square.stack.3d.up").font(.system(size:42,weight:.light)).foregroundStyle(accent)
Text(model.text("Tu colección empieza con tu primer juego")).font(.title2.bold())
Text(model.text("Añade su carpeta. Conservamos los archivos donde están y recordamos tus versiones para Mac, iPhone y iPad.")).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth:440)
Button(model.text("Añadir juego")){add()}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(occupied)
Button(model.text("Ver juegos compatibles")){model.openCatalog()}.buttonStyle(.link)
}.frame(maxWidth:.infinity,maxHeight:.infinity)
} else if results.isEmpty {Text(model.text("No hay juegos que coincidan con tu búsqueda.")).foregroundStyle(.secondary).frame(maxWidth:.infinity,maxHeight:.infinity)}
else {
HStack(alignment:.top,spacing:0) {
ScrollView {
LazyVStack(spacing:5) {
ForEach(results) {game in
Button {selectedGameID=game.id} label:{
HStack(spacing:9) {
if let image=GameArtworkCache.image(model.catalog.first(where:{$0.id==game.titleID})?.artworkURL) {Image(nsImage:image).resizable().scaledToFill().frame(width:42,height:42).clipped().clipShape(RoundedRectangle(cornerRadius:6))}
else {Image(systemName:"gamecontroller").frame(width:42,height:42)}
VStack(alignment:.leading,spacing:3) {Text(name(game)).font(.system(size:12,weight:.semibold)).lineLimit(2);Text(model.text(needsRebuild(game) ? "Conviene recompilar" : game.ready ? "Preparado" : "Sin preparar")).font(.caption2).foregroundStyle(needsRebuild(game) ? Color.orange : .secondary)}
Spacer(minLength:0)
}.padding(8).background(selectedResult?.id==game.id ? accent.opacity(0.12) : .clear,in:RoundedRectangle(cornerRadius:8))
}.buttonStyle(.plain)
}
}.padding(.trailing,12)
}.frame(width:210)
Divider()
if let game=selectedResult {
ScrollView {
GameLibraryDetail(game:game,title:name(game),catalog:model.catalog.first(where:{$0.id==game.titleID}),text:model.text,building:model.running && model.folder?.resolvingSymlinksInPath().path==game.folder.resolvingSymlinksInPath().path,disabled:occupied,observations:sideload.observations(titleID:game.titleID,originalID:game.originalBundleID),isVerified:sideload.isVerified,needsRebuild:needsRebuild(game),advice:["Mac","iPhone / iPad"].compactMap {platform in rebuildAdvice[game.id+platform].map {value in platform+": "+model.text(value=="rebuild" ? "Conviene recompilar: cambiaron herramientas o el perfil de este juego." : "No hay historial suficiente para comparar esta preparación.") }},checkDevices:{if sideload.devices.count>1 && sideload.device.isEmpty {page = .devices}else{sideload.listDevices()}},open:{if let mac=game.macApp {NSWorkspace.shared.open(mac)}},prepare:{configure(game)},install:{install(game)},remove:{removing=game})
}.padding(.leading,20)
}
}
}
Text(model.text("Esta biblioteca sólo contiene las carpetas que tú añades. No incluye ni descarga juegos.")).font(.caption).foregroundStyle(.secondary)
}.padding(28).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.topLeading)
}
}
}
if model.running || model.failed || !model.activity.history.isEmpty {
Divider()
BuildDock(model:model,show:{page = .prepare;model.showDetails=true})
}
}.frame(minWidth:1000,minHeight:640).tint(accent).background(Color(red:0.065,green:0.075,blue:0.07)).preferredColorScheme(.dark)
.task {
if model.folder != nil && library.games.isEmpty && library.error==nil {remember()}
checkBuildAdvice()
if !occupied && !library.games.isEmpty {sideload.listDevices()}
}
.dropDestination(for:URL.self) {items,_ in
guard !occupied else {return false}
if page != .prepare {model.target="";model.folder=nil;model.preparationStep=0}
model.acceptDrop(items);if !model.failed {remember()};page = .prepare;return true
}
.onReceive(NotificationCenter.default.publisher(for:Notification.Name("nxnative.openSettings"))){_ in page = .settings}
.onReceive(NotificationCenter.default.publisher(for:Notification.Name("nxnative.showLibrary"))){_ in page = .library;if !sideload.busy {model.showSideload=false};library.refresh()}
.onReceive(NotificationCenter.default.publisher(for:Notification.Name("nxnative.showBuildActivity"))){_ in page = .prepare;model.preparationStep=4;model.showDetails=true}
.onChange(of:model.showDeviceLibrary){_,show in if show {page = .devices;model.showDeviceLibrary=false}}
.onReceive(library.$games){games in checkBuildAdvice(games);sideload.preparedFolders=Dictionary(games.compactMap {game in game.titleID.map {($0,game.folder)}},uniquingKeysWith:{first,_ in first});sideload.preparedIdentifiers=games.compactMap {game in game.originalBundleID ?? game.titleID.map {"org.nxnative."+$0.lowercased()}}}
.onChange(of:model.folder){_,_ in if model.folder != nil {remember()}}
.onChange(of:model.running){old,new in
if old && !new {
if !model.failed,model.output != nil,let folder=model.folder,let revision=model.buildRevision {library.recordBuild(folder:folder,target:model.target,revision:revision)}
library.refresh();checkBuildAdvice()
}
}
.onChange(of:scenePhase){_,phase in if phase == .active {library.refresh()}}
.alert(model.text("¿Quitar de la biblioteca?"),isPresented:Binding(get:{removing != nil},set:{if !$0 {removing=nil}})) {
Button(model.text("Cancelar"),role:.cancel){removing=nil}
Button(model.text("Quitar de esta lista"),role:.destructive){if let removing {library.remove(removing.id)};removing=nil}
} message:{Text(model.text("Sólo quitamos el acceso de la biblioteca. No borramos juegos, cachés, llaves ni partidas."))}
}
private func nav(_ title:String,icon:String,selected:Bool,action:@escaping()->Void)->some View {
Button(action:action){Label(model.text(title),systemImage:icon).font(.system(size:12,weight:selected ? .semibold : .regular)).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,10).padding(.horizontal,9).background(selected ? accent.opacity(0.1) : .clear,in:RoundedRectangle(cornerRadius:9)).foregroundStyle(selected ? accent : Color.primary)}.buttonStyle(.plain)
}
}
struct WorkspaceSettingsView:View {
@ObservedObject var model:BuildModel
@ObservedObject var sideload:SideloadModel
enum Section:String,CaseIterable {case general,apple,controllers}
@State var section=Section.general
@State private var copiedDiagnostics=false
@AppStorage("buildNotifications") private var buildNotifications=true
private var version:String {Bundle.main.object(forInfoDictionaryKey:"CFBundleShortVersionString") as? String ?? "development"}
private func title(_ section:Section)->String {
switch section {case .general:return "General";case .apple:return "Cuenta Apple";case .controllers:return "Control"}
}
var body:some View {
VStack(alignment:.leading,spacing:0) {
VStack(alignment:.leading,spacing:20) {
Text(model.text("Configuración")).font(.system(size:30,weight:.bold,design:.rounded))
Picker(model.text("Sección de configuración"),selection:$section) {
ForEach(Section.allCases,id:\.self) {item in Text(model.text(title(item))).tag(item)}
}.pickerStyle(.segmented).labelsHidden()
}.padding(28)
Divider()
switch section {
case .general:
ScrollView {
VStack(alignment:.leading,spacing:24) {
VStack(alignment:.leading,spacing:20) {
Label(model.text("Personaliza la app"),systemImage:"slider.horizontal.3").font(.headline)
HStack {
VStack(alignment:.leading,spacing:5) {Text(model.text("Idioma de la interfaz")).font(.callout.bold());Text(model.text("El idioma del juego se configura por separado.")).font(.caption).foregroundStyle(.secondary)}
Spacer()
Picker(model.text("Idioma de la interfaz"),selection:$model.language){Text("English").tag(EasyLanguage.english);Text("Español").tag(EasyLanguage.spanish)}.labelsHidden().frame(width:180)
}
Divider()
Toggle(model.text("Notificar cuando termine una preparación"),isOn:$buildNotifications).toggleStyle(.switch)
}.padding(22).frame(maxWidth:.infinity,alignment:.leading).background(.white.opacity(0.035),in:RoundedRectangle(cornerRadius:16))
VStack(alignment:.leading,spacing:16) {
Label(model.text("Ayuda y diagnóstico"),systemImage:"lifepreserver").font(.headline)
Text(model.text("Comparte un resumen de la app y del último proceso, con datos sensibles ocultos.")).font(.callout).foregroundStyle(.secondary)
Button(model.text(copiedDiagnostics ? "Diagnóstico copiado" : "Copiar diagnóstico")){model.copyDiagnostics();copiedDiagnostics=true}
}.padding(22).frame(maxWidth:.infinity,alignment:.leading).background(.white.opacity(0.035),in:RoundedRectangle(cornerRadius:16))
Text("nxnative Easy · "+version).font(.caption).foregroundStyle(.secondary)
}.padding(28)
}
case .apple:
SideloadView(model:sideload,text:model.text,accountOnly:true,embedded:true)
case .controllers:
ScrollView {
VStack(alignment:.leading,spacing:12) {
Text(model.text("Prueba el control conectado a este Mac sin abrir ni preparar un juego.")).font(.callout).foregroundStyle(.secondary).padding(.horizontal,26).padding(.top,24)
ControllerTestView(text:model.text,embedded:true)
}
}
}
}.frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.topLeading)
}
}
struct GameLibraryDetail:View {
let game:OwnedGameOutput
let title:String
let catalog:CatalogGame?
let text:(String)->String
let building:Bool
let disabled:Bool
var observations:[DeviceGameObservation]=[]
var isVerified:(DeviceGameObservation)->Bool={_ in false}
var needsRebuild=false
var advice:[String]=[]
var checkDevices:()->Void={}
let open:()->Void
let prepare:()->Void
let install:()->Void
let remove:()->Void
var body:some View {
VStack(alignment:.leading,spacing:18) {
ZStack(alignment:.bottomLeading) {
if let catalog {GameArtworkView(game:catalog)}else {Rectangle().fill(.white.opacity(0.05)).overlay(Image(systemName:"gamecontroller").font(.system(size:50)).foregroundStyle(.secondary))}
LinearGradient(colors:[.clear,.black.opacity(0.85)],startPoint:.center,endPoint:.bottom)
VStack(alignment:.leading,spacing:6) {
if building {Label(text("Compilando"),systemImage:"hammer.fill").font(.caption.bold())}
Text(title).font(.system(size:28,weight:.bold,design:.rounded)).lineLimit(2)
}.padding(20).foregroundStyle(.white)
}.frame(height:240).clipped().clipShape(RoundedRectangle(cornerRadius:14))
if let device=observations.first {
Label(text(isVerified(device) ? "Instalación verificada" : "Última instalación observada")+" · "+device.deviceName,systemImage:isVerified(device) ? "checkmark.circle.fill" : "clock").font(.callout.weight(.semibold)).foregroundStyle(isVerified(device) ? Color.green : .secondary)
}
if needsRebuild {Label(text("Conviene recompilar: cambiaron herramientas o el perfil de este juego."),systemImage:"arrow.triangle.2.circlepath").font(.callout).foregroundStyle(.orange)}
HStack(spacing:12) {
if game.macApp != nil {Button(action:open){Label(text("Jugar en Mac"),systemImage:"play.fill")}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(disabled || game.missing)}
if game.iosIPA != nil {Button(action:install){Label(text(observations.isEmpty ? "Instalar en iPhone / iPad" : "Actualizar en iPhone / iPad"),systemImage:"iphone")}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(disabled || game.missing)}
if !game.ready {Button(text("Preparar juego"),action:prepare).buttonStyle(LibraryPrimaryButtonStyle()).disabled(disabled || game.missing)}
Spacer(minLength:0)
Menu {
Button(text("Preparar o actualizar"),action:prepare).disabled(disabled || game.missing)
Button(text("Mostrar carpeta")){NSWorkspace.shared.activateFileViewerSelecting([game.folder])}
Button(text("Quitar de esta lista"),role:.destructive,action:remove).disabled(disabled)
} label:{Image(systemName:"ellipsis")}.menuStyle(.borderlessButton).fixedSize().accessibilityLabel(text("Acciones del juego"))
}
HStack(spacing:18) {
Label(text(game.macApp != nil ? "Mac listo" : "Mac sin preparar"),systemImage:"desktopcomputer")
Label(text(game.iosIPA != nil ? "IPA preparada en este Mac" : "iPhone / iPad sin preparar"),systemImage:"iphone")
}.font(.caption).foregroundStyle(.secondary)
VStack(alignment:.leading,spacing:10) {
HStack {Text(text("En tus dispositivos")).font(.headline);Spacer();Button(text("Comprobar dispositivos"),action:checkDevices).disabled(disabled)}
if observations.isEmpty {Text(text("Todavía no verificamos este juego en un iPhone o iPad. Conéctalo para comprobarlo.")).font(.caption).foregroundStyle(.secondary)}
ForEach(observations) {item in
HStack(alignment:.top,spacing:9) {
Image(systemName:isVerified(item) ? "checkmark.circle.fill" : "clock").foregroundStyle(isVerified(item) ? Color.green : .secondary)
VStack(alignment:.leading,spacing:4) {
Text(item.deviceName).font(.callout.bold())
Text(text(isVerified(item) ? "Instalación verificada" : "Última instalación observada")+" · "+item.checkedAt.formatted(date:.abbreviated,time:.shortened)).font(.caption).foregroundStyle(.secondary)
if !item.version.isEmpty {Text(text("Versión del juego")+" · "+item.version).font(.caption).foregroundStyle(.secondary)}
}
}
}
}.padding(14).frame(maxWidth:.infinity,alignment:.leading).background(.white.opacity(0.035),in:RoundedRectangle(cornerRadius:12))
ForEach(advice,id:\.self) {note in Label(note,systemImage:"arrow.triangle.2.circlepath").font(.caption).foregroundStyle(.secondary)}
if let version=game.version {Text(text("Versión del juego")+" · "+version).font(.callout).foregroundStyle(.secondary)}
if game.missing {Label(text("Carpeta no disponible"),systemImage:"externaldrive.badge.exclamationmark").foregroundStyle(.orange)}
Divider()
Button(text("Preparar o actualizar"),action:prepare).disabled(disabled || game.missing)
Text(text("Usa tus archivos para preparar otra plataforma o aplicar un update. Tus partidas y archivos originales se conservan.")).font(.caption).foregroundStyle(.secondary)
}.frame(maxWidth:.infinity,alignment:.leading).padding(.bottom,12)
}
}
struct BuildDock:View {
@ObservedObject var model:BuildModel
let show:()->Void
var body:some View {
HStack(spacing:20) {
VStack(alignment:.leading,spacing:5) {
HStack {Image(systemName:model.failed ? "exclamationmark.triangle" : "hammer");Text(model.gameName).font(.headline);Text(model.isMac ? "Mac" : "iPhone / iPad").font(.caption).foregroundStyle(.secondary)}
Text(!model.running ? model.text(model.phase) : [model.activity.stage,model.activity.step,model.activity.count].filter{!$0.isEmpty}.joined(separator:" · ")).font(.caption).lineLimit(1).foregroundStyle(.secondary)
}.frame(maxWidth:.infinity,alignment:.leading)
if model.running {
VStack(alignment:.leading,spacing:5) {
if let fraction=model.activity.fraction {ProgressView(value:fraction)}else{ProgressView().progressViewStyle(.linear)}
Text(model.text("Etapa actual")+(model.activity.fraction.map{" · "+String(Int($0*100))+" %"} ?? "")+(model.activity.eta.map{" · ~"+String(Int($0))+" s"} ?? "")).font(.caption2).foregroundStyle(.secondary)
}.frame(width:220)
}
Button(model.text("Ver actividad"),action:show)
if model.running {Button(model.text("Cancelar")){model.cancel()}}
}.padding(.horizontal,24).padding(.vertical,16)
}
}
struct BuildTimeline:View {
let activity:BuildActivity
let text:(String)->String
var body:some View {
VStack(alignment:.leading,spacing:12) {
HStack {Text(text("Etapas de compilación")).font(.headline);Spacer();if activity.stages>0 {Text(String(activity.history.filter{$0.state == .done && $0.number>0}.count)+" / "+String(activity.stages)).font(.caption).monospacedDigit().foregroundStyle(.secondary)}}
ForEach(activity.history) {stage in
HStack(alignment:.top,spacing:10) {
VStack(spacing:4) {Image(systemName:stage.state == .done ? "checkmark.circle.fill" : stage.state == .failed ? "xmark.circle.fill" : stage.state == .cancelled ? "pause.circle" : "circle.inset.filled").foregroundStyle(stage.state == .failed ? Color.orange : stage.state == .done ? Color.green : Color.primary)
if stage.id != activity.history.last?.id {Rectangle().fill(.secondary.opacity(0.3)).frame(width:1,height:14)}
}
VStack(alignment:.leading,spacing:3) {Text(stage.name).font(.callout.weight(stage.state == .active ? .semibold : .regular));if stage.state == .active && !activity.step.isEmpty {Text(activity.step).font(.caption).foregroundStyle(.secondary)}}
Spacer()
}
}
if let eta=activity.eta {Text(text("Tiempo restante informado")+": ~"+String(Int(eta))+" s").font(.caption).foregroundStyle(.secondary)}
}.padding(16).background(.white.opacity(0.035),in:RoundedRectangle(cornerRadius:12))
}
}
struct LibraryPrimaryButtonStyle:ButtonStyle {
@Environment(\.isEnabled) private var enabled
func makeBody(configuration:Configuration)->some View {
configuration.label.font(.system(size:13,weight:.semibold))
.padding(.horizontal,13).padding(.vertical,8).frame(minHeight:34)
.foregroundStyle(enabled ? Color.black : Color.secondary)
.background(enabled ? Color(red:0.79,green:0.95,blue:0.37) : Color.white.opacity(0.065),in:RoundedRectangle(cornerRadius:8))
.opacity(configuration.isPressed ? 0.8 : 1)
}
}