GPX-Import via Teilen-Menü und 'Öffnen mit'
Andere Apps können jetzt GPX-Tracks zu Ban Yaro Go schicken (Komoot,
Outdooractive, GPSies, AllTrails, Files-App, Mail-Anhänge, AirDrop).
- Info.plist:
- UTImportedTypeDeclarations: com.topografix.gpx (conforms to
public.xml/data/content, ext gpx, MIME application/gpx+xml)
- CFBundleDocumentTypes registriert die UTI als Viewer (LSHandlerRank
Alternate, damit wir nicht die Default-App werden)
- LSSupportsOpeningDocumentsInPlace=true
- Support/GPXParser.swift: schlanker XMLParser/SAX-Reader für
<trkpt>/<wpt>/<rtept>, Track-Name aus <trk><name>, ele + ISO8601 time
- Views/GPXImportSheet.swift: Sheet mit Map(MapPolyline)+Start/Ziel-Pins,
Distanz/Punkte/Dauer-Karte, zwei Aktionen:
1. 'Als Tour übernehmen' — Name editierbar, Hunde-Picker (FlowDogs),
öffentlich-Toggle → POST /api/routes
2. 'Nur ansehen' — Startpunkt in Apple Maps
- BanYaroGoApp.swift: .onOpenURL prüft .gpx, security-scoped resource,
parst und triggert das Sheet via TrackBox-Wrapper
This commit is contained in:
parent
3d229d42ce
commit
7848817cbe
4 changed files with 470 additions and 0 deletions
|
|
@ -60,5 +60,49 @@
|
||||||
</array>
|
</array>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
|
<key>UTImportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>com.topografix.gpx</string>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>GPX-Track</string>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.xml</string>
|
||||||
|
<string>public.data</string>
|
||||||
|
<string>public.content</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict>
|
||||||
|
<key>public.filename-extension</key>
|
||||||
|
<array>
|
||||||
|
<string>gpx</string>
|
||||||
|
</array>
|
||||||
|
<key>public.mime-type</key>
|
||||||
|
<array>
|
||||||
|
<string>application/gpx+xml</string>
|
||||||
|
<string>application/xml</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>GPX-Track</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>LSHandlerRank</key>
|
||||||
|
<string>Alternate</string>
|
||||||
|
<key>LSItemContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>com.topografix.gpx</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,47 @@ import SwiftData
|
||||||
struct BanYaroGoApp: App {
|
struct BanYaroGoApp: App {
|
||||||
@State private var auth = AuthSession()
|
@State private var auth = AuthSession()
|
||||||
@State private var activeDog = ActiveDogStore()
|
@State private var activeDog = ActiveDogStore()
|
||||||
|
@State private var pendingGPX: GPXTrack?
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(auth)
|
.environment(auth)
|
||||||
.environment(activeDog)
|
.environment(activeDog)
|
||||||
|
.onOpenURL { url in
|
||||||
|
handleIncoming(url: url)
|
||||||
|
}
|
||||||
|
.sheet(item: Binding(
|
||||||
|
get: { pendingGPX.map { TrackBox(track: $0) } },
|
||||||
|
set: { pendingGPX = $0?.track }
|
||||||
|
)) { box in
|
||||||
|
GPXImportSheet(track: box.track) {
|
||||||
|
pendingGPX = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
|
.modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleIncoming(url: URL) {
|
||||||
|
// Nur GPX akzeptieren — andere URLs (Deep-Links) sind anderweitig verdrahtet.
|
||||||
|
guard url.pathExtension.lowercased() == "gpx" else { return }
|
||||||
|
|
||||||
|
let didAccess = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let track = try GPXParser.parse(data: data)
|
||||||
|
pendingGPX = track
|
||||||
|
} catch {
|
||||||
|
print("GPX-Import fehlgeschlagen: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hilfs-Wrapper, damit GPXTrack als `Identifiable` in `.sheet(item:)` taugt.
|
||||||
|
private struct TrackBox: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let track: GPXTrack
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
BanYaroGo/Support/GPXParser.swift
Normal file
138
BanYaroGo/Support/GPXParser.swift
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
struct GPXTrack {
|
||||||
|
let name: String?
|
||||||
|
let points: [GPXPoint]
|
||||||
|
|
||||||
|
var coordinates: [CLLocationCoordinate2D] {
|
||||||
|
points.map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total length in meters along the track using great-circle distance.
|
||||||
|
var distanceMeters: Double {
|
||||||
|
guard points.count > 1 else { return 0 }
|
||||||
|
var sum: Double = 0
|
||||||
|
for i in 1..<points.count {
|
||||||
|
let a = CLLocation(latitude: points[i-1].lat, longitude: points[i-1].lon)
|
||||||
|
let b = CLLocation(latitude: points[i].lat, longitude: points[i].lon)
|
||||||
|
sum += b.distance(from: a)
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration in seconds derived from first/last <time> if available.
|
||||||
|
var durationSeconds: TimeInterval? {
|
||||||
|
guard let first = points.first?.time, let last = points.last?.time else { return nil }
|
||||||
|
let delta = last.timeIntervalSince(first)
|
||||||
|
return delta > 0 ? delta : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GPXPoint {
|
||||||
|
let lat: Double
|
||||||
|
let lon: Double
|
||||||
|
let ele: Double?
|
||||||
|
let time: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GPXParseError: Error, LocalizedError {
|
||||||
|
case noTrack
|
||||||
|
case invalid(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noTrack: return "Keine Trackpunkte gefunden."
|
||||||
|
case .invalid(let why): return "GPX ungültig: \(why)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimaler SAX-Parser für GPX. Liest <trkpt>, <wpt> und <rtept> als Punkte;
|
||||||
|
/// Track-Name aus <name> innerhalb des ersten <trk>.
|
||||||
|
final class GPXParser: NSObject, XMLParserDelegate {
|
||||||
|
private var points: [GPXPoint] = []
|
||||||
|
private var trackName: String?
|
||||||
|
|
||||||
|
private var currentLat: Double?
|
||||||
|
private var currentLon: Double?
|
||||||
|
private var currentEle: Double?
|
||||||
|
private var currentTime: Date?
|
||||||
|
private var currentElement: String?
|
||||||
|
private var inTrk = false
|
||||||
|
private var trkNameCaptured = false
|
||||||
|
private var textBuffer = ""
|
||||||
|
|
||||||
|
private static let isoFormatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func parse(data: Data) throws -> GPXTrack {
|
||||||
|
let parser = GPXParser()
|
||||||
|
let xml = XMLParser(data: data)
|
||||||
|
xml.delegate = parser
|
||||||
|
if !xml.parse() {
|
||||||
|
if let err = xml.parserError {
|
||||||
|
throw GPXParseError.invalid(err.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !parser.points.isEmpty else { throw GPXParseError.noTrack }
|
||||||
|
return GPXTrack(name: parser.trackName, points: parser.points)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(_ parser: XMLParser, didStartElement elementName: String,
|
||||||
|
namespaceURI: String?, qualifiedName qName: String?,
|
||||||
|
attributes attributeDict: [String : String] = [:]) {
|
||||||
|
currentElement = elementName
|
||||||
|
textBuffer = ""
|
||||||
|
switch elementName {
|
||||||
|
case "trk":
|
||||||
|
inTrk = true
|
||||||
|
trkNameCaptured = false
|
||||||
|
case "trkpt", "wpt", "rtept":
|
||||||
|
currentLat = attributeDict["lat"].flatMap { Double($0) }
|
||||||
|
currentLon = attributeDict["lon"].flatMap { Double($0) }
|
||||||
|
currentEle = nil
|
||||||
|
currentTime = nil
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||||
|
textBuffer += string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(_ parser: XMLParser, didEndElement elementName: String,
|
||||||
|
namespaceURI: String?, qualifiedName qName: String?) {
|
||||||
|
let text = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
switch elementName {
|
||||||
|
case "ele":
|
||||||
|
currentEle = Double(text)
|
||||||
|
case "time":
|
||||||
|
currentTime = Self.isoFormatter.date(from: text)
|
||||||
|
?? Self.isoFormatterNoFraction.date(from: text)
|
||||||
|
case "name":
|
||||||
|
if inTrk && !trkNameCaptured {
|
||||||
|
trackName = text
|
||||||
|
trkNameCaptured = true
|
||||||
|
}
|
||||||
|
case "trkpt", "wpt", "rtept":
|
||||||
|
if let lat = currentLat, let lon = currentLon {
|
||||||
|
points.append(GPXPoint(lat: lat, lon: lon, ele: currentEle, time: currentTime))
|
||||||
|
}
|
||||||
|
case "trk":
|
||||||
|
inTrk = false
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
textBuffer = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
254
BanYaroGo/Views/GPXImportSheet.swift
Normal file
254
BanYaroGo/Views/GPXImportSheet.swift
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import SwiftUI
|
||||||
|
import MapKit
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
struct GPXImportSheet: View {
|
||||||
|
let track: GPXTrack
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var name: String
|
||||||
|
@State private var dogs: [Dog] = []
|
||||||
|
@State private var selectedDogIds: Set<Int> = []
|
||||||
|
@State private var isPublic: Bool = false
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var saved: Bool = false
|
||||||
|
|
||||||
|
init(track: GPXTrack, onDismiss: @escaping () -> Void) {
|
||||||
|
self.track = track
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
_name = State(initialValue: track.name?.isEmpty == false
|
||||||
|
? (track.name ?? "Importierte Tour")
|
||||||
|
: "Importierte Tour")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
mapView
|
||||||
|
summaryCard
|
||||||
|
importCard
|
||||||
|
viewOnlyCard
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("GPX importieren")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Schließen") {
|
||||||
|
dismiss()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
dogs = (try? await APIClient.shared.get("/api/dogs")) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Map
|
||||||
|
|
||||||
|
private var mapView: some View {
|
||||||
|
Map {
|
||||||
|
MapPolyline(coordinates: track.coordinates)
|
||||||
|
.stroke(Color.accentColor, lineWidth: 4)
|
||||||
|
if let first = track.coordinates.first {
|
||||||
|
Annotation("Start", coordinate: first) {
|
||||||
|
Image(systemName: "flag.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.background(.white, in: Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let last = track.coordinates.last, track.coordinates.count > 1 {
|
||||||
|
Annotation("Ziel", coordinate: last) {
|
||||||
|
Image(systemName: "flag.checkered.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white, .red)
|
||||||
|
.background(.white, in: Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mapStyle(.standard(elevation: .realistic))
|
||||||
|
.frame(height: 220)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary
|
||||||
|
|
||||||
|
private var summaryCard: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
stat("Distanz", value: distanceLabel, icon: "ruler")
|
||||||
|
Divider()
|
||||||
|
stat("Punkte", value: "\(track.points.count)", icon: "point.3.connected.trianglepath.dotted")
|
||||||
|
if let dur = durationLabel {
|
||||||
|
Divider()
|
||||||
|
stat("Dauer", value: dur, icon: "clock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stat(_ title: String, value: String, icon: String) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon).foregroundStyle(Color.accentColor)
|
||||||
|
Text(value).font(.headline.monospacedDigit())
|
||||||
|
Text(title).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import (save as route)
|
||||||
|
|
||||||
|
private var importCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Label("Als Tour übernehmen", systemImage: "square.and.arrow.down.fill")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
|
if !dogs.isEmpty {
|
||||||
|
Text("Hunde").font(.caption).foregroundStyle(.secondary)
|
||||||
|
FlowDogs(dogs: dogs, selected: $selectedDogIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle(isOn: $isPublic) {
|
||||||
|
Label("Öffentlich teilen", systemImage: "globe")
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await saveAsRoute() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if isSaving { ProgressView() }
|
||||||
|
Image(systemName: saved ? "checkmark.circle.fill" : "square.and.arrow.down")
|
||||||
|
Text(saved ? "Gespeichert" : "Speichern")
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
|
}
|
||||||
|
.background((saved ? Color.green : Color.accentColor).opacity(isSaving ? 0.6 : 1), in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.disabled(isSaving || saved || name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View only
|
||||||
|
|
||||||
|
private var viewOnlyCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Label("Nur ansehen", systemImage: "eye")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Den Track in Apple Maps öffnen und dort z. B. eine Route zum Startpunkt planen.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button {
|
||||||
|
openStartInMaps()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "arrow.triangle.turn.up.right.diamond.fill")
|
||||||
|
Text("Startpunkt in Apple Maps").bold()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
|
}
|
||||||
|
.background(.thinMaterial, in: Capsule())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func saveAsRoute() async {
|
||||||
|
isSaving = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isSaving = false }
|
||||||
|
let dist = track.distanceMeters / 1000.0
|
||||||
|
let durMin: Int = {
|
||||||
|
if let s = track.durationSeconds { return Int(s / 60.0) }
|
||||||
|
return 0
|
||||||
|
}()
|
||||||
|
let body = RouteCreateBody(
|
||||||
|
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
gpsTrack: track.points.map { GPSPoint(lat: $0.lat, lon: $0.lon, alt: $0.ele) },
|
||||||
|
distanzKm: dist,
|
||||||
|
dauerMin: durMin,
|
||||||
|
dogIds: Array(selectedDogIds),
|
||||||
|
isPublic: isPublic
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
let _: RouteDetail = try await APIClient.shared.post("/api/routes", body: body)
|
||||||
|
saved = true
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openStartInMaps() {
|
||||||
|
guard let start = track.coordinates.first else { return }
|
||||||
|
let item = MKMapItem(placemark: MKPlacemark(coordinate: start))
|
||||||
|
item.name = name
|
||||||
|
item.openInMaps(launchOptions: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Labels
|
||||||
|
|
||||||
|
private var distanceLabel: String {
|
||||||
|
let km = track.distanceMeters / 1000.0
|
||||||
|
return km < 1 ? "\(Int(track.distanceMeters)) m" : String(format: "%.1f km", km)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var durationLabel: String? {
|
||||||
|
guard let s = track.durationSeconds else { return nil }
|
||||||
|
let h = Int(s) / 3600
|
||||||
|
let m = (Int(s) % 3600) / 60
|
||||||
|
if h > 0 { return "\(h):\(String(format: "%02d", m)) h" }
|
||||||
|
return "\(m) min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlowDogs: View {
|
||||||
|
let dogs: [Dog]
|
||||||
|
@Binding var selected: Set<Int>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let cols = [GridItem(.adaptive(minimum: 110))]
|
||||||
|
LazyVGrid(columns: cols, alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(dogs) { d in
|
||||||
|
let on = selected.contains(d.id)
|
||||||
|
Button {
|
||||||
|
if on { selected.remove(d.id) } else { selected.insert(d.id) }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: on ? "checkmark.circle.fill" : "circle")
|
||||||
|
Text(d.name).lineLimit(1)
|
||||||
|
}
|
||||||
|
.font(.caption.bold())
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(on ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12),
|
||||||
|
in: Capsule())
|
||||||
|
.foregroundStyle(on ? Color.accentColor : .primary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue