OIMbalances.swift (13324B)
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 enum OIMbalancesState { 12 case chestsClosed 13 case chestClosing 14 case chestOpenTapped 15 case chestIsOpen 16 17 case sendTapped 18 case sending 19 20 case requestTapped 21 case requesting 22 23 case balanceTapped 24 case historyShown 25 case historyTapped 26 } 27 28 // MARK: - 29 // called by BalancesListView 30 // shows one savings box per currency 31 @available(iOS 16.4, *) 32 struct OIMbalances: View { 33 let stack: CallStack 34 // let decimal: Int // 0 for ¥,HUF; 2 for $,€,£; 3 for ﷼,₯ (arabic) 35 @Binding var selectedBalance: Balance? // return user's choice 36 @Binding var historyTapped: Int? 37 let oimEuro: Bool 38 39 @EnvironmentObject private var controller: Controller 40 @EnvironmentObject private var wrapper: NamespaceWrapper 41 42 @StateObject private var cash: OIMcash 43 @State private var availableVal: UInt64 = 0 44 @State private var tappedVal: UInt64 = 0 // unused, canEdit == false 45 @State private var available: Amount? = nil 46 @State private var viewState: OIMbalancesState = .chestsClosed 47 @State private var closing = false // debounce tap (on open chest to close it) 48 @State private var balanceIndex: Int? = nil 49 50 init(stack: CallStack, 51 selectedBalance: Binding<Balance?>, 52 historyTapped: Binding<Int?>, 53 oimEuro: Bool 54 ) { 55 self.stack = stack 56 self._selectedBalance = selectedBalance 57 self._historyTapped = historyTapped 58 self.oimEuro = oimEuro 59 let oimCurrency = oimCurrency(selectedBalance.wrappedValue?.scopeInfo, oimEuro: oimEuro) // might be nil ==> OIMeuros 60 let oimCash = OIMcash(oimCurrency) 61 self._cash = StateObject(wrappedValue: { oimCash }()) 62 } 63 64 func requestTapped() { 65 66 } 67 68 func sendTapped() { 69 withAnimation(.basicFast) { 70 viewState = .sendTapped 71 } 72 cash.flyOneByOne(to: .drawer) 73 withAnimation(.basic1.delay(0.6)) { 74 viewState = .sending // go to edit view, blend in missing denominations in drawer 75 } 76 DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { 77 let actionType = ActionType(animationDisabled: true) 78 let userinfo = [NOTIFICATIONANIMATION: actionType] 79 // will trigger NavigationLink 80 NotificationCenter.default.post(name: .SendAction, // switch to OIMEditView 81 object: nil, 82 userInfo: userinfo) 83 } 84 } 85 86 func closeChest() { 87 if !closing { 88 closing = true 89 let chestBeingClosed = selectedBalance // so a stale timer can't stomp on a chest opened later 90 viewState = .chestClosing 91 let delay = cash.flyOneByOne(to: .curve) // back to chest... 92 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 93 print("closeChest", delay) 94 if selectedBalance == chestBeingClosed { // still the same chest? otherwise a newer 95 withAnimation(.basic1) { // openChest() already replaced it - leave it alone 96 print("🚩OIMbalances.closeChest() reset selectedBalance") 97 selectedBalance = nil 98 available = nil 99 viewState = .chestsClosed 100 } 101 } 102 closing = false 103 } 104 } 105 } 106 107 func openChest(_ oimCurrency: OIMcurrency, _ index: Int, _ balance: Balance) { 108 cash.clearFunds() 109 print("❗️openChest❗️") 110 let duration: TimeInterval 111 let initial: TimeInterval 112 #if DEBUG 113 duration = debugAnimations ? 2.5 : 114 fastAnimations ? 0.6 : 1.1 115 initial = debugAnimations ? 1.0 : 0.1 116 #else 117 duration = fastAnimations ? 0.6 : 1.1 118 initial = 0.1 119 #endif 120 viewState = .chestOpenTapped 121 withAnimation(.basic1) { 122 print("🚩OIMbalances.openChest() set selectedBalance to", balance.scopeInfo.currency) 123 selectedBalance = balance 124 balanceIndex = index 125 viewState = .chestIsOpen 126 cash.setCurrency(oimCurrency) 127 available = balance.available 128 availableVal = balance.available.centValue // TODO: centValue factor 129 cash.update2(availableVal, state: .chestOpening, duration, initial) // set cash to available 130 let maxAvailable = cash.max(available: availableVal) 131 print("OIMView.openChest availableVal", availableVal, maxAvailable) 132 } 133 } 134 135 // func closeHistory() { 136 // withAnimation(.basic1) { 137 // viewState = .historyTapped 138 // } 139 // let delay = cash.flyOneByOne(to: .idle) // back to center 140 // DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 141 // print("closeHistory", delay) 142 // withAnimation(.basic1) { 143 // viewState = .chestIsOpen 144 // } 145 // } 146 // } 147 148 func openHistory() { 149 viewState = .balanceTapped 150 let delay = cash.flyOneByOne(to: .history, true) 151 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 152 print("openHistory", delay) 153 withAnimation(.basic1) { 154 viewState = .historyShown 155 DispatchQueue.main.asyncAfter(deadline: .now() + Animation.talerDuration2) { 156 var transaction = Transaction() 157 transaction.disablesAnimations = true 158 withTransaction(transaction) { 159 historyTapped = balanceIndex // ==> go to transaction list 160 } 161 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 162 cash.moveBack() 163 viewState = .chestIsOpen 164 } 165 } 166 } 167 } 168 } 169 170 func initView() { 171 availableVal = 0 172 cash.update2(availableVal) // set cash to 0 173 viewState = .chestsClosed 174 } 175 176 var body: some View { 177 var debugTick = 0 178 // let _ = Self._printChanges() 179 180 let enabled = if let available { 181 !available.isZero 182 } else { false } 183 let showSend = viewState == .chestIsOpen 184 #if DEBUG 185 let showRequest = viewState == .chestIsOpen 186 #else 187 let showRequest = false 188 #endif 189 let topButtons = HStack(alignment: .top) { 190 if selectedBalance == nil { 191 let showQR = viewState == .chestsClosed 192 QRButton(hideTitle: true) { 193 NotificationCenter.default.post(name: .QrScanAction, object: nil) // will trigger NavigationLink 194 } 195 .opacity(showQR ? 1 : INVISIBLE) 196 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 197 .matchedGeometryEffect(id: OIMBACK, in: wrapper.namespace, isSource: true) 198 } else { 199 200 } 201 Spacer() 202 VStack { 203 OIMactionButton(type: .sendP2P, isFinal: false, action: sendTapped) 204 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 205 .opacity(showSend ? 1 : INVISIBLE) 206 OIMactionButton(type: .requestP2P, isFinal: false, action: requestTapped) 207 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 208 .opacity(showRequest ? 1 : INVISIBLE) 209 } 210 } 211 212 let maxAvailable = cash.max(available: available?.centValue ?? 0) // TODO: centValue factor 213 // let _ = print("maxAvailable", maxAvailable) 214 215 let sidePosition = HStack { 216 Spacer() 217 Color.clear 218 .frame(width: 80, height: 80) 219 .matchedGeometryEffect(id: OIMSIDE, in: wrapper.namespace, isSource: true) 220 } 221 OIMbackground() { 222 ZStack(alignment: .top) { 223 topButtons 224 VStack { 225 // balance, amountToSend 226 OIMtitleView(cash: cash, 227 amount: available, 228 history: viewState == .historyShown, 229 secondAmount: nil) // appears in OIMEditView 230 Spacer() 231 let isOpen = selectedBalance != nil 232 ZStack { 233 sidePosition 234 // let scaleMoney = viewState == .chestIsOpen 235 OIMlineView(stack: stack.push(), 236 cash: cash, 237 amountVal: $availableVal, 238 canEdit: false) 239 .opacity(isOpen ? 1 : INVISIBLE) 240 // .scaleEffect(scaleMoney ? 0.6 : 1) 241 .onTapGesture { 242 openHistory() 243 } 244 } 245 Spacer() 246 } // title on top, money in the middle 247 248 VStack { 249 // multiple savings chests (Euro, Sierra Leone, Côte d'Ivoire) 250 Spacer() 251 HStack(spacing: 30) { 252 ForEachWithIndex(array: controller.balances) { index, balance in 253 let oimCurrency = oimCurrency(balance.scopeInfo, oimEuro: oimEuro) 254 let itsMe = selectedBalance == balance 255 let isClosed = selectedBalance == nil 256 let size = isClosed ? 160.0 : OIMbuttonSize 257 ZStack { 258 OIMbalanceButton(isOpen: itsMe, chest: oimCurrency.chest, isFinal: false) { 259 if itsMe { 260 closeChest() 261 } else { 262 openChest(oimCurrency, index, balance) 263 } 264 } 265 .frame(width: size, height: size) 266 .zIndex(itsMe ? 3 : 0) 267 .opacity((isClosed || itsMe) ? 1 : INVISIBLE) 268 .disabled(!(isClosed || itsMe)) // disable invisible 269 .matchedGeometryEffect(id: itsMe ? OIMNUMBER // (sending ? OIMBACK : OIMNUMBER) 270 // : String(index), 271 : oimCurrency.currencyStr, 272 in: wrapper.namespace, isSource: false) 273 Color.clear 274 .frame(width: 40, height: 40) 275 // .matchedGeometryEffect(id: OIMCHEST + String(index), in: wrapper.namespace, isSource: true) 276 .matchedGeometryEffect(id: OIMCHEST + oimCurrency.currencyStr, in: wrapper.namespace, isSource: true) 277 } 278 } 279 } 280 Spacer() 281 } // three chests 282 283 VStack { 284 Spacer() 285 let showDrawer = viewState == .sending 286 OIMcurrencyDrawer(stack: stack.push(), 287 cash: cash, 288 availableVal: $availableVal, 289 tappedVal: $tappedVal, // unused, since canEdit == false 290 scrollPosition: maxAvailable, 291 canEdit: false) 292 .clipped(antialiased: true) 293 .padding(.horizontal, 5) 294 .ignoresSafeArea(edges: .horizontal) 295 .scrollDisabled(true) 296 .opacity(showDrawer ? 1 : INVISIBLE) 297 } // source for matching positions of money in the drawer 298 } 299 } 300 .onAppear { 301 if let selectedBalance { 302 print("🚩OIMbalances.onAppear() selectedBalance", selectedBalance.scopeInfo.currency) 303 available = selectedBalance.available 304 availableVal = available?.centValue ?? 0 // TODO: centValue factor 305 cash.update2(availableVal) // set cash to available 306 if viewState == .historyTapped { 307 withAnimation(.basic1) { 308 viewState = .chestIsOpen 309 } 310 } 311 } else { 312 print("🚩OIMbalances.onAppear() no selectedBalance") 313 initView() 314 } 315 debugTick += 1 316 } 317 .onDisappear { 318 if (selectedBalance != nil) { 319 cash.moveBack() 320 viewState = .chestIsOpen 321 } else { 322 initView() 323 } 324 } 325 } 326 }