commit 1fbf8e883a5a703cf1dcffe54c721be28ffc83d9
parent 0c657e9dfe14ba35ff939134e6e87e04b25e59e1
Author: Marc Stibane <marc@taler.net>
Date: Sat, 30 Aug 2025 06:30:23 +0200
Chart history
Diffstat:
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)
}
}