taler-ios

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

commit 95996776bd925d02735039c7fc61ad10e22ddb62
parent b2a3273a4ea0976bda2b29a3c6d2be182c91997f
Author: Marc Stibane <marc@taler.net>
Date:   Mon, 14 Jul 2025 20:16:44 +0200

History animations

Diffstat:
MTalerWallet1/Views/OIM/OIMEditView.swift | 8+++++---
MTalerWallet1/Views/OIM/OIMSubjectView.swift | 6+++++-
MTalerWallet1/Views/OIM/OIMView.swift | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
MTalerWallet1/Views/OIM/OIMcash.swift | 69+++++++++++++++++++++++++++++++++++++++++++--------------------------
MTalerWallet1/Views/OIM/OIMlayout.swift | 2+-
MTalerWallet1/Views/OIM/OIMp2pReceiveView.swift | 6+++++-
6 files changed, 194 insertions(+), 97 deletions(-)

diff --git a/TalerWallet1/Views/OIM/OIMEditView.swift b/TalerWallet1/Views/OIM/OIMEditView.swift @@ -92,9 +92,11 @@ struct OIMEditView: View { ) { ZStack(alignment: .top) { VStack { -// OIMtitleView(cash: cash, amount: availRest, isSending: true, secondAmount: amount) -// OIMtitleView(cash: cash, amount: available, isSending: true, secondAmount: amount2) - OIMtitleView(cash: cash, amount: available, isSending: true, secondAmount: amountToTransfer) + OIMtitleView(cash: cash, + amount: available, + isSending: true, + history: false, + secondAmount: amountToTransfer) Spacer() OIMlineView(stack: stack.push(), cash: cash, diff --git a/TalerWallet1/Views/OIM/OIMSubjectView.swift b/TalerWallet1/Views/OIM/OIMSubjectView.swift @@ -95,7 +95,11 @@ struct OIMSubjectView: View { action: gotAction ) { VStack { - OIMtitleView(cash: cash, amount: available, isSending: true, secondAmount: amountToTransfer) + OIMtitleView(cash: cash, + amount: available, + isSending: true, + history: false, + secondAmount: amountToTransfer) Spacer() GoalsHStack(goals: goals1, selectedGoal: $selectedGoal) .opacity(appeared ? 1.0 : 0.01) diff --git a/TalerWallet1/Views/OIM/OIMView.swift b/TalerWallet1/Views/OIM/OIMView.swift @@ -15,6 +15,8 @@ let OIMACTION = "OIMaction" let OIMACTION2 = "OIMaction2" let OIMNUMBER = "OIMnumber" let OIMCHEST = "OIMchest" +let OIMBACK = "OIMback" +let OIMSIDE = "OIMside" // MARK: - struct OIMnavBack<Content: View>: View { @@ -64,6 +66,7 @@ struct OIMtitleView: View { let cash: OIMcash let amount: Amount? let isSending: Bool + let history: Bool let secondAmount: Amount? @EnvironmentObject private var wrapper: NamespaceWrapper @@ -78,24 +81,44 @@ struct OIMtitleView: View { .matchedGeometryEffect(id: OIMNUMBER, in: wrapper.namespace, isSource: true) .accessibilityHidden(true) - OIMamountV(amount: amount, currencyName: cash.currency.noteBase) - - if isSending { + if history { Spacer() - OIMamountV(amount: secondAmount, currencyName: cash.currency.noteBase) } + OIMamountV(amount: amount, currencyName: cash.currency.noteBase) + if !history { + if isSending { + Spacer() + OIMamountV(amount: secondAmount, currencyName: cash.currency.noteBase) + } - OIMactionButton(type: .sendP2P, isFinal: false, action: nil) - .frame(width: OIMactionSize, height: OIMbuttonSize) - .disabled(true) - .opacity(0.01) - .matchedGeometryEffect(id: OIMACTION, in: wrapper.namespace, isSource: true) - .accessibilityHidden(true) - + OIMactionButton(type: .sendP2P, isFinal: false, action: nil) + .frame(width: OIMactionSize, height: OIMbuttonSize) + .disabled(true) + .opacity(0.01) + .matchedGeometryEffect(id: OIMACTION, in: wrapper.namespace, isSource: true) + .accessibilityHidden(true) + } } } } // MARK: - +enum OIMViewState { + case chestsClosed + case chestClosing + case chestOpenTapped + case chestIsOpen + + case sendTapped + case sending + + case requestTapped + case requesting + + case balanceTapped + case historyShown + case historyTapped +} + @available(iOS 16.4, *) struct OIMView: View { let stack: CallStack @@ -112,21 +135,28 @@ struct OIMView: View { @State private var tappedVal: UInt64 = 0 @State private var available: Amount? = nil @State private var chestOpen: Int? = nil - @State private var showingActions = false // set true after user opened a chest, set false when choosing an action - @State private var sending = false // after user tapped on Send or on the money + @State private var viewState: OIMViewState = .chestsClosed +// @State private var showingActions = false // set true after user opened a chest, set false when choosing an action +// @State private var sending = false // after user tapped on Send or on the money @State private var closing = false // after user tapped on the open chest func noAction() { } - func sendAction() { + func requestTapped() { + + } + + func sendTapped() { withAnimation(.basicFast) { - showingActions = false // +// showingActions = false // + viewState = .sendTapped } -// let delay = - cash.moveDown() // cash animates itself +// let delay = cash.moveDown() // cash animates itself + cash.flyOneByOne(to: .drawer) // withAnimation(.basic1.delay(delay + 0.5)) { withAnimation(.basic1.delay(0.6)) { - sending = true // blends in the missing denominations +// sending = true // blends in the missing denominations + viewState = .sending } // DispatchQueue.main.asyncAfter(deadline: .now() + delay + 1) { // cash.delay DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { @@ -139,19 +169,20 @@ struct OIMView: View { } } - func closeAnimated() { + func closeChest() { if !closing { closing = true - let duration = fastAnimations ? 0.6 : 1.1 - let delay = cash.backToChest(duration) + viewState = .chestClosing + let delay = cash.flyOneByOne(to: .curve) // back to chest... DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - print("closeAnimated", delay) + print("closeChest", delay) withAnimation(.basic1) { chestOpen = nil selectedBalance = nil selectedIndex = nil available = nil - showingActions = false +// showingActions = false + viewState = .chestsClosed } closing = false } @@ -171,8 +202,10 @@ struct OIMView: View { duration = fastAnimations ? 0.6 : 1.1 initial = 0.1 #endif + viewState = .chestOpenTapped withAnimation(.basic1) { chestOpen = index + viewState = .chestIsOpen selectedIndex = index cash.currency = index == 0 ? OIMeuros : OIMleones selectedBalance = balance @@ -184,6 +217,30 @@ struct OIMView: View { } } + func closeHistory() { + withAnimation(.basic1) { + viewState = .historyTapped + } + let delay = cash.flyOneByOne(to: .idle) // back to center + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + print("closeHistory", delay) + withAnimation(.basic1) { + viewState = .chestIsOpen + } + } + } + + func openHistory() { + viewState = .balanceTapped + let delay = cash.flyOneByOne(to: .history, true) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + print("openHistory", delay) + withAnimation(.basic1) { + viewState = .historyShown + } + } + } + var body: some View { var debugTick = 0 // let _ = Self._printChanges() @@ -192,58 +249,69 @@ struct OIMView: View { !available.isZero } else { false } let topButtons = HStack(alignment: .top) { - QRButton(hideTitle: true) { - qrButtonTapped = true - } - .opacity(sending ? 0.01 : 1.0) + if chestOpen == nil { + let showQR = viewState == .chestsClosed + QRButton(hideTitle: true) { + qrButtonTapped = true + } + .opacity(showQR ? 1.0 : 0.01) .frame(width: OIMbuttonSize, height: OIMbuttonSize) - .matchedGeometryEffect(id: "OIMback", in: wrapper.namespace, isSource: true) + .matchedGeometryEffect(id: OIMBACK, in: wrapper.namespace, isSource: true) + } else { + let showRequest = viewState == .chestIsOpen + OIMactionButton(type: .requestP2P, isFinal: false, action: requestTapped) + .frame(width: OIMbuttonSize, height: OIMbuttonSize) + .opacity(showRequest ? 1.0 : 0.01) + } Spacer() -// OIMsendButton(isGoal: false, isFinal: false, enabled: enabled, action: sendAction) -// .frame(width: OIMbuttonSize, height: OIMbuttonSize) + let showSend = viewState == .chestIsOpen + OIMactionButton(type: .sendP2P, isFinal: false, action: sendTapped) + .frame(width: OIMbuttonSize, height: OIMbuttonSize) + .opacity(showSend ? 1.0 : 0.01) } let maxAvailable = cash.max(available: available?.centValue ?? 0) // let _ = print("maxAvailable", maxAvailable) - let botButtons = HStack() { - let buttons: [OIMactions] = [.deposit, .withdrawal, .requestP2P, .sendP2P].shuffled() + let sidePosition = HStack { Spacer() - ForEach(buttons, id: \.self) { button in - let action = switch button { - case .sendP2P: sendAction - case .withdrawal: noAction - case .deposit: noAction - case .requestP2P: noAction - } - OIMactionButton(type: button, isFinal: false, action: action) - .frame(width: OIMactionSize, height: OIMactionSize) - Spacer() - } + Color.clear + .frame(width: 80, height: 80) + .matchedGeometryEffect(id: OIMSIDE, in: wrapper.namespace, isSource: true) } - OIMbackground() { ZStack(alignment: .top) { topButtons VStack { - OIMtitleView(cash: cash, amount: available, isSending: sending, secondAmount: nil) + let isSending = viewState == .sending + OIMtitleView(cash: cash, + amount: available, + isSending: isSending, + history: viewState == .historyShown, + secondAmount: nil) Spacer() let isOpen = chestOpen != nil - OIMlineView(stack: stack.push(), - cash: cash, - amountVal: $availableVal, - tappedVal: $tappedVal, - canEdit: false) - .opacity(isOpen ? 1.0 : 0.01) - .scaleEffect(showingActions ? 0.6 : 1.0) - .onTapGesture { - withAnimation(.basic1) { - showingActions.toggle() + ZStack { + sidePosition +// let scaleMoney = viewState == .chestIsOpen + OIMlineView(stack: stack.push(), + cash: cash, + amountVal: $availableVal, + tappedVal: $tappedVal, + canEdit: false) + .opacity(isOpen ? 1.0 : 0.01) +// .scaleEffect(scaleMoney ? 0.6 : 1.0) + .onTapGesture { + if viewState == .historyShown { + closeHistory() + } else { + openHistory() + } } - } + } Spacer() - botButtons - .opacity(showingActions ? 1.0 : 0.01) +// botButtons +// .opacity(showingActions ? 1.0 : 0.01) } // title, money, buttons VStack { @@ -261,7 +329,7 @@ struct OIMView: View { ZStack { OIMbalanceButton(isOpen: itsMe, isSierra: index > 0, isFinal: false) { if itsMe { - closeAnimated() + closeChest() } else { openChest(index, balance) } @@ -269,7 +337,7 @@ struct OIMView: View { .frame(width: size, height: size) .zIndex(itsMe ? 3 : 0) .opacity((isClosed || itsMe) ? 1.0 : 0.01) - .matchedGeometryEffect(id: itsMe ? OIMNUMBER // (sending ? "OIMback" : OIMNUMBER) + .matchedGeometryEffect(id: itsMe ? OIMNUMBER : String(index), in: wrapper.namespace, isSource: false) .frame(width: size, height: size) @@ -284,6 +352,7 @@ struct OIMView: View { VStack { Spacer() + let showDrawer = viewState == .sending OIMcurrencyDrawer(stack: stack.push(), cash: cash, availableVal: $availableVal, @@ -294,18 +363,18 @@ struct OIMView: View { .padding(.horizontal, 5) .ignoresSafeArea(edges: .horizontal) .scrollDisabled(true) - .opacity(sending ? 1.0 : 0.01) - } // for matching positions of money + .opacity(showDrawer ? 1.0 : 0.01) + } // for matching positions of money in the drawer } } .onAppear { - showingActions = false +// showingActions = false if (chestOpen != nil) { let balance = controller.balances[0] available = balance.available availableVal = available?.centValue ?? 0 cash.update2(availableVal) // set cash to available - showingActions = true +// showingActions = true } else { availableVal = 0 cash.update2(availableVal) // set cash to available @@ -314,7 +383,8 @@ struct OIMView: View { } .onDisappear { cash.moveBack() - sending = false +// sending = false + viewState = (chestOpen != nil) ? .chestIsOpen : .chestsClosed } } } diff --git a/TalerWallet1/Views/OIM/OIMcash.swift b/TalerWallet1/Views/OIM/OIMcash.swift @@ -14,13 +14,16 @@ let MAXSTACK = 4 enum FundState: Int { case idle - case returning // user tapped to remove + case removing // user tapped to remove + case drawer 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 history // pile up for history case position // position to move to case moving // move to position @@ -29,12 +32,14 @@ enum FundState: Int { case arriving // keep flipped image, move to final position case mutating // flip back with disbled transition - var returning: Bool { self == .returning } + var removing: Bool { self == .removing } + var drawer: Bool { self == .drawer } var shouldFly: Bool { self == .shouldFly } 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 history: Bool { self == .history } var position: Bool { self == .position } var morphing: Bool { self == .morphing } var moving: Bool { self == .moving } @@ -53,11 +58,13 @@ public struct OIMfund: Identifiable, Equatable, Hashable, Sendable { 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 { // 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) + let isDrawer = state.shouldFly || state.drawer || state.removing + return state.chestOpening ? OIMCHEST + String(currencyIndex) + : state.reveal ? OIMNUMBER + : state.curve ? OIMACTION + : state.history ? OIMSIDE + : String(isDrawer ? -Int(value) // match currencyDrawer + : id) } var shouldFly2: Bool { state.shouldFly || state.chestOpening } var isFlying2: Bool { state.isFlying } @@ -350,7 +357,7 @@ final class OIMcash: ObservableObject, Sendable { func removeCash(id: Int, value: UInt64) { if let index = funds.firstIndex(where: { $0.id == id && $0.value == value }) { funds.remove(at: index) - } else if let index = funds.lastIndex(where: { $0.value == value && $0.state.returning }) { + } else if let index = funds.lastIndex(where: { $0.value == value && $0.state.removing }) { funds.remove(at: index) } else if let index = funds.firstIndex(where: { $0.value == value }) { funds.remove(at: index) @@ -377,7 +384,7 @@ final class OIMcash: ObservableObject, Sendable { for var fund in sorted { // withAnimation(.easeOut1.delay(counter * 0.1)) { withAnimation(.easeOut1) { - fund.state = .returning + fund.state = .drawer self.updateFund(fund) } if lastVal != fund.value { @@ -390,7 +397,7 @@ final class OIMcash: ObservableObject, Sendable { func moveBack() { for var fund in funds { - if fund.state == .returning { + if fund.state == .drawer { fund.state = .idle self.updateFund(fund) } @@ -407,39 +414,49 @@ final class OIMcash: ObservableObject, Sendable { } } - func flyToChest(_ index: Int, after delay: TimeInterval) { + func flyToTarget(target: FundState, index: Int, after delay: TimeInterval) { let interval = fastAnimations ? 0.2 : 0.4 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { var fund = self.funds[index] withAnimation(.move1) { - fund.state = .curve + fund.state = target self.updateFund(fund) } - print("backToChest", fund.id, fund.value, delay) - DispatchQueue.main.asyncAfter(deadline: .now() + interval) { - withAnimation(.move1) { - fund.state = .reveal - self.updateFund(fund) - } + print("flyToTarget", target, fund.id, fund.value, delay) + if target == .curve { DispatchQueue.main.asyncAfter(deadline: .now() + interval) { withAnimation(.move1) { - do { - self.funds.remove(at: index) - } catch {} + fund.state = .reveal + self.updateFund(fund) + } + DispatchQueue.main.asyncAfter(deadline: .now() + interval) { + withAnimation(.move1) { + do { + self.funds.remove(at: index) + } catch {} + } } } - } + } // curve } } - func backToChest(_ duration: TimeInterval) -> TimeInterval { + func flyOneByOne(to target: FundState, + _ increasing: Bool = false, + _ duration: TimeInterval? = nil + ) -> TimeInterval { var count = funds.count var initial: TimeInterval = 0.01 - let interval = interval(count: count, duration: duration, initial: initial) - + let interval = interval(count: count, + duration: duration ?? (fastAnimations ? 0.6 : 1.1), + initial: initial) + var index = 0 while count > 0 { // for each fund, small to high count -= 1 - flyToChest(count, after: initial) + flyToTarget(target: target, + index: increasing ? index : count, + after: initial) + index += 1 initial += interval } return initial - interval + (fastAnimations ? 0.1 : 0.2) diff --git a/TalerWallet1/Views/OIM/OIMlayout.swift b/TalerWallet1/Views/OIM/OIMlayout.swift @@ -109,7 +109,7 @@ struct OIMlayoutView: View { var fund = funds[index] symLog.log("*** fly back:\(fund.value) \(fundID)") withAnimation(.move1) { - fund.state = .returning + fund.state = .removing cash.updateFund(fund) } DispatchQueue.main.asyncAfter(deadline: .now() + cash.flyDelay) { diff --git a/TalerWallet1/Views/OIM/OIMp2pReceiveView.swift b/TalerWallet1/Views/OIM/OIMp2pReceiveView.swift @@ -44,7 +44,11 @@ struct OIMp2pReceiveView: View { let goal = terms.icon_id let effective = terms.amount VStack { - OIMtitleView(cash: cash, amount: effective, isSending: false, secondAmount: nil) + OIMtitleView(cash: cash, + amount: effective, + isSending: false, + history: false, + secondAmount: nil) Spacer() if let goal { Image(goal)