OIMbalances.swift (12856B)
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 qrButtonTapped: Bool 37 @Binding var historyTapped: Int? 38 let oimEuro: Bool 39 40 @EnvironmentObject private var controller: Controller 41 @EnvironmentObject private var wrapper: NamespaceWrapper 42 43 @StateObject private var cash: OIMcash 44 @State private var availableVal: UInt64 = 0 45 @State private var tappedVal: UInt64 = 0 // unused, canEdit == false 46 @State private var available: Amount? = nil 47 @State private var viewState: OIMbalancesState = .chestsClosed 48 @State private var closing = false // debounce tap (on open chest to close it) 49 @State private var balanceIndex: Int? = nil 50 51 init(stack: CallStack, 52 selectedBalance: Binding<Balance?>, 53 qrButtonTapped: Binding<Bool>, 54 historyTapped: Binding<Int?>, 55 oimEuro: Bool 56 ) { 57 self.stack = stack 58 self._selectedBalance = selectedBalance 59 self._qrButtonTapped = qrButtonTapped 60 self._historyTapped = historyTapped 61 self.oimEuro = oimEuro 62 let oimCurrency = oimCurrency(selectedBalance.wrappedValue?.scopeInfo, oimEuro: oimEuro) // might be nil ==> OIMeuros 63 let oimCash = OIMcash(oimCurrency) 64 self._cash = StateObject(wrappedValue: { oimCash }()) 65 } 66 67 func requestTapped() { 68 69 } 70 71 func sendTapped() { 72 withAnimation(.basicFast) { 73 viewState = .sendTapped 74 } 75 cash.flyOneByOne(to: .drawer) 76 withAnimation(.basic1.delay(0.6)) { 77 viewState = .sending // go to edit view, blend in missing denominations in drawer 78 } 79 DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { 80 let actionType = ActionType(animationDisabled: true) 81 let userinfo = [NOTIFICATIONANIMATION: actionType] 82 // will trigger NavigationLink 83 NotificationCenter.default.post(name: .SendAction, // switch to OIMEditView 84 object: nil, 85 userInfo: userinfo) 86 } 87 } 88 89 func closeChest() { 90 if !closing { 91 closing = true 92 viewState = .chestClosing 93 let delay = cash.flyOneByOne(to: .curve) // back to chest... 94 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 95 print("closeChest", delay) 96 withAnimation(.basic1) { 97 print("🚩OIMbalances.closeChest() reset selectedBalance") 98 selectedBalance = nil 99 available = nil 100 viewState = .chestsClosed 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 topButtons = HStack(alignment: .top) { 184 if selectedBalance == nil { 185 let showQR = viewState == .chestsClosed 186 QRButton(hideTitle: true) { 187 qrButtonTapped = true 188 } 189 .opacity(showQR ? 1 : INVISIBLE) 190 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 191 .matchedGeometryEffect(id: OIMBACK, in: wrapper.namespace, isSource: true) 192 } else { 193 let showRequest = viewState == .chestIsOpen 194 OIMactionButton(type: .requestP2P, isFinal: false, action: requestTapped) 195 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 196 .opacity(showRequest ? 1 : INVISIBLE) 197 } 198 Spacer() 199 let showSend = viewState == .chestIsOpen 200 OIMactionButton(type: .sendP2P, isFinal: false, action: sendTapped) 201 .frame(width: OIMbuttonSize, height: OIMbuttonSize) 202 .opacity(showSend ? 1 : INVISIBLE) 203 } 204 205 let maxAvailable = cash.max(available: available?.centValue ?? 0) // TODO: centValue factor 206 // let _ = print("maxAvailable", maxAvailable) 207 208 let sidePosition = HStack { 209 Spacer() 210 Color.clear 211 .frame(width: 80, height: 80) 212 .matchedGeometryEffect(id: OIMSIDE, in: wrapper.namespace, isSource: true) 213 } 214 OIMbackground() { 215 ZStack(alignment: .top) { 216 topButtons 217 VStack { 218 // balance, amountToSend 219 OIMtitleView(cash: cash, 220 amount: available, 221 history: viewState == .historyShown, 222 secondAmount: nil) // appears in OIMEditView 223 Spacer() 224 let isOpen = selectedBalance != nil 225 ZStack { 226 sidePosition 227 // let scaleMoney = viewState == .chestIsOpen 228 OIMlineView(stack: stack.push(), 229 cash: cash, 230 amountVal: $availableVal, 231 canEdit: false) 232 .opacity(isOpen ? 1 : INVISIBLE) 233 // .scaleEffect(scaleMoney ? 0.6 : 1) 234 .onTapGesture { 235 openHistory() 236 } 237 } 238 Spacer() 239 } // title on top, money in the middle 240 241 VStack { 242 // multiple savings chests (Euro, Sierra Leone, Côte d'Ivoire) 243 Spacer() 244 HStack(spacing: 30) { 245 ForEach(Array(controller.balances.enumerated()), id: \.element) { index, balance in 246 let oimCurrency = oimCurrency(balance.scopeInfo, oimEuro: oimEuro) 247 let itsMe = selectedBalance == balance 248 let isClosed = selectedBalance == nil 249 let size = isClosed ? 160.0 : OIMbuttonSize 250 ZStack { 251 OIMbalanceButton(isOpen: itsMe, chest: oimCurrency.chest, isFinal: false) { 252 if itsMe { 253 closeChest() 254 } else { 255 openChest(oimCurrency, index, balance) 256 } 257 } 258 .frame(width: size, height: size) 259 .zIndex(itsMe ? 3 : 0) 260 .opacity((isClosed || itsMe) ? 1 : INVISIBLE) 261 .matchedGeometryEffect(id: itsMe ? OIMNUMBER 262 // : String(index), 263 : oimCurrency.currencyStr, 264 in: wrapper.namespace, isSource: false) 265 Color.clear 266 .frame(width: 40, height: 40) 267 // .matchedGeometryEffect(id: OIMCHEST + String(index), in: wrapper.namespace, isSource: true) 268 .matchedGeometryEffect(id: OIMCHEST + oimCurrency.currencyStr, in: wrapper.namespace, isSource: true) 269 } 270 } 271 } 272 Spacer() 273 } // three chests 274 275 VStack { 276 Spacer() 277 let showDrawer = viewState == .sending 278 OIMcurrencyDrawer(stack: stack.push(), 279 cash: cash, 280 availableVal: $availableVal, 281 tappedVal: $tappedVal, // unused, since canEdit == false 282 scrollPosition: maxAvailable, 283 canEdit: false) 284 .clipped(antialiased: true) 285 .padding(.horizontal, 5) 286 .ignoresSafeArea(edges: .horizontal) 287 .scrollDisabled(true) 288 .opacity(showDrawer ? 1 : INVISIBLE) 289 } // source for matching positions of money in the drawer 290 } 291 } 292 .onAppear { 293 if let selectedBalance { 294 print("🚩OIMbalances.onAppear() selectedBalance", selectedBalance.scopeInfo.currency) 295 available = selectedBalance.available 296 availableVal = available?.centValue ?? 0 // TODO: centValue factor 297 cash.update2(availableVal) // set cash to available 298 if viewState == .historyTapped { 299 withAnimation(.basic1) { 300 viewState = .chestIsOpen 301 } 302 } 303 } else { 304 print("🚩OIMbalances.onAppear() no selectedBalance") 305 initView() 306 } 307 debugTick += 1 308 } 309 .onDisappear { 310 if (selectedBalance != nil) { 311 cash.moveBack() 312 viewState = .chestIsOpen 313 } else { 314 initView() 315 } 316 } 317 } 318 }