taler-ios

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

commit 1fbf8e883a5a703cf1dcffe54c721be28ffc83d9
parent 0c657e9dfe14ba35ff939134e6e87e04b25e59e1
Author: Marc Stibane <marc@taler.net>
Date:   Sat, 30 Aug 2025 06:30:23 +0200

Chart history

Diffstat:
ATalerWallet1/Views/OIM/ChartHistoryView.swift | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTalerWallet1/Views/OIM/OIMtransactions.swift | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
2 files changed, 328 insertions(+), 46 deletions(-)

diff --git a/TalerWallet1/Views/OIM/ChartHistoryView.swift b/TalerWallet1/Views/OIM/ChartHistoryView.swift @@ -0,0 +1,183 @@ +/* + * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI +import Charts +import os.log +import SymLog + +fileprivate let lineWidth = 6.0 + +func yTransform(_ yVal: Double) -> Double { +#if OIMx + log2(yVal) +#else + yVal +#endif +} + +// MARK: - +struct ChartHistoryBadge: View { + let talerTX: TalerTransaction? + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + var body: some View { + let isDark = colorScheme == .dark + let increasedContrast = colorSchemeContrast == .increased + if let talerTX { + TransactionIconBadge(from: talerTX, isDark: isDark, increasedContrast) + .talerFont(.largeTitle) + } + } +} + +// MARK: - +@available(iOS 16.4, *) +struct ChartHistoryView: View { + 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 + + @State private var 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 + + init(currency: OIMcurrency, chartData: Binding<[LineMarkData]>, dataPointWidth: Binding<CGFloat>, + showData: Binding<Bool>, maxIndex: Int = 1, maxValue: Double = 200) { + self.currency = currency + self._showData = showData + self._chartData = chartData + self._dataPointWidth = dataPointWidth + self.maxYValue = maxValue + self.maxXValue = maxIndex + } + + var lineMarkforSelection: LineMarkData? { + if let selectedTX { + for lineMark in chartData { + if lineMark.distance == -selectedTX { + return lineMark + } + } + } + return nil + } + + + var body: some View { +// let stroke = StrokeStyle(lineWidth: CGFloat, +// lineCap: CGLineCap, +// lineJoin: CGLineJoin, +// miterLimit: CGFloat, +// dash: [CGFloat], +// dashPhase: CGFloat) + let count = chartData.count + let chart = Chart { + ForEach(0..<count, id: \.self) { index in + let data = chartData[index] + let xVal = -data.distance + let yVal = yTransform(data.balance) + LineMark(x: .value("x", xVal), + y: .value("y", yVal)) + .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 + + if let talerTX = data.talerTX { + PointMark(x: .value("x", xVal), + y: .value("y", yVal)) + + .symbolSize(0) // hide the existing symbol + .annotation(position: .overlay, alignment: .center) { + let isSelected = xVal == selectedTX + ChartHistoryBadge(talerTX: talerTX) + .id(index) + .scaleEffect(isSelected ? 1.8 : 1.0) + } + } + } + }.id(chartID) + .chartXScale(domain: -Double(maxXValue)...0.2) + .chartXAxis { } + .chartYScale(domain: -yTransform(maxYValue / 10)...yTransform(maxYValue)) + .chartYAxis { + AxisMarks(preset: .aligned, position: .leading) { _ 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) +// ) +// } + + ZStack(alignment: .top) { + if showData { + ScrollViewReader { scrollProxy in + ScrollView(.horizontal) { + if #available(iOS 17.0, *) { + chart + .chartXSelection(value: $selectedTX) + } else { + chart + } + } + + .task { + logger.log("Task") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + logger.log("Scrolling") + withAnimation(.easeOut(duration: 5.0)) { + scrollProxy.scrollTo(chartID, anchor: .bottomTrailing) + } + } + } + + .onTapGesture(count: 2) { + withAnimation(.easeOut(duration: 0.6)) { + dataPointWidth = 100 // reset the scale first + 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() + } + } + } + } + } + } + } +} diff --git a/TalerWallet1/Views/OIM/OIMtransactions.swift b/TalerWallet1/Views/OIM/OIMtransactions.swift @@ -19,33 +19,13 @@ enum OIMtransactionsState { } // MARK: - -struct OIMhistoryItem: View { - let talerTX: TalerTransaction - - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - - var body: some View { - let common = talerTX.common - let value = common.amountEffective.valueStr - let incoming = common.incoming() - let isDark = colorScheme == .dark - let increasedContrast = colorSchemeContrast == .increased - let badge = TransactionIconBadge(from: talerTX, isDark: isDark, increasedContrast) - .talerFont(.largeTitle) - let amount = Text("\(value)") - .talerFont(.title1) - VStack { - if incoming { - badge - amount - } else { - amount - badge - } - } - } +struct LineMarkData: Identifiable { + var id: String + let distance: Int + let balance: Double + let talerTX: TalerTransaction? } + // MARK: - @available(iOS 16.4, *) struct OIMtransactions: View { @@ -62,10 +42,17 @@ 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 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 + // when we start fetching transactions in chunks, MinX will get negative (going back in time) func closeHistory() { + showChart = false withAnimation(.basic1) { viewState = .historyTapped } @@ -95,9 +82,10 @@ struct OIMtransactions: View { while count > 0 { if interval > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - withAnimation { - talerTXs.removeLast() - return // Conflicting arguments to generic parameter 'Result' ('Void' vs …) + if talerTXs.count > 0 { + withAnimation { + talerTXs.removeLast() + } } } delay += interval @@ -134,30 +122,53 @@ struct OIMtransactions: View { let sidePosition = Color.clear .frame(width: 80, height: 80) .matchedGeometryEffect(id: OIMSIDE, in: wrapper.namespace, isSource: true) + let chartButtons = HStack { + let a11yZoomOutStr = String(localized: "Zoom out", comment: "a11y for the zoom button") + ZoomOutButton(accessibilityLabelStr: a11yZoomOutStr) { + if dataPointWidth > 25 { + withAnimation(.easeInOut(duration: 0.6)) { + dataPointWidth -= 10 + } + } + }//.buttonStyle(.borderedProminent) + // Text("scroll: \(scrollPosition.pTwo) width: \(dataPointWidth.pTwo) maxX: \(maxXValue.pTwo)") + Text("width: \(dataPointWidth.pTwo)") + let a11yZoomInStr = String(localized: "Zoom in", comment: "a11y for the zoom button") + ZoomInButton(accessibilityLabelStr: a11yZoomInStr) { + if dataPointWidth < 185 { + withAnimation(.easeInOut(duration: 0.6)) { + dataPointWidth += 15 + } + } + }//.buttonStyle(.borderedProminent) + } OIMbackground() { VStack { - OIMtitleView(cash: cash, - amount: available, - history: true, //viewState == .historyShown, - secondAmount: nil) + ZStack(alignment: .top) { + OIMtitleView(cash: cash, + amount: available, + history: true, //viewState == .historyShown, + secondAmount: nil) + chartButtons.padding(.top, 4) +// .border(.red) + } Spacer(minLength: 0) ZStack { HStack { - ScrollView(.horizontal) { - LazyHStack { - ForEach(talerTXs) { talerTX in - OIMhistoryItem(talerTX: talerTX) - .flippedLeftRight() - .padding(.horizontal) -// .transition(.backslide) - Spacer() - } - } - //.border(.red) - }.flippedLeftRight() + if chartMaxX > 1 && showChart { + let _ = print(chartMaxX, chartMaxY) + ChartHistoryView(currency: cash.currency, + chartData: $chartData, + dataPointWidth: $dataPointWidth, + showData: $showChart, + maxIndex: chartMaxX, + maxValue: chartMaxY) + } else { + Spacer() + } sidePosition - .padding(.leading, 35) + .padding(.leading, 20) }// .border(.blue) OIMlineView(stack: stack.push(), cash: cash, @@ -172,17 +183,105 @@ 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 + } showHistoryItems(Animation.talerDelay0) } .onDisappear { // cash.moveBack() viewState = .chestIsOpen + showChart = false + } + } +} +// MARK: - +@available(iOS 16.4, *) +extension OIMtransactions { + + func computeData(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 + /// day differs: add 1 spacer + /// 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 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 { + let common = talerTransaction.common + let amount = common.amountEffective + let incoming = common.incoming() + let timestamp = common.timestamp + let date = try! Date(milliseconds: timestamp.milliseconds()) + let components = calendar.dateComponents([.year, .month, .day, .weekOfMonth], from: date) + // add spacers + if lastTx.year != components.year { + xIndex += 4 + } else if lastTx.month != components.month { + xIndex += 3 + } else if lastTx.weekOfMonth != components.weekOfMonth { + xIndex += 2 + // } else if lastItem.weekday != components.weekday { + // index += 2 + } else if lastTx.day != components.day { + xIndex += 1 + } + // advance to next index + xIndex += 1 + // compute balance before that transaction + if incoming { + balance -= amount.value + } else { + balance += amount.value + } + lastTx = components + if balance > maxBalance { + maxBalance = balance + } + return LineMarkData(id: common.transactionId, distance: xIndex, balance: balance, talerTX: talerTransaction) + } + + for talerTransaction in history { + lineMarks.append(lineMark(for: talerTransaction)) } + + chartData = lineMarks + chartMaxY = maxBalance * 1.26 // 10 % plus, log2() + chartMaxX = xIndex + } +} +// MARK: - + + +extension Double { + var pTwo: String { + String(format: "%.2f", self) + } +} +extension CGFloat { + var pTwo: String { + String(format: "%.2f", self) + } +} +extension CGSize { + var pTwo: String { + String(format: "%.2f, %.2f", self.width, self.height) } }