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:
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: -