OIMtransactions.swift (12986B)
1 /* 2 * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * @author Marc Stibane 7 */ 8 import SwiftUI 9 import taler_swift 10 11 let DATAPOINTWIDTH = 60.0 12 let DATAPOINTWIDTH_MIN = 25.0 13 let DATAPOINTWIDTH_MAX = 200.0 14 15 enum OIMtransactionsState { 16 case chestIsOpen 17 case chestOpenTapped // <- move money 18 19 case balanceTapped 20 case historyShown 21 case historyTapped 22 23 } 24 25 // MARK: - 26 enum HistoryMarker: Int { 27 case none 28 case day 29 case week 30 case month 31 case year 32 } 33 34 struct HistoryItem: Identifiable { 35 var id: String { 36 if let talerTX { 37 return talerTX.id 38 } 39 return UUID().uuidString 40 } 41 let distance: Int 42 let marker: HistoryMarker 43 let balance: Double 44 let talerTX: TalerTransaction? 45 } 46 47 // MARK: - 48 @available(iOS 16.4, *) 49 struct OIMtransactions: View { 50 let stack: CallStack 51 // let decimal: Int // 0 for ¥,HUF; 2 for $,€,£; 3 for ﷼,₯ (arabic) 52 let balance: Balance // this is the currency to be used 53 let cash: OIMcash 54 let history: [TalerTransaction] 55 56 @Environment(\.dismiss) var dismiss // pop back once 57 @EnvironmentObject private var controller: Controller 58 @EnvironmentObject private var wrapper: NamespaceWrapper 59 @AppStorage("oimChart") var oimChart: Bool = false // false = river 60 61 @State private var availableVal: UInt64 = 0 62 @State private var available: Amount? = nil 63 @State private var viewState: OIMtransactionsState = .chestIsOpen 64 @State private var closing = false // after user tapped on the open chest 65 @State private var shownItems: [HistoryItem] = [] 66 @State private var computedItems: [HistoryItem] = [] 67 @State private var chartMaxY: Double = 200 // max balance 68 @State private var dataPointWidth: CGFloat = DATAPOINTWIDTH 69 // when we start fetching transactions in chunks, MinX will get negative (going back in time) 70 71 func closeHistory() { 72 withAnimation(.basic1) { 73 viewState = .historyTapped 74 } 75 let delay0 = hideHistoryItems(oimChart ? Animation.talerDelay0 : 0.0) 76 DispatchQueue.main.asyncAfter(deadline: .now() + delay0) { 77 let delay = cash.flyOneByOne(to: .idle) // back to center 78 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 79 // print("closeHistory", delay) 80 withAnimation(.basic1) { 81 viewState = .chestIsOpen 82 } 83 DispatchQueue.main.asyncAfter(deadline: .now() + Animation.talerDuration2) { 84 var transaction = Transaction() 85 transaction.disablesAnimations = true 86 withTransaction(transaction) { 87 dismiss() 88 } 89 } 90 } 91 } 92 } 93 94 @discardableResult 95 func hideHistoryItems(_ interval: TimeInterval = 0) -> TimeInterval { 96 if interval > 0 { 97 DispatchQueue.main.asyncAfter(deadline: .now() + interval) { 98 let count = shownItems.count 99 if count > 0 { 100 withAnimation { 101 shownItems.removeLast() 102 } 103 if count > 1 { 104 hideHistoryItems(interval) 105 } 106 } 107 } 108 return Double(shownItems.count) * interval 109 } 110 shownItems = [] 111 return 0 112 } 113 114 @discardableResult 115 func showHistoryItems(_ interval: TimeInterval = 0) -> TimeInterval { 116 var delay = 0.0 117 let count = computedItems.count 118 let reduce = interval / Double(count + 2) 119 var index = 0 120 for item in computedItems { 121 if interval > 0 { 122 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 123 withAnimation { 124 shownItems.append(item) 125 } 126 } 127 delay += (interval - Double(index) * reduce) 128 } else { 129 shownItems.append(item) 130 } 131 } 132 return delay 133 } 134 135 var body: some View { 136 var debugTick = 0 137 // let _ = Self._printChanges() 138 139 let sidePosition = Color.clear 140 .frame(width: 80, height: 80) 141 .matchedGeometryEffect(id: OIMSIDE, in: wrapper.namespace, isSource: true) 142 let chartButtons = HStack { 143 let a11yZoomOutStr = String(localized: "Zoom out", comment: "a11y for the zoom button") 144 ZoomOutButton(accessibilityLabelStr: a11yZoomOutStr) { 145 let width = dataPointWidth - 10 146 if dataPointWidth > DATAPOINTWIDTH_MIN { 147 withAnimation(.easeInOut(duration: 0.6)) { 148 dataPointWidth = width > DATAPOINTWIDTH_MIN ? width : DATAPOINTWIDTH_MIN 149 } 150 } 151 }//.buttonStyle(.borderedProminent) 152 // Text("scroll: \(scrollPosition.pTwo) width: \(dataPointWidth.pTwo) maxX: \(maxXValue.pTwo)") 153 // Text("width: \(dataPointWidth.pTwo)") 154 let a11yZoomInStr = String(localized: "Zoom in", comment: "a11y for the zoom button") 155 ZoomInButton(accessibilityLabelStr: a11yZoomInStr) { 156 let width = dataPointWidth + 15 157 if dataPointWidth < DATAPOINTWIDTH_MAX { 158 withAnimation(.easeInOut(duration: 0.6)) { 159 dataPointWidth = width < DATAPOINTWIDTH_MAX ? width : DATAPOINTWIDTH_MAX 160 } 161 } 162 }//.buttonStyle(.borderedProminent) 163 } 164 165 OIMbackground() { 166 VStack { 167 ZStack(alignment: .top) { 168 OIMtitleView(cash: cash, 169 amount: available, 170 history: true, //viewState == .historyShown, 171 secondAmount: nil) 172 173 if #unavailable(iOS 17.0) { 174 if oimChart { 175 chartButtons.padding(.top, 4) 176 // .border(.red) 177 } 178 } 179 } 180 Spacer(minLength: 0) 181 ZStack { 182 HStack { 183 Spacer(minLength: 0) 184 let count = shownItems.count 185 if count > 1 { 186 let last = shownItems.last 187 let maxIndex = last?.distance ?? 20 // should never happen 188 let scrollBack = count == computedItems.count 189 // let _ = print("calling HistoryView", count, maxIndex, scrollBack) 190 if oimChart { 191 #if TALER_WALLET 192 ChartHistoryView(stack: stack.push(), 193 currency: cash.currency, 194 shownItems: $shownItems, 195 dataPointWidth: $dataPointWidth, 196 scrollBack: scrollBack, 197 maxIndex: maxIndex, 198 maxValue: chartMaxY) 199 #else 200 ArrowHistoryView(stack: stack.push(), 201 currency: cash.currency, 202 shownItems: $shownItems, 203 dataPointWidth: $dataPointWidth, 204 scrollBack: scrollBack, 205 maxIndex: maxIndex, 206 maxValue: chartMaxY) 207 #endif 208 } else { 209 RiverHistoryView(stack: stack.push(), 210 currency: cash.currency, 211 shownItems: $shownItems, 212 dataPointWidth: $dataPointWidth, 213 scrollBack: scrollBack, 214 maxIndex: maxIndex, 215 maxValue: chartMaxY) 216 } 217 } 218 sidePosition 219 .padding(.leading, 20) 220 }// .border(.blue) 221 OIMlineView(stack: stack.push(), 222 cash: cash, 223 amountVal: $availableVal, 224 canEdit: false) 225 // .opacity(isOpen ? 1 : INVISIBLE) 226 // .scaleEffect(scaleMoney ? 0.6 : 1.0) 227 .onTapGesture { 228 closeHistory() 229 } 230 } 231 Spacer(minLength: 0) 232 } // title, HStack 233 } 234 .onAppear { 235 available = balance.available 236 availableVal = available?.centValue ?? 0 // TODO: centValue factor 237 cash.update2(availableVal) // set cash to available 238 cash.setTarget(.history) 239 debugTick += 1 240 if computedItems.count == 0 { 241 computeData(from: history, balance: balance.available.value) // set chartMaxY, computedData 242 } 243 showHistoryItems(oimChart ? Animation.talerDelay0 : 0.0) 244 } 245 .onDisappear { 246 // cash.moveBack() 247 viewState = .chestIsOpen 248 } 249 } 250 } 251 // MARK: - 252 @available(iOS 16.4, *) 253 extension OIMtransactions { 254 255 func computeData(from history: [TalerTransaction], balance: Double) { 256 /// Algorithm for the X value of the river of time 257 /// Endpoint (now) is 0 258 /// same day transactions have no space between them 259 /// day differs: add 1 spacer 260 /// week differs: add another spacer 261 /// month differs: add one more spacer 262 /// year differs: add yet another spacer 263 var historyItems: [HistoryItem] = [ HistoryItem(distance: 0, marker: .none, balance: balance, talerTX: nil) ] 264 var balance = balance 265 var maxBalance = balance 266 let calendar = Calendar.current // or do we need (identifier: .gregorian) ? 267 var lastTx = calendar.dateComponents([.year, .month, .day, .weekOfMonth], from: Date.now) 268 var xIndex = 0 269 270 func historyItem(for talerTransaction: TalerTransaction) -> HistoryItem? { 271 let common = talerTransaction.common 272 let amount = common.amountEffective 273 let incoming = common.isIncoming 274 let pending = common.isPending 275 let timestamp = common.timestamp 276 let date = try! Date(milliseconds: timestamp.milliseconds()) 277 let components = calendar.dateComponents([.year, .month, .day, .weekOfMonth], from: date) 278 // add spacers 279 var marker: HistoryMarker = .none 280 let isFirst = xIndex == 0 281 if lastTx.year != components.year { 282 xIndex += isFirst ? 1 : 4 283 marker = .year 284 } else if lastTx.month != components.month { 285 xIndex += isFirst ? 1 : 3 286 marker = .month 287 } else if lastTx.weekOfMonth != components.weekOfMonth { 288 xIndex += isFirst ? 1 : 2 289 marker = .week 290 // } else if lastItem.weekday != components.weekday { 291 // index += 2 292 } else if lastTx.day != components.day { 293 xIndex += 1 294 marker = .day 295 } 296 // advance to next index 297 xIndex += 1 298 // compute balance before that transaction 299 if incoming { 300 balance -= amount.value 301 } else { 302 balance += amount.value 303 } 304 lastTx = components 305 if balance > maxBalance { 306 maxBalance = balance 307 } 308 return HistoryItem(distance: xIndex, marker: marker, balance: balance, talerTX: talerTransaction) 309 } 310 311 for talerTransaction in history { 312 if let item = historyItem(for: talerTransaction) { 313 historyItems.append(item) 314 } 315 } 316 317 computedItems = historyItems 318 chartMaxY = maxBalance * yTransform(1.1) // 10 % plus, log2() 319 // print("computeData:", history.count, xIndex) 320 } 321 } 322 // MARK: - 323 324 325 extension Double { 326 var pTwo: String { 327 String(format: "%.2f", self) 328 } 329 } 330 extension CGFloat { 331 var pTwo: String { 332 String(format: "%.2f", self) 333 } 334 } 335 extension CGSize { 336 var pTwo: String { 337 String(format: "%.2f, %.2f", self.width, self.height) 338 } 339 }