Compare commits
5 Commits
b006293896
...
8a4196db3a
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 8a4196db3a | |
Shadowfacts | 9e16f9a693 | |
Shadowfacts | 85f60c7261 | |
Shadowfacts | ffd9b8434e | |
Shadowfacts | 643452459b |
|
@ -29,8 +29,8 @@ struct KeyData: Codable {
|
||||||
try container.encode(folders, forKey: .folders)
|
try container.encode(folders, forKey: .folders)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func addKey(_ key: TOTPKey) {
|
mutating func addKey(_ key: TOTPKey, folderID: UUID? = nil) {
|
||||||
entries.append(Entry(key: key))
|
entries.append(Entry(key: key, folderID: folderID))
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func addOrUpdateEntries(_ entries: [Entry]) {
|
mutating func addOrUpdateEntries(_ entries: [Entry]) {
|
||||||
|
|
|
@ -54,8 +54,8 @@ class KeyStore: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addKey(_ key: TOTPKey) {
|
func addKey(_ key: TOTPKey, folderID: UUID? = nil) {
|
||||||
data.addKey(key)
|
data.addKey(key, folderID: folderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateKey(entryID id: UUID, newKey: TOTPKey) {
|
func updateKey(entryID id: UUID, newKey: TOTPKey) {
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
//
|
||||||
|
// AddKeyButton.swift
|
||||||
|
// OTP
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/25/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddKeyButton: View {
|
||||||
|
let folderID: UUID?
|
||||||
|
let canAddFolder: Bool
|
||||||
|
@ObservedObject private var store: KeyStore = .shared
|
||||||
|
@State private var isPresentingScanner = false
|
||||||
|
@State private var isPresentingScanFailedAlert = false
|
||||||
|
@State private var isPresentingAddURLSheet = false
|
||||||
|
@State private var isPresentingManualAddFormSheet = false
|
||||||
|
|
||||||
|
init(folderID: UUID?, canAddFolder: Bool) {
|
||||||
|
self.folderID = folderID
|
||||||
|
self.canAddFolder = canAddFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
isPresentingScanner = true
|
||||||
|
} label: {
|
||||||
|
Label("Scan QR", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
isPresentingAddURLSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("From URL", systemImage: "link")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
isPresentingManualAddFormSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Enter Manually", systemImage: "textbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if canAddFolder {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
store.addFolder()
|
||||||
|
} label: {
|
||||||
|
Label("New Folder", systemImage: "folder.badge.plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Add Key", systemImage: "plus.circle")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Add Key")
|
||||||
|
.sheet(isPresented: $isPresentingScanner, content: self.scannerSheet)
|
||||||
|
.sheet(isPresented: $isPresentingManualAddFormSheet, content: self.manualAddFormSheet)
|
||||||
|
.sheet(isPresented: $isPresentingAddURLSheet, content: self.addURLSheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scannerSheet() -> some View {
|
||||||
|
AddQRView() { (action) in
|
||||||
|
self.isPresentingScanner = false
|
||||||
|
switch action {
|
||||||
|
case .cancel:
|
||||||
|
break
|
||||||
|
case .save(let key):
|
||||||
|
store.addKey(key, folderID: folderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addURLSheet() -> some View {
|
||||||
|
NavigationView {
|
||||||
|
AddURLForm { (action) in
|
||||||
|
self.isPresentingAddURLSheet = false
|
||||||
|
switch action {
|
||||||
|
case .cancel:
|
||||||
|
break
|
||||||
|
case .save(let key):
|
||||||
|
store.addKey(key, folderID: folderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func manualAddFormSheet() -> some View {
|
||||||
|
NavigationView {
|
||||||
|
EditKeyForm(editingKey: nil, focusOnAppear: true) { (action) in
|
||||||
|
self.isPresentingManualAddFormSheet = false
|
||||||
|
switch action {
|
||||||
|
case .cancel:
|
||||||
|
break
|
||||||
|
case .save(let key):
|
||||||
|
store.addKey(key, folderID: folderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AddKeyButton_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
AddKeyButton(folderID: nil, canAddFolder: true)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
//
|
||||||
|
// AllKeysView.swift
|
||||||
|
// OTP
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/25/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AllKeysView: View {
|
||||||
|
@ObservedObject private var store: KeyStore
|
||||||
|
@ObservedObject private var codeHolder: AppView.CodeHolder
|
||||||
|
@State private var searchQuery = ""
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let store = KeyStore.shared
|
||||||
|
self.store = store
|
||||||
|
self.codeHolder = AppView.CodeHolder(store: store, entryFilter: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
KeysSection(codeHolder: codeHolder, searchQuery: searchQuery)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.searchable(text: $searchQuery)
|
||||||
|
.navigationTitle("All Keys")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
AddKeyButton(folderID: nil, canAddFolder: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AllKeysView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
AllKeysView()
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,23 +12,32 @@ import Combine
|
||||||
struct AppView: View {
|
struct AppView: View {
|
||||||
@ObservedObject private var store: KeyStore
|
@ObservedObject private var store: KeyStore
|
||||||
@ObservedObject private var entryHolder: CodeHolder
|
@ObservedObject private var entryHolder: CodeHolder
|
||||||
@State private var isPresentingScanner = false
|
@ObservedObject private var allEntriesHolder: CodeHolder
|
||||||
@State private var isPresentingScanFailedAlert = false
|
@State private var searchQuery = ""
|
||||||
@State private var isPresentingAddURLSheet = false
|
|
||||||
@State private var isPresentingManualAddFormSheet = false
|
|
||||||
@State private var isPresentingPreferences = false
|
@State private var isPresentingPreferences = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.store = .shared
|
self.store = .shared
|
||||||
self.entryHolder = CodeHolder(store: .shared) { (entry) in entry.folderID == nil }
|
self.entryHolder = CodeHolder(store: .shared) { (entry) in entry.folderID == nil }
|
||||||
|
self.allEntriesHolder = CodeHolder(store: .shared, entryFilter: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
List {
|
List {
|
||||||
KeysSection(codeHolder: entryHolder)
|
if searchQuery.isEmpty {
|
||||||
|
KeysSection(codeHolder: entryHolder)
|
||||||
FoldersSection()
|
|
||||||
|
Section {
|
||||||
|
NavigationLink("All Keys") {
|
||||||
|
AllKeysView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FoldersSection()
|
||||||
|
} else {
|
||||||
|
KeysSection(codeHolder: allEntriesHolder, searchQuery: searchQuery)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.navigationTitle("OTP")
|
.navigationTitle("OTP")
|
||||||
|
@ -38,49 +47,16 @@ struct AppView: View {
|
||||||
isPresentingPreferences = true
|
isPresentingPreferences = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Preferences", systemImage: "gear")
|
Label("Preferences", systemImage: "gear")
|
||||||
.tint(.accentColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Menu {
|
AddKeyButton(folderID: nil, canAddFolder: true)
|
||||||
Section {
|
|
||||||
Button {
|
|
||||||
isPresentingScanner = true
|
|
||||||
} label: {
|
|
||||||
Label("Scan QR", systemImage: "qrcode.viewfinder")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
isPresentingAddURLSheet = true
|
|
||||||
} label: {
|
|
||||||
Label("From URL", systemImage: "link")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
isPresentingManualAddFormSheet = true
|
|
||||||
} label: {
|
|
||||||
Label("Enter Manually", systemImage: "textbox")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button {
|
|
||||||
store.addFolder()
|
|
||||||
} label: {
|
|
||||||
Label("New Folder", systemImage: "folder.badge.plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Add Key", systemImage: "plus.circle")
|
|
||||||
.tint(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(.blue)
|
.searchable(text: $searchQuery)
|
||||||
.sheet(isPresented: $isPresentingPreferences, content: self.preferencesSheet)
|
.sheet(isPresented: $isPresentingPreferences, content: self.preferencesSheet)
|
||||||
.sheet(isPresented: $isPresentingScanner, content: self.scannerSheet)
|
|
||||||
.sheet(isPresented: $isPresentingManualAddFormSheet, content: self.manualAddFormSheet)
|
|
||||||
.sheet(isPresented: $isPresentingAddURLSheet, content: self.addURLSheet)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func preferencesSheet() -> some View {
|
private func preferencesSheet() -> some View {
|
||||||
|
@ -89,47 +65,6 @@ struct AppView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scannerSheet() -> some View {
|
|
||||||
AddQRView() { (action) in
|
|
||||||
self.isPresentingScanner = false
|
|
||||||
switch action {
|
|
||||||
case .cancel:
|
|
||||||
break
|
|
||||||
case .save(let key):
|
|
||||||
store.addKey(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addURLSheet() -> some View {
|
|
||||||
NavigationView {
|
|
||||||
AddURLForm { (action) in
|
|
||||||
self.isPresentingAddURLSheet = false
|
|
||||||
switch action {
|
|
||||||
case .cancel:
|
|
||||||
break
|
|
||||||
case .save(let key):
|
|
||||||
store.addKey(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func manualAddFormSheet() -> some View {
|
|
||||||
NavigationView {
|
|
||||||
EditKeyForm(editingKey: nil, focusOnAppear: true) { (action) in
|
|
||||||
self.isPresentingManualAddFormSheet = false
|
|
||||||
switch action {
|
|
||||||
case .cancel:
|
|
||||||
break
|
|
||||||
case .save(let key):
|
|
||||||
store.addKey(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Add Key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CodeEntry: Identifiable, Equatable, Hashable {
|
struct CodeEntry: Identifiable, Equatable, Hashable {
|
||||||
let entry: KeyData.Entry
|
let entry: KeyData.Entry
|
||||||
let code: TOTPCode
|
let code: TOTPCode
|
||||||
|
|
|
@ -94,14 +94,12 @@ struct EditKeyForm: View {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
dismiss(.cancel)
|
dismiss(.cancel)
|
||||||
}
|
}
|
||||||
.tint(.accentColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
dismiss(.save(editedKey.toTOTPKey()!))
|
dismiss(.save(editedKey.toTOTPKey()!))
|
||||||
}
|
}
|
||||||
.tint(.accentColor)
|
|
||||||
.disabled(!isValid)
|
.disabled(!isValid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,11 @@ struct FolderView: View {
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.navigationTitle(folder.name)
|
.navigationTitle(folder.name)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
AddKeyButton(folderID: folder.id, canAddFolder: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,9 @@ import OTPKit
|
||||||
struct KeyView: View {
|
struct KeyView: View {
|
||||||
let key: TOTPKey
|
let key: TOTPKey
|
||||||
let currentCode: TOTPCode
|
let currentCode: TOTPCode
|
||||||
|
@State private var copying = false
|
||||||
|
|
||||||
var formattedCode: String {
|
private var formattedCode: String {
|
||||||
let code = currentCode.code
|
let code = currentCode.code
|
||||||
let mid = code.index(code.startIndex, offsetBy: code.count / 2)
|
let mid = code.index(code.startIndex, offsetBy: code.count / 2)
|
||||||
return "\(code[code.startIndex..<mid]) \(code[mid...])"
|
return "\(code[code.startIndex..<mid]) \(code[mid...])"
|
||||||
|
@ -24,37 +25,47 @@ struct KeyView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
Button(action: self.copy) {
|
||||||
VStack(alignment: .leading) {
|
HStack {
|
||||||
Text(key.issuer)
|
VStack(alignment: .leading) {
|
||||||
.font(.title3)
|
Text(key.issuer)
|
||||||
|
.font(.title3)
|
||||||
if let label = key.label, !label.isEmpty {
|
|
||||||
Text(label)
|
|
||||||
.font(.footnote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(formattedCode)
|
|
||||||
.font(.system(.title2, design: .monospaced))
|
|
||||||
|
|
||||||
// Text("\(currentCode.validUntil, style: .relative)")
|
|
||||||
// .font(.body.monospacedDigit())
|
|
||||||
|
|
||||||
// I don't think this TimelineView should be necessary since the CodeHolder timer fires every .5 seconds
|
|
||||||
TimelineView(.animation) { (ctx) in
|
|
||||||
ZStack {
|
|
||||||
CircularProgressView(progress: progress(at: Date()), colorChangeThreshold: 5.0 / Double(key.period))
|
|
||||||
|
|
||||||
Text(Int(round(currentCode.validUntil.timeIntervalSinceNow)).description)
|
if let label = key.label, !label.isEmpty {
|
||||||
.font(.caption.monospacedDigit())
|
Text(label)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
|
||||||
|
if copying {
|
||||||
|
Text("Copied!")
|
||||||
|
.font(.title2)
|
||||||
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
|
} else {
|
||||||
|
Text(formattedCode)
|
||||||
|
.font(.system(.title2, design: .monospaced))
|
||||||
|
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text("\(currentCode.validUntil, style: .relative)")
|
||||||
|
// .font(.body.monospacedDigit())
|
||||||
|
|
||||||
|
// I don't think this TimelineView should be necessary since the CodeHolder timer fires every .5 seconds
|
||||||
|
TimelineView(.animation) { (ctx) in
|
||||||
|
ZStack {
|
||||||
|
CircularProgressView(progress: progress(at: Date()), colorChangeThreshold: 5.0 / Double(key.period))
|
||||||
|
|
||||||
|
Text(Int(round(currentCode.validUntil.timeIntervalSinceNow)).description)
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
}
|
||||||
|
.frame(width: 30)
|
||||||
}
|
}
|
||||||
.frame(width: 30)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
.tint(.black)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func progress(at date: Date) -> Double {
|
private func progress(at date: Date) -> Double {
|
||||||
|
@ -62,6 +73,16 @@ struct KeyView: View {
|
||||||
let progress = 1 - seconds / Double(key.period)
|
let progress = 1 - seconds / Double(key.period)
|
||||||
return progress
|
return progress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func copy() {
|
||||||
|
UIPasteboard.general.string = currentCode.code
|
||||||
|
withAnimation(.easeInOut(duration: 0.5)) {
|
||||||
|
copying = true
|
||||||
|
}
|
||||||
|
withAnimation(.easeInOut(duration: 0.5).delay(0.65)) {
|
||||||
|
copying = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct KeyView_Previews: PreviewProvider {
|
struct KeyView_Previews: PreviewProvider {
|
||||||
|
|
|
@ -9,18 +9,30 @@ import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct KeysSection: View {
|
struct KeysSection: View {
|
||||||
|
private let searchQuery: String?
|
||||||
@ObservedObject private var store: KeyStore = .shared
|
@ObservedObject private var store: KeyStore = .shared
|
||||||
@ObservedObject private var entryHolder: AppView.CodeHolder
|
@ObservedObject private var entryHolder: AppView.CodeHolder
|
||||||
@State private var editedEntry: AppView.CodeEntry? = nil
|
@State private var editedEntry: AppView.CodeEntry? = nil
|
||||||
@State private var presentedQRCode: AppView.CodeEntry? = nil
|
@State private var presentedQRCode: AppView.CodeEntry? = nil
|
||||||
|
|
||||||
init(codeHolder: AppView.CodeHolder) {
|
init(codeHolder: AppView.CodeHolder, searchQuery: String? = nil) {
|
||||||
self.entryHolder = codeHolder
|
self.entryHolder = codeHolder
|
||||||
|
self.searchQuery = searchQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredEntries: [AppView.CodeEntry] {
|
||||||
|
if let query = searchQuery?.lowercased(), !query.isEmpty {
|
||||||
|
return entryHolder.sortedEntries.filter { (e) in
|
||||||
|
e.key.issuer.lowercased().contains(query) || (e.key.label?.lowercased().contains(query) ?? false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return entryHolder.sortedEntries
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
ForEach(entryHolder.sortedEntries) { (entry) in
|
ForEach(filteredEntries) { (entry) in
|
||||||
KeyView(key: entry.key, currentCode: entry.code)
|
KeyView(key: entry.key, currentCode: entry.code)
|
||||||
// disabled because dropping onto list rows does not work :/
|
// disabled because dropping onto list rows does not work :/
|
||||||
// .onDrag {
|
// .onDrag {
|
||||||
|
@ -33,7 +45,7 @@ struct KeysSection: View {
|
||||||
.onDelete { (indices) in
|
.onDelete { (indices) in
|
||||||
withAnimation(.default) {
|
withAnimation(.default) {
|
||||||
for index in indices {
|
for index in indices {
|
||||||
store.removeKey(entryID: entryHolder.sortedEntries[index].id)
|
store.removeKey(entryID: filteredEntries[index].id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,7 +76,6 @@ struct QRCodeView: View {
|
||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.tint(.accentColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background {
|
.background {
|
||||||
|
@ -106,7 +105,6 @@ struct QRCodeView: View {
|
||||||
Label("Save", systemImage: "square.and.arrow.down")
|
Label("Save", systemImage: "square.and.arrow.down")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(.accentColor)
|
|
||||||
}
|
}
|
||||||
// fix the size so that the HStack doesn't grow beyond with width of the image
|
// fix the size so that the HStack doesn't grow beyond with width of the image
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
|
Loading…
Reference in New Issue