OIMbalances.swift (12787B)
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 viewState = .chestClosing 90 let delay = cash.flyOneByOne(to: .curve) // back to chest... 91 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 92 print("closeChest", delay) 93 withAnimation(.basic1) { 94 print("🚩OIMbalances.closeChest() reset selectedBalance") 95 selectedBalance = nil 96 available = nil 97 viewState = .chestsClosed 98 } 99 closing = false 100 } 101 } 102 } 103 104 func openChest(_ oimCurrency: OIMcurrency, _ index: Int, _ balance: Balance) { 105 cash.clearFunds() 106 print("❗️openChest❗️") 107 let duration: TimeInterval 108 let initial: TimeInterval 109 #if DEBUG 110 duration = debugAnimations ? 2.5 : 111 fastAnimations ? 0.6 : 1.1 112 initial = debugAnimations ? 1.0 : 0.1 113 #else 114 duration = fastAnimations ? 0.6 : 1.1 115 initial = 0.1 116 #endif 117 viewState = .chestOpenTapped 118 withAnimation(.basic1) { 119 print("🚩OIMbalances.openChest() set selectedBalance to", balance.scopeInfo.currency) 120 selectedBalance = balance 121 balanceIndex = index 122 viewState = .chestIsOpen 123 cash.setCurrency(oimCurrency) 124 available = balance.available 125 availableVal = balance.available.centValue // TODO: centValue factor 126 cash.update2(availableVal, state: .chestOpening, duration, initial) // set cash to available 127 let maxAvailable = cash.max(available: availableVal) 128 print("OIMView.openChest availableVal", availableVal, maxAvailable) 129 } 130 } 131 132 // func closeHistory() { 133 // withAnimation(.basic1) { 134 // viewState = .historyTapped 135 // } 136 // let delay = cash.flyOneByOne(to: .idle) // back to center 137 // DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 138 // print("closeHistory", delay) 139 // withAnimation(.basic1) { 140 // viewState = .chestIsOpen 141 // } 142 // } 143 // } 144 145 func openHistory() { 146 viewState = .balanceTapped 147 let delay = cash.flyOneByOne(to: .history, true) 148 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 149 print("openHistory", delay) 150 withAnimation(.basic1) { 151 viewState = .historyShown 152 DispatchQueue.main.asyncAfter(deadline: .now() + Animation.talerDuration2) { 153 var transaction = Transaction() 154 transaction.disablesAnimations = true 155 withTransaction(transaction) { 156 historyTapped = balanceIndex // ==> go to transaction list 157 } 158 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 159 cash.moveBack() 160 viewState = .chestIsOpen 161 } 162 } 163 } 164 } 165 } 166 167 func initView() { 168 availableVal = 0 169 cash.update2(availableVal) // set cash to 0 170 viewState = .chestsClosed 171 } 172 173 var body: some View { 174 var debugTick = 0 175 // let _ = Self._printChanges() 176 177 let enabled = if let available { 178 !available.isZero 179 } else { false } 180 let topButtons = HStack(alignment: .top) { 181 if selectedBalance == nil { 182 let showQR = viewState == .chestsClosed 183 QRButton(hideTitle: true) { 184 NotificationCenter.default.post(name: .QrScanAction, object: nil) // will trigger NavigationLink 185 } 186 .opacity(showQR ? 1 : INVISIBLE) 187 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 188 .matchedGeometryEffect(id: OIMBACK, in: wrapper.namespace, isSource: true) 189 } else { 190 let showRequest = viewState == .chestIsOpen 191 OIMactionButton(type: .requestP2P, isFinal: false, action: requestTapped) 192 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 193 .opacity(showRequest ? 1 : INVISIBLE) 194 } 195 Spacer() 196 let showSend = viewState == .chestIsOpen 197 OIMactionButton(type: .sendP2P, isFinal: false, action: sendTapped) 198 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 199 .opacity(showSend ? 1 : INVISIBLE) 200 } 201 202 let maxAvailable = cash.max(available: available?.centValue ?? 0) // TODO: centValue factor 203 // let _ = print("maxAvailable", maxAvailable) 204 205 let sidePosition = HStack { 206 Spacer() 207 Color.clear 208 .frame(width: 80, height: 80) 209 .matchedGeometryEffect(id: OIMSIDE, in: wrapper.namespace, isSource: true) 210 } 211 OIMbackground() { 212 ZStack(alignment: .top) { 213 topButtons 214 VStack { 215 // balance, amountToSend 216 OIMtitleView(cash: cash, 217 amount: available, 218 history: viewState == .historyShown, 219 secondAmount: nil) // appears in OIMEditView 220 Spacer() 221 let isOpen = selectedBalance != nil 222 ZStack { 223 sidePosition 224 // let scaleMoney = viewState == .chestIsOpen 225 OIMlineView(stack: stack.push(), 226 cash: cash, 227 amountVal: $availableVal, 228 canEdit: false) 229 .opacity(isOpen ? 1 : INVISIBLE) 230 // .scaleEffect(scaleMoney ? 0.6 : 1) 231 .onTapGesture { 232 openHistory() 233 } 234 } 235 Spacer() 236 } // title on top, money in the middle 237 238 VStack { 239 // multiple savings chests (Euro, Sierra Leone, Côte d'Ivoire) 240 Spacer() 241 HStack(spacing: 30) { 242 ForEachWithIndex(data: controller.balances) { index, balance in 243 let oimCurrency = oimCurrency(balance.scopeInfo, oimEuro: oimEuro) 244 let itsMe = selectedBalance == balance 245 let isClosed = selectedBalance == nil 246 let size = isClosed ? 160.0 : OIMbuttonSize 247 ZStack { 248 OIMbalanceButton(isOpen: itsMe, chest: oimCurrency.chest, isFinal: false) { 249 if itsMe { 250 closeChest() 251 } else { 252 openChest(oimCurrency, index, balance) 253 } 254 } 255 .frame(width: size, height: size) 256 .zIndex(itsMe ? 3 : 0) 257 .opacity((isClosed || itsMe) ? 1 : INVISIBLE) 258 .matchedGeometryEffect(id: itsMe ? OIMNUMBER 259 // : String(index), 260 : oimCurrency.currencyStr, 261 in: wrapper.namespace, isSource: false) 262 Color.clear 263 .frame(width: 40, height: 40) 264 // .matchedGeometryEffect(id: OIMCHEST + String(index), in: wrapper.namespace, isSource: true) 265 .matchedGeometryEffect(id: OIMCHEST + oimCurrency.currencyStr, in: wrapper.namespace, isSource: true) 266 } 267 } 268 } 269 Spacer() 270 } // three chests 271 272 VStack { 273 Spacer() 274 let showDrawer = viewState == .sending 275 OIMcurrencyDrawer(stack: stack.push(), 276 cash: cash, 277 availableVal: $availableVal, 278 tappedVal: $tappedVal, // unused, since canEdit == false 279 scrollPosition: maxAvailable, 280 canEdit: false) 281 .clipped(antialiased: true) 282 .padding(.horizontal, 5) 283 .ignoresSafeArea(edges: .horizontal) 284 .scrollDisabled(true) 285 .opacity(showDrawer ? 1 : INVISIBLE) 286 } // source for matching positions of money in the drawer 287 } 288 } 289 .onAppear { 290 if let selectedBalance { 291 print("🚩OIMbalances.onAppear() selectedBalance", selectedBalance.scopeInfo.currency) 292 available = selectedBalance.available 293 availableVal = available?.centValue ?? 0 // TODO: centValue factor 294 cash.update2(availableVal) // set cash to available 295 if viewState == .historyTapped { 296 withAnimation(.basic1) { 297 viewState = .chestIsOpen 298 } 299 } 300 } else { 301 print("🚩OIMbalances.onAppear() no selectedBalance") 302 initView() 303 } 304 debugTick += 1 305 } 306 .onDisappear { 307 if (selectedBalance != nil) { 308 cash.moveBack() 309 viewState = .chestIsOpen 310 } else { 311 initView() 312 } 313 } 314 } 315 }