OIMbalances.swift (12913B)
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 showSend = viewState == .chestIsOpen 181 #if DEBUG 182 let showRequest = viewState == .chestIsOpen 183 #else 184 let showRequest = false 185 #endif 186 let topButtons = HStack(alignment: .top) { 187 if selectedBalance == nil { 188 let showQR = viewState == .chestsClosed 189 QRButton(hideTitle: true) { 190 NotificationCenter.default.post(name: .QrScanAction, object: nil) // will trigger NavigationLink 191 } 192 .opacity(showQR ? 1 : INVISIBLE) 193 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 194 .matchedGeometryEffect(id: OIMBACK, in: wrapper.namespace, isSource: true) 195 } else { 196 197 } 198 Spacer() 199 VStack { 200 OIMactionButton(type: .sendP2P, isFinal: false, action: sendTapped) 201 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 202 .opacity(showSend ? 1 : INVISIBLE) 203 OIMactionButton(type: .requestP2P, isFinal: false, action: requestTapped) 204 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 205 .opacity(showRequest ? 1 : INVISIBLE) 206 } 207 } 208 209 let maxAvailable = cash.max(available: available?.centValue ?? 0) // TODO: centValue factor 210 // let _ = print("maxAvailable", maxAvailable) 211 212 let sidePosition = HStack { 213 Spacer() 214 Color.clear 215 .frame(width: 80, height: 80) 216 .matchedGeometryEffect(id: OIMSIDE, in: wrapper.namespace, isSource: true) 217 } 218 OIMbackground() { 219 ZStack(alignment: .top) { 220 topButtons 221 VStack { 222 // balance, amountToSend 223 OIMtitleView(cash: cash, 224 amount: available, 225 history: viewState == .historyShown, 226 secondAmount: nil) // appears in OIMEditView 227 Spacer() 228 let isOpen = selectedBalance != nil 229 ZStack { 230 sidePosition 231 // let scaleMoney = viewState == .chestIsOpen 232 OIMlineView(stack: stack.push(), 233 cash: cash, 234 amountVal: $availableVal, 235 canEdit: false) 236 .opacity(isOpen ? 1 : INVISIBLE) 237 // .scaleEffect(scaleMoney ? 0.6 : 1) 238 .onTapGesture { 239 openHistory() 240 } 241 } 242 Spacer() 243 } // title on top, money in the middle 244 245 VStack { 246 // multiple savings chests (Euro, Sierra Leone, Côte d'Ivoire) 247 Spacer() 248 HStack(spacing: 30) { 249 ForEachWithIndex(data: controller.balances) { index, balance in 250 let oimCurrency = oimCurrency(balance.scopeInfo, oimEuro: oimEuro) 251 let itsMe = selectedBalance == balance 252 let isClosed = selectedBalance == nil 253 let size = isClosed ? 160.0 : OIMbuttonSize 254 ZStack { 255 OIMbalanceButton(isOpen: itsMe, chest: oimCurrency.chest, isFinal: false) { 256 if itsMe { 257 closeChest() 258 } else { 259 openChest(oimCurrency, index, balance) 260 } 261 } 262 .frame(width: size, height: size) 263 .zIndex(itsMe ? 3 : 0) 264 .opacity((isClosed || itsMe) ? 1 : INVISIBLE) 265 .matchedGeometryEffect(id: itsMe ? OIMNUMBER // (sending ? OIMBACK : OIMNUMBER) 266 // : String(index), 267 : oimCurrency.currencyStr, 268 in: wrapper.namespace, isSource: false) 269 Color.clear 270 .frame(width: 40, height: 40) 271 // .matchedGeometryEffect(id: OIMCHEST + String(index), in: wrapper.namespace, isSource: true) 272 .matchedGeometryEffect(id: OIMCHEST + oimCurrency.currencyStr, in: wrapper.namespace, isSource: true) 273 } 274 } 275 } 276 Spacer() 277 } // three chests 278 279 VStack { 280 Spacer() 281 let showDrawer = viewState == .sending 282 OIMcurrencyDrawer(stack: stack.push(), 283 cash: cash, 284 availableVal: $availableVal, 285 tappedVal: $tappedVal, // unused, since canEdit == false 286 scrollPosition: maxAvailable, 287 canEdit: false) 288 .clipped(antialiased: true) 289 .padding(.horizontal, 5) 290 .ignoresSafeArea(edges: .horizontal) 291 .scrollDisabled(true) 292 .opacity(showDrawer ? 1 : INVISIBLE) 293 } // source for matching positions of money in the drawer 294 } 295 } 296 .onAppear { 297 if let selectedBalance { 298 print("🚩OIMbalances.onAppear() selectedBalance", selectedBalance.scopeInfo.currency) 299 available = selectedBalance.available 300 availableVal = available?.centValue ?? 0 // TODO: centValue factor 301 cash.update2(availableVal) // set cash to available 302 if viewState == .historyTapped { 303 withAnimation(.basic1) { 304 viewState = .chestIsOpen 305 } 306 } 307 } else { 308 print("🚩OIMbalances.onAppear() no selectedBalance") 309 initView() 310 } 311 debugTick += 1 312 } 313 .onDisappear { 314 if (selectedBalance != nil) { 315 cash.moveBack() 316 viewState = .chestIsOpen 317 } else { 318 initView() 319 } 320 } 321 } 322 }