taler-ios

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

commit 685de878b071628f06781af18a6ffb90f07b9452
parent 2d89f6fcbd7a32e0f97e793a797a53abdf708e52
Author: Marc Stibane <marc@taler.net>
Date:   Tue,  2 Sep 2025 01:06:43 +0200

chart overlay with notes/coins

Diffstat:
MTalerWallet1/Views/OIM/ChartHistoryView.swift | 217++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
MTalerWallet1/Views/OIM/OIMtransactions.swift | 133++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
2 files changed, 239 insertions(+), 111 deletions(-)

diff --git a/TalerWallet1/Views/OIM/ChartHistoryView.swift b/TalerWallet1/Views/OIM/ChartHistoryView.swift @@ -9,6 +9,7 @@ import SwiftUI import Charts import os.log import SymLog +import taler_swift fileprivate let lineWidth = 6.0 @@ -40,23 +41,30 @@ struct ChartHistoryBadge: View { // MARK: - @available(iOS 16.4, *) struct ChartHistoryView: View { + let stack: CallStack private let logger = Logger(subsystem: "net.taler.gnu", category: "Chart") let currency: OIMcurrency @Binding var chartData: [LineMarkData] @Binding var dataPointWidth: CGFloat - @Binding var showData: Bool + let scrollBack: Bool - @State private var maxXValue: Int // #of dataPoints in history, plus spacers + let maxXValue: Int // #of dataPoints in history, plus spacers @State private var maxYValue: Double // max balance @Namespace var chartID - @State var scrollPosition: Double = 0 - @State var selectedTX: Int? = nil + @State private var scrollPosition: Double = 0 + @State private var selectedTX: Int? = nil + @State private var selectedY: Double? = nil + @State private var selectedRange: ClosedRange<Double>? + @State private var lastDelta: Double? - init(currency: OIMcurrency, chartData: Binding<[LineMarkData]>, dataPointWidth: Binding<CGFloat>, - showData: Binding<Bool>, maxIndex: Int = 1, maxValue: Double = 200) { + init(stack: CallStack, + currency: OIMcurrency, chartData: Binding<[LineMarkData]>, dataPointWidth: Binding<CGFloat>, + scrollBack: Bool, maxIndex: Int = 1, maxValue: Double = 200 + ) { + self.stack = stack self.currency = currency - self._showData = showData + self.scrollBack = scrollBack self._chartData = chartData self._dataPointWidth = dataPointWidth self.maxYValue = maxValue @@ -74,15 +82,32 @@ struct ChartHistoryView: View { return nil } + func isTapped(y: Double) -> Bool { + if let selectedY { + return abs(y - selectedY) < 5 + } + return false + } + + func magnify(_ newDelta: Double) { + if let lastDelta { + let delta = newDelta - lastDelta + let absDelta = abs(delta) + var width = dataPointWidth + if delta > 0 { // zoom in + width += absDelta * 10 + dataPointWidth = width < DATAPOINTWIDTH_MAX ? width : DATAPOINTWIDTH_MAX + } else if delta < 0 { // zoom out + width -= absDelta * 10 + dataPointWidth = width > DATAPOINTWIDTH_MIN ? width : DATAPOINTWIDTH_MIN + } + } + } var body: some View { -// let stroke = StrokeStyle(lineWidth: CGFloat, -// lineCap: CGLineCap, -// lineJoin: CGLineJoin, -// miterLimit: CGFloat, -// dash: [CGFloat], -// dashPhase: CGFloat) let count = chartData.count + let width = dataPointWidth * CGFloat(maxXValue) +// let _ = logger.log("ChartHistoryView \(width.pTwo)") let chart = Chart { ForEach(0..<count, id: \.self) { index in let data = chartData[index] @@ -90,10 +115,13 @@ struct ChartHistoryView: View { let yVal = yTransform(data.balance) LineMark(x: .value("x", xVal), y: .value("y", yVal)) + .foregroundStyle(WalletColors().talerColor) .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) // 0 = uniform spline, 0.5 = centripetal spline, 1.0 = chordal spline // See https://en.wikipedia.org/wiki/File:Catmull-Rom_examples_with_parameters..png - .interpolationMethod(.catmullRom(alpha: 0.5)) // centripetal +// .interpolationMethod(.catmullRom(alpha: 0.5)) // centripetal + .interpolationMethod(.monotone) +// .interpolationMethod(.stepCenter) // stepStart if let talerTX = data.talerTX { PointMark(x: .value("x", xVal), @@ -102,49 +130,72 @@ struct ChartHistoryView: View { .symbolSize(0) // hide the existing symbol .annotation(position: .overlay, alignment: .center) { let isSelected = xVal == selectedTX + let isTapped = isTapped(y: yVal) + if isSelected && isTapped { + let _ = logger.log("annotation \(xVal) \(yVal.pTwo) \(selectedY ?? 0)") + } ChartHistoryBadge(talerTX: talerTX) .id(index) .scaleEffect(isSelected ? 1.8 : 1.0) } + } else if xVal == 0 { +// let _ = logger.log("PointMark \(xVal) \(yVal.pTwo)") + PointMark(x: .value("x", xVal), + y: .value("y", yVal)) + .symbol(symbol: { + Image(systemName: "banknote.fill") // wallet.bifold.fill + .resizable() + .scaledToFit() + .frame(width: 25) + .foregroundStyle(WalletColors().talerColor) + }) } } }.id(chartID) - .chartXScale(domain: -Double(maxXValue)...0.2) + .chartXScale(domain: -Double(maxXValue)-0.4...0.1) .chartXAxis { } .chartYScale(domain: -yTransform(maxYValue / 10)...yTransform(maxYValue)) .chartYAxis { - AxisMarks(preset: .aligned, position: .leading) { _ in + AxisMarks(preset: .aligned, position: .trailing) { _ in AxisGridLine() AxisValueLabel(horizontalSpacing: 16) } } .chartLegend(.hidden) - .frame(width: dataPointWidth * CGFloat(count)) -// .chartOverlay { chartProxy in -// Rectangle().fill(.clear).contentShape(Rectangle()) -// .gesture( -// magnification//.simultaneously(with: select) -// ) -// } - + .frame(width: width) + .onChange(of: selectedRange) { newRange in + if let newRange { // pinch started - or changed + let newDelta = newRange.upperBound - newRange.lowerBound +// logger.log("selectedRange \(newDelta)") + magnify(newDelta) + lastDelta = newDelta + } else { // pinch ended + lastDelta = nil + } + } ZStack(alignment: .top) { - if showData { - ScrollViewReader { scrollProxy in + ScrollViewReader { scrollProxy in + OptimalSize(.horizontal) { ScrollView(.horizontal) { if #available(iOS 17.0, *) { chart + .chartYSelection(value: $selectedY) .chartXSelection(value: $selectedTX) + .chartXSelection(range: $selectedRange) +// .border(.red) } else { chart } - } - - .task { + }//.border(.blue) + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .task(id: scrollBack) { logger.log("Task") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - logger.log("Scrolling") - withAnimation(.easeOut(duration: 5.0)) { - scrollProxy.scrollTo(chartID, anchor: .bottomTrailing) + if scrollBack { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + logger.log("Scrolling") + withAnimation() { // .easeOut(duration: 3.0) doesn't work, always uses standard timing + scrollProxy.scrollTo(chartID, anchor: .bottomTrailing) + } } } } @@ -155,29 +206,91 @@ struct ChartHistoryView: View { scrollProxy.scrollTo(chartID, anchor: .bottomTrailing) } } - // Button("Top") { withAnimation { proxy.scrollTo(topID) } } } - if let selectedTX { - if let lineMark = lineMarkforSelection { - if let talerTX = lineMark.talerTX { - // TODO: hero animation from index - VStack { - Spacer() - let common = talerTX.common - let eff = common.amountEffective - let balance = lineMark.balance * Double(currency.factor) - VStack { - Text("Balance: \(balance.pTwo)") - OIMamountV(amount: eff, currencyName: currency.currencyStr, factor: currency.factor) - } - .padding() - .background(WalletColors().backgroundColor) - Spacer() - } - } +// Button("Top") { withAnimation { proxy.scrollTo(topID) } } + } // ScrollViewReader + if let selectedTX { + if let lineMark = lineMarkforSelection { + if let talerTX = lineMark.talerTX { + ChartOverlayV(stack: stack.push(), + currency: currency, + balance: lineMark.balance, + talerTX: talerTX) } } } + } // ZStack + } +} +// MARK: - +@available(iOS 16.4, *) +struct ChartOverlayV: View { + let stack: CallStack + let currency: OIMcurrency + let balance: Double + let talerTX: TalerTransaction + let effective: Amount + + @StateObject private var cash: OIMcash + @State var amountVal: UInt64 = 0 + @Namespace var namespace + + init(stack: CallStack, + currency: OIMcurrency, + balance: Double, + talerTX: TalerTransaction + ) { + self.stack = stack + self.currency = currency + self.balance = balance + self.talerTX = talerTX + let oimCash = OIMcash(currency) + self._cash = StateObject(wrappedValue: { oimCash }()) + let common = talerTX.common + let eff = common.amountEffective + self.effective = eff + let cents = eff.centValue + self._amountVal = State(wrappedValue: { cents }()) +// print("ChartOverlayV init:", cents) + } + + var body: some View { + // TODO: hero animation from index + VStack { + Spacer() + let balance2 = balance * Double(currency.factor) + VStack { + Text("Balance: \(balance2.pTwo)") + .foregroundStyle(WalletColors().gray2) + OIMamountV(amount: effective, currencyName: currency.currencyStr, factor: currency.factor) + OIMlineView(stack: stack.push(), + cash: cash, + amountVal: $amountVal, + canEdit: false) +// .opacity(isOpen ? 1 : INVISIBLE) +// .scaleEffect(scaleMoney ? 0.6 : 1) + }.environmentObject(NamespaceWrapper(namespace)) // keep OIMviews apart + .padding() + .onAppear { + cash.update2(amountVal) // set cash to talerTX.common.effective + } + .onChange(of: effective) { newVal in + print("ChartOverlayV onChange of:", newVal) + let cents = newVal.centValue + cash.update2(cents) // set cash to talerTX.common.effective + } + .background( + RoundedRectangle(cornerRadius: 30).style( + withStroke: Color.primary, + lineWidth: 2, + fill: LinearGradient( + gradient: Gradient(colors: [WalletColors().talerColor, .white]), + startPoint: .top, + endPoint: .bottom + ).opacity(0.85) + ) + ) + Spacer() } } } diff --git a/TalerWallet1/Views/OIM/OIMtransactions.swift b/TalerWallet1/Views/OIM/OIMtransactions.swift @@ -8,6 +8,10 @@ import SwiftUI import taler_swift +let DATAPOINTWIDTH = 60.0 +let DATAPOINTWIDTH_MIN = 25.0 +let DATAPOINTWIDTH_MAX = 200.0 + enum OIMtransactionsState { case chestIsOpen case chestOpenTapped // <- move money @@ -20,7 +24,12 @@ enum OIMtransactionsState { // MARK: - struct LineMarkData: Identifiable { - var id: String + var id: String { + if let talerTX { + return talerTX.id + } + return UUID().uuidString + } let distance: Int let balance: Double let talerTX: TalerTransaction? @@ -42,17 +51,14 @@ struct OIMtransactions: View { @State private var availableVal: UInt64 = 0 @State private var available: Amount? = nil @State private var viewState: OIMtransactionsState = .chestIsOpen - @State private var showChart = false @State private var closing = false // after user tapped on the open chest - @State private var talerTXs: [TalerTransaction] = [] + @State private var lineMarks: [LineMarkData] = [] @State private var chartData: [LineMarkData] = [] @State private var chartMaxY: Double = 200 // max balance - @State private var chartMaxX: Int = 1 // count of transactions plus spacers - @State private var dataPointWidth: CGFloat = 100 + @State private var dataPointWidth: CGFloat = DATAPOINTWIDTH // when we start fetching transactions in chunks, MinX will get negative (going back in time) func closeHistory() { - showChart = false withAnimation(.basic1) { viewState = .historyTapped } @@ -60,7 +66,7 @@ struct OIMtransactions: View { DispatchQueue.main.asyncAfter(deadline: .now() + delay0) { let delay = cash.flyOneByOne(to: .idle) // back to center DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - print("closeHistory", delay) +// print("closeHistory", delay) withAnimation(.basic1) { viewState = .chestIsOpen } @@ -77,39 +83,40 @@ struct OIMtransactions: View { @discardableResult func hideHistoryItems(_ interval: TimeInterval = 0) -> TimeInterval { - var delay = 0.0 - var count = talerTXs.count - while count > 0 { - if interval > 0 { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - if talerTXs.count > 0 { - withAnimation { - talerTXs.removeLast() - } + if interval > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + interval) { + let count = lineMarks.count + if count > 0 { + withAnimation { + lineMarks.removeLast() + } + if count > 1 { + hideHistoryItems(interval) } } - delay += interval - } else { - talerTXs.removeLast() } - count -= 1 + return Double(lineMarks.count) * interval } - return delay + lineMarks = [] + return 0 } @discardableResult func showHistoryItems(_ interval: TimeInterval = 0) -> TimeInterval { var delay = 0.0 - for talerTX in history { + let count = chartData.count + let reduce = interval / Double(count + 2) + var index = 0 + for lineMark in chartData { if interval > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { withAnimation { - talerTXs.append(talerTX) + lineMarks.append(lineMark) } } - delay += interval + delay += (interval - Double(index) * reduce) } else { - talerTXs.append(talerTX) + lineMarks.append(lineMark) } } return delay @@ -125,9 +132,10 @@ struct OIMtransactions: View { let chartButtons = HStack { let a11yZoomOutStr = String(localized: "Zoom out", comment: "a11y for the zoom button") ZoomOutButton(accessibilityLabelStr: a11yZoomOutStr) { - if dataPointWidth > 25 { + let width = dataPointWidth - 10 + if dataPointWidth > DATAPOINTWIDTH_MIN { withAnimation(.easeInOut(duration: 0.6)) { - dataPointWidth -= 10 + dataPointWidth = width > DATAPOINTWIDTH_MIN ? width : DATAPOINTWIDTH_MIN } } }//.buttonStyle(.borderedProminent) @@ -135,9 +143,10 @@ struct OIMtransactions: View { Text("width: \(dataPointWidth.pTwo)") let a11yZoomInStr = String(localized: "Zoom in", comment: "a11y for the zoom button") ZoomInButton(accessibilityLabelStr: a11yZoomInStr) { - if dataPointWidth < 185 { + let width = dataPointWidth + 15 + if dataPointWidth < DATAPOINTWIDTH_MAX { withAnimation(.easeInOut(duration: 0.6)) { - dataPointWidth += 15 + dataPointWidth = width < DATAPOINTWIDTH_MAX ? width : DATAPOINTWIDTH_MAX } } }//.buttonStyle(.borderedProminent) @@ -150,22 +159,29 @@ struct OIMtransactions: View { amount: available, history: true, //viewState == .historyShown, secondAmount: nil) - chartButtons.padding(.top, 4) + + if #unavailable(iOS 17.0) { + chartButtons.padding(.top, 4) // .border(.red) + } } Spacer(minLength: 0) ZStack { HStack { - if chartMaxX > 1 && showChart { - let _ = print(chartMaxX, chartMaxY) - ChartHistoryView(currency: cash.currency, - chartData: $chartData, - dataPointWidth: $dataPointWidth, - showData: $showChart, - maxIndex: chartMaxX, - maxValue: chartMaxY) - } else { - Spacer() + Spacer(minLength: 0) + let count = lineMarks.count + if count > 1 { + let last = lineMarks.last + let maxIndex = last?.distance ?? 20 // should never happen + let scrollBack = count == chartData.count +// let _ = print("calling ChartHistoryView", count, maxIndex, scrollBack) + ChartHistoryView(stack: stack.push(), + currency: cash.currency, + chartData: $lineMarks, + dataPointWidth: $dataPointWidth, + scrollBack: scrollBack, + maxIndex: maxIndex, + maxValue: chartMaxY) } sidePosition .padding(.leading, 20) @@ -183,25 +199,20 @@ struct OIMtransactions: View { Spacer(minLength: 0) } // title, HStack } - .task { - computeData(from: history, balance: balance.available.value) - // set chartData, chartMaxX, chartMaxY - } .onAppear { available = balance.available availableVal = available?.centValue ?? 0 // TODO: centValue factor cash.update2(availableVal) // set cash to available cash.setTarget(.history) debugTick += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + flyDelay) { - showChart = true + if chartData.count == 0 { + chartData(from: history, balance: balance.available.value) // set chartMaxY, chartData } showHistoryItems(Animation.talerDelay0) } .onDisappear { // cash.moveBack() viewState = .chestIsOpen - showChart = false } } } @@ -209,7 +220,7 @@ struct OIMtransactions: View { @available(iOS 16.4, *) extension OIMtransactions { - func computeData(from history: [TalerTransaction], balance: Double) { + func chartData(from history: [TalerTransaction], balance: Double) { /// Algorithm for the X value of the river of time /// Endpoint (now) is 0 /// same day transactions have no space between them @@ -217,29 +228,31 @@ extension OIMtransactions { /// week differs: add another spacer /// month differs: add one more spacer /// year differs: add yet another spacer - var lineMarks: [LineMarkData] = [ LineMarkData(id: "Start", distance: 0, balance: balance, talerTX: nil) ] + var lineMarks: [LineMarkData] = [ LineMarkData(distance: 0, balance: balance, talerTX: nil) ] var balance = balance var maxBalance = balance let calendar = Calendar.current // or do we need (identifier: .gregorian) ? var lastTx = calendar.dateComponents([.year, .month, .day, .weekOfMonth], from: Date.now) var xIndex = 0 - func lineMark(for talerTransaction: TalerTransaction) -> LineMarkData { + func lineMark(for talerTransaction: TalerTransaction) -> LineMarkData? { let common = talerTransaction.common let amount = common.amountEffective let incoming = common.isIncoming + let pending = common.isPending let timestamp = common.timestamp let date = try! Date(milliseconds: timestamp.milliseconds()) let components = calendar.dateComponents([.year, .month, .day, .weekOfMonth], from: date) // add spacers + let isFirst = xIndex == 0 if lastTx.year != components.year { - xIndex += 4 + xIndex += isFirst ? 1 : 4 } else if lastTx.month != components.month { - xIndex += 3 + xIndex += isFirst ? 1 : 3 } else if lastTx.weekOfMonth != components.weekOfMonth { - xIndex += 2 - // } else if lastItem.weekday != components.weekday { - // index += 2 + xIndex += isFirst ? 1 : 2 +// } else if lastItem.weekday != components.weekday { +// index += 2 } else if lastTx.day != components.day { xIndex += 1 } @@ -255,16 +268,18 @@ extension OIMtransactions { if balance > maxBalance { maxBalance = balance } - return LineMarkData(id: common.transactionId, distance: xIndex, balance: balance, talerTX: talerTransaction) + return LineMarkData(distance: xIndex, balance: balance, talerTX: talerTransaction) } for talerTransaction in history { - lineMarks.append(lineMark(for: talerTransaction)) + if let mark = lineMark(for: talerTransaction) { + lineMarks.append(mark) + } } chartData = lineMarks - chartMaxY = maxBalance * 1.26 // 10 % plus, log2() - chartMaxX = xIndex + chartMaxY = maxBalance * yTransform(1.1) // 10 % plus, log2() +// print("computeData:", history.count, xIndex) } } // MARK: -