taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit 3270843c61095f5fc6043c30d087956295767bd8
parent a92dc23dcc3861ac0fbec1ea2ecf0d1d727ae5da
Author: Marc Stibane <marc@taler.net>
Date:   Tue,  8 Jul 2025 17:34:28 +0200

Chest animation

Diffstat:
MTalerWallet1/Views/Actions/Peer2peer/RequestPayment.swift | 6++++--
MTalerWallet1/Views/Actions/Peer2peer/SendAmountV.swift | 6++++--
MTalerWallet1/Views/OIM/OIMEditView.swift | 2+-
MTalerWallet1/Views/OIM/OIMView.swift | 71++++++++++++++++++++++++++++++++++++++++++++++-------------------------
MTalerWallet1/Views/OIM/OIMcash.swift | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
MTalerWallet1/Views/OIM/OIMcurrencyButton.swift | 6++++++
MTalerWallet1/Views/OIM/OIMcurrencyDrawer.swift | 8++++++--
MTalerWallet1/Views/OIM/OIMlayout.swift | 51++++++++++++++++++++++++++++++++++-----------------
8 files changed, 202 insertions(+), 92 deletions(-)

diff --git a/TalerWallet1/Views/Actions/Peer2peer/RequestPayment.swift b/TalerWallet1/Views/Actions/Peer2peer/RequestPayment.swift @@ -44,8 +44,10 @@ struct RequestPayment: View { self._summary = summary self._iconID = iconID //#if OIM - let currency = selectedIndex == 1 ? OIMleones : OIMeuros - self._cash = StateObject(wrappedValue: { OIMcash(currency) }()) + let index = selectedIndex ?? 0 + let currency = index == 0 ? OIMeuros : OIMleones + let oimCash = OIMcash(currency, currencyIndex: (index > 0) ? 1 : 0) + self._cash = StateObject(wrappedValue: { oimCash }()) //#endif } diff --git a/TalerWallet1/Views/Actions/Peer2peer/SendAmountV.swift b/TalerWallet1/Views/Actions/Peer2peer/SendAmountV.swift @@ -46,8 +46,10 @@ struct SendAmountV: View { self._summary = summary self._iconID = iconID //#if OIM - let currency = selectedIndex == 1 ? OIMleones : OIMeuros - self._cash = StateObject(wrappedValue: { OIMcash(currency) }()) + let index = selectedIndex ?? 0 + let currency = index == 0 ? OIMeuros : OIMleones + let oimCash = OIMcash(currency, currencyIndex: (index > 0) ? 1 : 0) + self._cash = StateObject(wrappedValue: { oimCash }()) //#endif } diff --git a/TalerWallet1/Views/OIM/OIMEditView.swift b/TalerWallet1/Views/OIM/OIMEditView.swift @@ -142,7 +142,7 @@ struct OIMEditView: View { amountVal = amountToTransfer.centValue availableVal = isAvailable // print("OIMEditView.task", availableVal, amountVal) - cash.update2(amountVal) + cash.update2(amountVal, state: .idle) } .onAppear { // print("OIMEditView.onAppear", availableVal, amountVal) diff --git a/TalerWallet1/Views/OIM/OIMView.swift b/TalerWallet1/Views/OIM/OIMView.swift @@ -11,6 +11,11 @@ import taler_swift let OIMbuttonSize = 80.0 let OIMactionSize = 120.0 +let OIMACTION = "OIMaction" +let OIMACTION2 = "OIMaction2" +let OIMNUMBER = "OIMnumber" +let OIMCHEST = "OIMchest" + // MARK: - struct OIMnavBack<Content: View>: View { let stack: CallStack @@ -45,7 +50,7 @@ struct OIMnavBack<Content: View>: View { isFinal: isFinal, action: amountIsZero ? nil : action) .frame(width: OIMactionSize, height: OIMbuttonSize) - .matchedGeometryEffect(id: isSending ? "OIMaction" : "OIMaction2", + .matchedGeometryEffect(id: isSending ? OIMACTION : OIMACTION2, in: wrapper.namespace, isSource: false) } } @@ -70,7 +75,7 @@ struct OIMtitleView: View { .frame(width: OIMbuttonSize, height: OIMbuttonSize) .disabled(true) .opacity(0.01) - .matchedGeometryEffect(id: "OIMnumber", in: wrapper.namespace, isSource: true) + .matchedGeometryEffect(id: OIMNUMBER, in: wrapper.namespace, isSource: true) .accessibilityHidden(true) OIMamountV(amount: amount, currencyName: cash.currency.noteBase) @@ -84,7 +89,7 @@ struct OIMtitleView: View { .frame(width: OIMactionSize, height: OIMbuttonSize) .disabled(true) .opacity(0.01) - .matchedGeometryEffect(id: "OIMaction", in: wrapper.namespace, isSource: true) + .matchedGeometryEffect(id: OIMACTION, in: wrapper.namespace, isSource: true) .accessibilityHidden(true) } @@ -144,16 +149,29 @@ struct OIMView: View { } func openChest(_ index: Int, _ balance: Balance) { - chestOpen = index - selectedIndex = index - cash.currency = index == 0 ? OIMeuros : OIMleones - selectedBalance = balance - available = balance.available - - availableVal = available?.centValue ?? 0 - cash.update2(availableVal, 0.2) // set cash to available - let maxAvailable = cash.max(available: availableVal) - print("OIMView.task availableVal", availableVal, maxAvailable) + cash.clearFunds() + print("❗️openChest❗️") + let duration: TimeInterval + let initial: TimeInterval +#if DEBUG + duration = debugAnimations ? 2.5 : + fastAnimations ? 0.6 : 1.1 + initial = debugAnimations ? 1.0 : 0.1 +#else + duration = fastAnimations ? 0.6 : 1.1 + initial = 0.1 +#endif + withAnimation(.basic1) { + chestOpen = index + selectedIndex = index + cash.currency = index == 0 ? OIMeuros : OIMleones + selectedBalance = balance + available = balance.available + availableVal = balance.available.centValue + cash.update2(availableVal, state: .chestOpening, duration, initial) // set cash to available + let maxAvailable = cash.max(available: availableVal) + print("OIMView.openChest availableVal", availableVal, maxAvailable) + } } var body: some View { @@ -230,22 +248,25 @@ struct OIMView: View { let itsMe = chestOpen == index let isClosed = chestOpen == nil let size = isClosed ? 160.0 : OIMbuttonSize - OIMbalanceButton(isOpen: itsMe, isSierra: index > 0, isFinal: false) { - if itsMe { - closeAnimated() - } else { - withAnimation(.basic1) { + ZStack { + OIMbalanceButton(isOpen: itsMe, isSierra: index > 0, isFinal: false) { + if itsMe { + closeAnimated() + } else { openChest(index, balance) } } + .frame(width: size, height: size) + .zIndex(itsMe ? 3 : 0) + .opacity((isClosed || itsMe) ? 1.0 : 0.01) + .matchedGeometryEffect(id: itsMe ? OIMNUMBER // (sending ? "OIMback" : OIMNUMBER) + : String(index), + in: wrapper.namespace, isSource: false) + .frame(width: size, height: size) + Color.clear + .frame(width: 40, height: 40) + .matchedGeometryEffect(id: OIMCHEST + String(index), in: wrapper.namespace, isSource: true) } - .frame(width: size, height: size) - .zIndex(itsMe ? 3 : 0) - .opacity((isClosed || itsMe) ? 1.0 : 0.01) - .matchedGeometryEffect(id: itsMe ? "OIMnumber" // (sending ? "OIMback" : "OIMnumber") - : String(index), - in: wrapper.namespace, isSource: false) - .frame(width: size, height: size) } } Spacer() diff --git a/TalerWallet1/Views/OIM/OIMcash.swift b/TalerWallet1/Views/OIM/OIMcash.swift @@ -18,6 +18,9 @@ enum FundState: Int { case shouldFly // + button tapped case isFlying // after onAppear + case chestOpening // chest tapped, money flies out + case reveal // move (invisible) together with the chest to the top + case curve // fly out of the chest in a curve to the topRight corner case position // position to move to case moving // move to position @@ -28,7 +31,10 @@ enum FundState: Int { var returning: Bool { self == .returning } var shouldFly: Bool { self == .shouldFly } - var isFlying: Bool { self == .isFlying } + var isFlying: Bool { self == .isFlying || self == .reveal || self == .curve } + var chestOpening: Bool { self == .chestOpening } + var reveal: Bool { self == .reveal } + var curve: Bool { self == .curve } var position: Bool { self == .position } var morphing: Bool { self == .morphing } var moving: Bool { self == .moving } @@ -39,37 +45,56 @@ enum FundState: Int { /// data structure for a cash item on the table public struct OIMfund: Identifiable, Equatable, Hashable, Sendable { public let id: Int // support multiple funds with the same value - var state: FundState var value: UInt64 // can be morphed + var currencyIndex: Int + var state: FundState + var delay: TimeInterval var morphTicker: Int? // `semaphore´ to reserve a fund - don't morph this twice concurrently var flippedVal: UInt64? // the value to morph into - layout.sortByValue will take this instead of value var outValue: UInt64? // if this is set it determines the image (used for flipping) - - var targetID: String { - String(state.shouldFly || state.returning ? -Int(value) // match sourceID - : id) + var targetID: String { // match sourceID for SwiftUI animations + state.chestOpening ? OIMCHEST + String(currencyIndex) + : state.reveal ? OIMNUMBER + : state.curve ? OIMACTION + : String(state.shouldFly || state.returning ? -Int(value) // match currencyScroller + : id) + } + var shouldFly2: Bool { state.shouldFly || state.chestOpening } + var isFlying2: Bool { state.isFlying } + + init(id: Int, value: UInt64, currencyIndex: Int, + state: FundState = .idle, delay: TimeInterval = 0, + morphTicker: Int? = nil, flippedVal: UInt64? = nil, outValue: UInt64? = nil + ) { + self.id = id + self.value = value + self.currencyIndex = currencyIndex + self.state = state + self.delay = delay + self.morphTicker = morphTicker + self.flippedVal = flippedVal + self.outValue = outValue } - var shouldFly: Bool { state.shouldFly } - var isFlying: Bool { state.isFlying } -// var isFlipping: Bool { state.flipping } - } public typealias OIMfunds = [OIMfund] // MARK: - final class OIMcash: ObservableObject, Sendable { - private let symLog = SymLogV(0) + private let symLog = SymLogV() private let logger = Logger(subsystem: "net.taler.gnu", category: "OIMcash") private var ticker = 0 // increment each time a fund is added (or melted from others) // let semaphore = AsyncSemaphore(value: 1) var currency: OIMcurrency + var currencyIndex: Int @Published var funds: OIMfunds = [] - init(_ currency: OIMcurrency? = nil) { + init(_ currency: OIMcurrency? = nil, currencyIndex: Int = 0) { if let currency { self.currency = currency + self.currencyIndex = currencyIndex } else { self.currency = OIMeuros + self.currencyIndex = 0 } } @@ -87,7 +112,7 @@ final class OIMcash: ObservableObject, Sendable { return 0 } - var delay: Double { + var flyDelay: Double { // time #if DEBUG if debugAnimations { return 5.0 } // return fastAnimations ? 5 : 1.0 @@ -95,6 +120,9 @@ final class OIMcash: ObservableObject, Sendable { return fastAnimations ? 0.4 : 0.8 } + var curveDelay: Double { + flyDelay * 2 / 3 + } func sortByValue() -> OIMfunds { /// sorts ASCENDING but keeps the order of the IDs for identical values @@ -115,7 +143,7 @@ final class OIMcash: ObservableObject, Sendable { for fund in sorted { let value = fund.value let state = fund.state - if fund.shouldFly || fund.isFlying { // flying can not go on the stack + if fund.shouldFly2 || fund.isFlying2 { // flying can not go on the stack lastValue = 0 // let the next subview... stackIndex = 0 // ...start a new stack } else if lastValue != value { // different value? @@ -193,7 +221,7 @@ final class OIMcash: ObservableObject, Sendable { } /// Stage 2: After arriving at the position, without animation hide all outValues (except our anchor) - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + DispatchQueue.main.asyncAfter(deadline: .now() + flyDelay) { for var delFund in toDelete { self.symLog.log(">>hide \(delFund.id)") delFund.state = .hiding // make invisible @@ -207,7 +235,7 @@ final class OIMcash: ObservableObject, Sendable { self.symLog.log(">>flipped \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") /// Stage 4: move inValues to final position, delete outValues from funds array - DispatchQueue.main.asyncAfter(deadline: .now() + self.delay) { + DispatchQueue.main.asyncAfter(deadline: .now() + self.flyDelay) { morphFund.state = .arriving morphFund.value = inValue // stack position morphFund.morphTicker = nil // release `semaphore´ @@ -230,7 +258,7 @@ final class OIMcash: ObservableObject, Sendable { } /// Stage 5: transmute morphFund - DispatchQueue.main.asyncAfter(deadline: .now() + self.delay) { + DispatchQueue.main.asyncAfter(deadline: .now() + self.flyDelay) { morphFund.state = .mutating // morphFund.state = .idle morphFund.outValue = nil @@ -309,10 +337,12 @@ final class OIMcash: ObservableObject, Sendable { return funds.filter { $0.value <= firstCoinVal } } + @discardableResult func addCash(value: UInt64, _ newState: FundState = .shouldFly, _ flippedVal: UInt64? = nil) -> OIMfund { let myTicker = ticker; ticker += 1 // TODO: atomic increment! - let fund = OIMfund(id: myTicker, state: newState, value: value, flippedVal: flippedVal) - symLog.log(">>adding \(value) \(fund.id)") + let fund = OIMfund(id: myTicker, value: value, currencyIndex: currencyIndex, + state: newState, flippedVal: flippedVal) + symLog.log(">>adding \(value) \(fund.id) \(newState)") funds.append(fund) return fund } @@ -367,24 +397,47 @@ final class OIMcash: ObservableObject, Sendable { } } - func update2(_ intVal: UInt64, _ duration: TimeInterval = 0, _ initial: TimeInterval = 0) { - let result = currency.notesCoins(intVal) - let chng1 = update1(result.0, currencyNotesCoins: currency.bankNotes, duration, initial) - let chng2 = update1(result.1, currencyNotesCoins: currency.bankCoins, duration, initial) + func clearFunds() { + funds = [] + } + + func countDosh(_ dosh: OIMdenominations) -> UInt64 { + dosh.reduce(0) { x, y in + x + y + } + } + + func interval(count: UInt64, + duration: TimeInterval, + initial: TimeInterval = 0 + ) -> TimeInterval { + (duration <= initial) || (count == 0) ? 0 + : (duration - initial) / Double(count) + } + + func update2(_ intVal: UInt64, + state: FundState = .idle, + _ duration: TimeInterval = 0, + _ initial: TimeInterval = 0) { + // optimize/rebuild funds + let dosh = currency.notesCoins(intVal) + let count = countDosh(dosh.0) + countDosh(dosh.1) + let interval = interval(count: count, duration: duration, initial: initial) + let delay = update1(dosh.0, denominations: currency.bankNotes, state: state, count, interval, initial) + update1(dosh.1, denominations: currency.bankCoins, state: state, count, interval, delay) } + @discardableResult func update1(_ notesCoins: OIMdenominations, - currencyNotesCoins: OIMdenominations, - _ duration: TimeInterval = 0, - _ initial: TimeInterval = 0) -> Bool { + denominations: OIMdenominations, + state: FundState = .idle, + _ count: UInt64, + _ interval: TimeInterval = 0, + _ initial: TimeInterval = 0) -> TimeInterval { var array = funds var changed = false var accumulatedDelay = initial - let count = notesCoins.reduce(0) { x, y in - x + y - } - let interval: TimeInterval = (duration <= initial) || (count == 0) ? 0 : (duration - initial) / Double(count) - for (index, value) in currencyNotesCoins.enumerated() { + for (index, value) in denominations.enumerated() { let wanted = notesCoins[index] // number of notes which should be shown var shownNotes = array.filter { $0.value == value } var count = shownNotes.count @@ -399,24 +452,29 @@ final class OIMcash: ObservableObject, Sendable { count -= 1 } while count < wanted { - let note = OIMfund(id: ticker, state: .idle, value: value, flippedVal: nil) - ticker += 1 - symLog.log("update: add \(note.value), \(note.id)") - if duration > 0 { - withAnimation(.move1.delay(accumulatedDelay)) { - array.append(note) - } +// if interval == 0 { // add all at once + let fund = OIMfund(id: ticker, value: value, + currencyIndex: currencyIndex, state: state, + delay: accumulatedDelay) + ticker += 1 + symLog.log("update: add \(fund.value), \(fund.id)") + array.append(fund) + changed = true +// } else { // add each delayed +// DispatchQueue.main.asyncAfter(deadline: .now() + accumulatedDelay) { +// withAnimation(.move1) { +// self.addCash(value: value, state) +// return +// } +// } accumulatedDelay += interval - } else { - array.append(note) - } - changed = true +// } count += 1 } } if changed { funds = array } - return changed + return accumulatedDelay } } diff --git a/TalerWallet1/Views/OIM/OIMcurrencyButton.swift b/TalerWallet1/Views/OIM/OIMcurrencyButton.swift @@ -117,6 +117,7 @@ struct OIMcurrencyButton: View { let currency: OIMcurrency let availableVal: UInt64 let canEdit: Bool + let isDrawer: Bool // debugging - button in the drawer var pct: CGFloat let action: () -> Void @@ -150,6 +151,11 @@ struct OIMcurrencyButton: View { : 0.5 // checkDisabled = true // TODO: don't run twice let unavailable = availableVal < value + if isDrawer && value == 2000 { + if unavailable { + let _ = print("OIMcurrencyButton❗️❗️", availableVal, value) + } + } let disabled = !canEdit || isMorphing || unavailable // cannot edit morphing funds EmptyView().modifier(OIMmod(name: imgName(outValue ?? value), flippedName: imgName(flippedVal), diff --git a/TalerWallet1/Views/OIM/OIMcurrencyDrawer.swift b/TalerWallet1/Views/OIM/OIMcurrencyDrawer.swift @@ -30,12 +30,14 @@ struct OIMcurrencyDrawer: View { HStack(alignment: .bottom, spacing: 10) { ForEach(currency.bankNotes, id: \.self) { value in let sourceID = -Int(value) - let fund = OIMfund(id: sourceID, state: .idle, value: value) + let fund = OIMfund(id: sourceID, value: value, + currencyIndex: cash.currencyIndex) OIMcurrencyButton(stack: stack.push(), fund: fund, currency: currency, availableVal: availableVal, canEdit: canEdit, + isDrawer: true, pct: 0.9, // hover a bit above the desk action: { tappedVal = value } ) @@ -44,12 +46,14 @@ struct OIMcurrencyDrawer: View { } ForEach(currency.bankCoins, id: \.self) { value in let sourceID = -Int(value) - let fund = OIMfund(id: sourceID, state: .idle, value: value) + let fund = OIMfund(id: sourceID, value: value, + currencyIndex: cash.currencyIndex) OIMcurrencyButton(stack: stack.push(), fund: fund, currency: currency, availableVal: availableVal, canEdit: canEdit, + isDrawer: true, pct: 0.9, action: { tappedVal = value } ) diff --git a/TalerWallet1/Views/OIM/OIMlayout.swift b/TalerWallet1/Views/OIM/OIMlayout.swift @@ -56,7 +56,7 @@ extension LayoutSubview { // renders a stack of funds (banknotes, coins) @available(iOS 16.4, *) struct OIMlayoutView: View { - private let symLog = SymLogV(0) + private let symLog = SymLogV() // let logger = Logger(subsystem: "net.taler.gnu", category: "OIMlayoutView") let stack: CallStack let cash: OIMcash @@ -67,25 +67,40 @@ struct OIMlayoutView: View { @EnvironmentObject private var wrapper: NamespaceWrapper @State private var checkStacks: UInt64 = 0 - func startFlying(fundID: Int) { + func endFlying(_ index: Int, after delay: Double) { + var fund = funds[index] + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + symLog.log("*** end flying:\(fund.value) \(fund.id)") + withAnimation(.move1) { + fund.state = .idle // move onto stack + cash.updateFund(fund) + } + if checkStacks > fund.value || checkStacks == 0 { + symLog.log("*** checkStacks:\(fund.value)") + checkStacks = fund.value + } + } + } + + func startFlying(fundID: Int, fromChest: Bool = false) { if let index = funds.firstIndex(where: { $0.id == fundID }) { var fund = funds[index] symLog.log("*** start flying:\(fund.value) \(fundID)") + if fromChest && fund.delay > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + fund.delay) { + withAnimation(.move1) { + fund.state = .curve + cash.updateFund(fund) + } + endFlying(index, after: cash.flyDelay / 3) + } + } else { + endFlying(index, after: cash.flyDelay) + } withAnimation(.move1) { - fund.state = .isFlying // switch off matching + fund.state = fromChest ? .reveal : .isFlying // switch off matching cash.updateFund(fund) } - DispatchQueue.main.asyncAfter(deadline: .now() + cash.delay) { - symLog.log("*** end flying:\(fund.value) \(fundID)") - withAnimation(.move1) { - fund.state = .idle // move onto stack - cash.updateFund(fund) - } - if checkStacks > fund.value || checkStacks == 0 { - symLog.log("*** checkStacks:\(fund.value)") - checkStacks = fund.value - } - } } } @@ -97,7 +112,7 @@ struct OIMlayoutView: View { fund.state = .returning cash.updateFund(fund) } - DispatchQueue.main.asyncAfter(deadline: .now() + cash.delay) { + DispatchQueue.main.asyncAfter(deadline: .now() + cash.flyDelay) { symLog.log("*** remove:\(fund.value) \(fundID)") withAnimation(.move1) { cash.removeCash(id: fundID, value: fund.value) @@ -112,13 +127,14 @@ struct OIMlayoutView: View { let value = fund.value let fundID = fund.id let fundState = fund.state - let shouldFly = fundState == .shouldFly + let shouldFly = fundState.shouldFly // let willMutate = fundState == .arriving || fundState == .mutating OIMcurrencyButton(stack: stack.push(), fund: fund, currency: cash.currency, availableVal: value, canEdit: canEdit, + isDrawer: false, pct: shouldFly ? 0.0 : 1.0 ) { // remove on button press if amountVal >= fund.value { @@ -139,7 +155,8 @@ struct OIMlayoutView: View { .onAppear { if shouldFly { startFlying(fundID: fundID) - } else { + } else if fundState.chestOpening { + startFlying(fundID: fundID, fromChest: true) // print(" ->OIMlayout ForEach fund.onAppear ignore \(value), \(fundID)") } }