OIMcash.swift (23235B)
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 import os.log 11 import SymLog 12 13 let MAXSTACK = 4 14 15 enum FundState: Int { 16 case idle 17 case removing // user tapped to remove 18 case drawer 19 20 case shouldFly // + button tapped 21 case isFlying // after onAppear 22 23 case chestOpening // chest tapped, money flies out 24 case reveal // move (invisible) together with the chest to the top 25 case curve // fly out of the chest in a curve to the topRight corner 26 case history // pile up for history 27 28 case position // position to move to 29 case moving // move to position 30 case hiding // hide at position 31 case morphing // flip 32 case arriving // keep flipped image, move to final position 33 case mutating // flip back with disbled transition 34 35 var removing: Bool { self == .removing } 36 var drawer: Bool { self == .drawer } 37 var shouldFly: Bool { self == .shouldFly } 38 var isFlying: Bool { self == .isFlying || self == .reveal || self == .curve } 39 var chestOpening: Bool { self == .chestOpening } 40 var reveal: Bool { self == .reveal } 41 var curve: Bool { self == .curve } 42 var history: Bool { self == .history } 43 var position: Bool { self == .position } 44 var morphing: Bool { self == .morphing } 45 var moving: Bool { self == .moving } 46 var hiding: Bool { self == .hiding } 47 var mutating: Bool { self == .mutating } 48 } 49 50 /// data structure for a cash item on the table 51 public struct OIMfund: Identifiable, Equatable, Hashable, Sendable { 52 public let id: Int // support multiple funds with the same value 53 var value: UInt64 // can be morphed 54 // var currencyIndex: Int 55 var currencyStr: String 56 var state: FundState 57 var delay: TimeInterval 58 var morphTicker: Int? // `semaphore´ to reserve a fund - don't morph this twice concurrently 59 var flippedVal: UInt64? // the value to morph into - layout.sortByValue will take this instead of value 60 var outValue: UInt64? // if this is set it determines the image (used for flipping) 61 var targetID: String { // match sourceID for SwiftUI animations 62 let isDrawer = state.shouldFly || state.drawer || state.removing 63 return state.chestOpening ? OIMCHEST + currencyStr // String(currencyIndex) 64 : state.reveal ? OIMNUMBER 65 : state.curve ? OIMACTION 66 : state.history ? OIMSIDE 67 : String(isDrawer ? -Int(value) // match currencyDrawer 68 : id) 69 } 70 var shouldFly2: Bool { state.shouldFly || state.chestOpening } 71 var isFlying2: Bool { state.isFlying } 72 73 init(id: Int, value: UInt64, currencyStr: String, // currencyIndex: Int, 74 state: FundState = .idle, delay: TimeInterval = 0, 75 morphTicker: Int? = nil, flippedVal: UInt64? = nil, outValue: UInt64? = nil 76 ) { 77 self.id = id 78 self.value = value 79 // self.currencyIndex = currencyIndex 80 self.currencyStr = currencyStr 81 self.state = state 82 self.delay = delay 83 self.morphTicker = morphTicker 84 self.flippedVal = flippedVal 85 self.outValue = outValue 86 } 87 } 88 public typealias OIMfunds = [OIMfund] 89 90 // MARK: - 91 final class OIMcash: ObservableObject, Sendable { 92 private let symLog = SymLogV(0) 93 private let logger = Logger(subsystem: "net.taler.gnu", category: "OIMcash") 94 private var ticker = 0 // increment each time a fund is added (or melted from others) 95 // let semaphore = AsyncSemaphore(value: 1) 96 var currency: OIMcurrency 97 // var currencyIndex: Int 98 @Published var funds: OIMfunds = [] 99 100 // init(_ index: Int? = 0) { 101 // self.currencyIndex = 0 102 // self.currency = OIMeuros 103 // setIndex(index) 104 // } 105 106 init(_ oimCurrency: OIMcurrency) { 107 self.currency = oimCurrency 108 } 109 110 func setCurrency(_ oimCurrency: OIMcurrency) { 111 self.currency = oimCurrency 112 } 113 114 // func setIndex(_ index: Int? = 0) { 115 // if let index { 116 // let count = OIMcurrencies.count 117 // if index < count { 118 // self.currencyIndex = index 119 // self.currency = OIMcurrencies[index] 120 // return 121 // } 122 // } 123 // self.currencyIndex = 0 124 // self.currency = OIMeuros 125 // } 126 127 func max(available: UInt64) -> UInt64 { 128 for note in currency.bankNotes { 129 if note <= available { 130 return note 131 } 132 } 133 for coin in currency.bankCoins { 134 if coin <= available { 135 return coin 136 } 137 } 138 return 0 139 } 140 141 func sortByValue() -> OIMfunds { 142 /// sorts ASCENDING but keeps the order of the IDs for identical values 143 funds.sorted(by: { 144 ($0.value < $1.value) || 145 (($0.value == $1.value) && ($0.id > $1.id)) 146 }) 147 } 148 149 func checkStacks(first firstCheck: UInt64, _ max: Int = MAXSTACK) -> UInt64? { 150 let sorted = sortByValue() 151 /// same algorithm as OIMlayout.computeSpaces 152 /// result is 0 if all stacks have 4 or less items 153 /// returns lowest fund.value with more than 4 items 154 155 var stackIndex = 0 156 var lastValue: UInt64 = 0 157 for fund in sorted { 158 let value = fund.value 159 let state = fund.state 160 if fund.shouldFly2 || fund.isFlying2 { // flying can not go on the stack 161 lastValue = 0 // let the next subview... 162 stackIndex = 0 // ...start a new stack 163 } else if lastValue != value { // different value? 164 lastValue = value // save this value for the next subview 165 stackIndex = 0 // start a new stack 166 } else { 167 stackIndex += 1 // Yay, we found one to add to this stack 168 if stackIndex == max { // stack is full 169 return value // <<= compact this stack 170 } } 171 } 172 return nil 173 } 174 //MARK: - 175 func morph(_ nr: UInt64, of outValue: UInt64, to inValue: UInt64) -> Bool { 176 let morphTicker = ticker // capture ticker at start of morphing 177 symLog.log(" Start morphing #\(morphTicker)") 178 let sorted = sortByValue() 179 var toDelete: OIMfunds = [] 180 181 var amount: UInt64 = 0 // ensure sum(morphedOut) is sum(morphedIn) 182 var counter = nr // counts outValues to be morphed 183 var added = 0 // counts inValues to be morphed 184 var first = true 185 for var fund in sorted { // remove newest funds first 186 if fund.value == outValue && fund.morphTicker == nil && counter > 0 { 187 if first { 188 logger.log(" ❗️added first \(fund.id) to morph for \(morphTicker)") 189 } else { 190 logger.log(" ❗️added \(fund.id) to morph for \(morphTicker)") 191 } 192 fund.morphTicker = first ? morphTicker : -morphTicker 193 first = false 194 updateFund(fund) 195 toDelete.append(fund) 196 counter -= 1 197 amount += outValue 198 } 199 } 200 guard counter == 0 else { 201 logger.warning(" ❗️Yikes: didn't find \(nr) funds to morph, missing \(counter)") 202 for var delFund in toDelete { 203 logger.log(" ❗️free \(delFund.id) from morphing \(morphTicker)") 204 delFund.morphTicker = nil 205 updateFund(delFund) 206 } 207 return true 208 } 209 210 while amount >= inValue { 211 amount -= inValue 212 added += 1 // TODO: If we added more than 1, then we must flip more than 1 213 } 214 if amount > 0 { 215 logger.warning(" ❗️Yikes: morph leftover:\(amount)") 216 } 217 218 guard toDelete.count > 1 else { return true } 219 /// Stage 1: the fund with the highest id just got added - that is the anchor position for the morph 220 var morphFund = toDelete.removeFirst() 221 added -= 1 222 logger.log(">>taking this anchor \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") 223 withAnimation(.basic1) { 224 morphFund.outValue = outValue // save old image 225 morphFund.flippedVal = inValue // layout.sortByValue will take this instead of value 226 morphFund.state = .position // to compute the position where the morph happens 227 updateFund(morphFund) 228 } 229 withAnimation(.easeInOutDelay2) { 230 symLog.log(">>moving outgoing to \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") 231 for var delFund in toDelete { // move the rest of the leaving funds 232 delFund.state = .moving // to the morphing position 233 updateFund(delFund) 234 } 235 } 236 237 /// Stage 2: After arriving at the position, without animation hide all outValues (except our anchor) 238 DispatchQueue.main.asyncAfter(deadline: .now() + flyDelay) { 239 for var delFund in toDelete { 240 self.symLog.log(">>hide \(delFund.id)") 241 delFund.state = .hiding // make invisible 242 self.updateFund(delFund) // TODO: check if stack remains!!! 243 } 244 245 /// Stage 3: flip animated 246 morphFund.state = .morphing 247 self.symLog.log(">>flip \(morphFund.id), \(morphFund.value) \(morphFund.state.rawValue)") 248 self.updateFund(morphFund) // withAnimation not needed, it animates itself when flipping 249 self.symLog.log(">>flipped \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") 250 251 /// Stage 4: move inValues to final position, delete outValues from funds array 252 DispatchQueue.main.asyncAfter(deadline: .now() + flyDelay) { 253 morphFund.state = .arriving 254 morphFund.value = inValue // stack position 255 morphFund.morphTicker = nil // release `semaphore´ 256 self.symLog.log(">>move to final position \(morphFund.id) \(morphFund.state.rawValue)") 257 withAnimation(.move1) { 258 self.updateFund(morphFund) 259 while added > 0 { 260 var fund = toDelete.removeFirst() 261 fund.value = inValue // transmute to inValue (without flip) 262 fund.morphTicker = nil // release `semaphore´ 263 fund.state = .idle // move to their positions 264 self.updateFund(fund) 265 added -= 1 266 } 267 // remaining outValues were already invisible, but we need to animate the stack they came from 268 for var delFund in toDelete { 269 self.symLog.log(">>remove \(delFund.id)") 270 self.removeCash(id: delFund.id, value: delFund.value) 271 } 272 } 273 274 /// Stage 5: transmute morphFund 275 DispatchQueue.main.asyncAfter(deadline: .now() + flyDelay) { 276 morphFund.state = .mutating 277 // morphFund.state = .idle 278 morphFund.outValue = nil 279 morphFund.flippedVal = nil 280 self.symLog.log(">>transmute \(morphFund.id) \(morphFund.value) \(morphFund.state.rawValue)") 281 self.updateFund(morphFund) 282 283 /// Stage 6: back to idle 284 DispatchQueue.main.async { 285 withAnimation(.move1) { 286 morphFund.state = .idle 287 self.updateFund(morphFund) 288 } 289 } 290 } 291 292 } 293 } // asyncAfter delay 294 return counter == 0 295 } 296 297 func compactStacks(_ value: UInt64) -> Bool { 298 symLog.log(" Start compactStacks \(value)") 299 let denominations = currency.bankNotes + currency.bankCoins 300 if let index = denominations.firstIndex(where: { $0 == value }) { 301 if index > 0 { // does a bigger denomination exist? 302 let value5x = value * 5 303 let nextIndex = index-1 304 let nextValue = denominations[nextIndex] // let next = next_bigger_denomination 305 if nextValue == value5x { 306 return morph(5, of: value, to: nextValue) 307 } 308 // since we want to "convert" adjacent denominations (some smaller will become 1 larger fund), 309 // we cannot use the whole 5 smaller funds (4 in stack, 1 just added) if next is not 5 times bigger 310 // check whether there are any items of next already on the table 311 let hasNextIdx = funds.firstIndex(where: { $0.value == nextValue }) 312 if hasNextIdx == nil { // no, then... 313 if index > 1 { // check the second bigger denomination 314 let secondIdx = index-2 315 let secondValue = denominations[secondIdx] 316 if secondValue <= value5x && secondValue.isMultiple(of: value) { 317 let nrToDelete = secondValue / value 318 return morph(nrToDelete, of: value, to: secondValue) 319 } 320 } 321 } 322 // If we arrive here, either there is no second denomination any more, or it is not a multiple of value, 323 // or there are already some funds of next on the table 324 if nextValue.isMultiple(of: value) { 325 let nrToDelete = nextValue / value 326 return morph(nrToDelete, of: value, to: nextValue) 327 } 328 // Now this is tricky - there are already some funds of next on the table, but next is not a multiple of value 329 // But maybe 2 of next are a multiple - e.g. value=10, next=25 330 let nextValue2x = nextValue * 2 331 if nextValue2x.isMultiple(of: value) { 332 let nrToDelete = nextValue * 2 / value 333 if nrToDelete <= 5 { 334 return morph(nrToDelete, of: value, to: nextValue) // 5*10 = 2*25 335 } 336 } 337 } 338 // It seems we cannot merge with adjacent funds :-( 339 // Or there is no "next", the user already has the highest denomination 340 } 341 return false 342 } 343 344 func notes() -> OIMfunds { 345 let firstCoinVal = currency.bankCoins[0] 346 return funds.filter { $0.value > firstCoinVal } 347 } 348 349 func coins() -> OIMfunds { 350 let firstCoinVal = currency.bankCoins[0] 351 return funds.filter { $0.value <= firstCoinVal } 352 } 353 354 @discardableResult 355 func addCash(value: UInt64, _ newState: FundState = .shouldFly, _ flippedVal: UInt64? = nil) -> OIMfund { 356 let myTicker = ticker; ticker += 1 // TODO: atomic increment! 357 let fund = OIMfund(id: myTicker, 358 value: value, 359 currencyStr: currency.currencyStr, // currencyIndex: currencyIndex, 360 state: newState, 361 flippedVal: flippedVal) 362 symLog.log(">>adding \(value) \(fund.id) \(newState)") 363 funds.append(fund) 364 return fund 365 } 366 367 func removeCash(id: Int, value: UInt64) { 368 if let index = funds.firstIndex(where: { $0.id == id && $0.value == value }) { 369 funds.remove(at: index) 370 } else if let index = funds.lastIndex(where: { $0.value == value && $0.state.removing }) { 371 funds.remove(at: index) 372 } else if let index = funds.firstIndex(where: { $0.value == value }) { 373 funds.remove(at: index) 374 } 375 } 376 377 func updateFund(_ item: OIMfund) { 378 let itemID = item.id 379 if let index = funds.firstIndex(where: { $0.id == itemID }) { 380 funds[index] = item 381 } else { 382 funds = funds.map { 383 $0.id == itemID ? item : $0 384 } 385 } 386 } 387 388 @discardableResult 389 func moveDown() -> Double { 390 let sorted = sortByValue() 391 392 var counter = 0.0 393 var lastVal: UInt64 = 0 394 for var fund in sorted { 395 // withAnimation(.easeOut1.delay(counter * 0.1)) { 396 withAnimation(.easeOut1) { 397 fund.state = .drawer 398 self.updateFund(fund) 399 } 400 if lastVal != fund.value { 401 lastVal = fund.value 402 counter += 1 403 } 404 } 405 return counter * 0.1 406 } 407 408 func moveBack() { 409 for var fund in funds { 410 // if fund.state == .drawer { 411 fund.state = .idle 412 self.updateFund(fund) 413 // } 414 } 415 } 416 417 func clearFunds() { 418 funds = [] 419 } 420 421 func countDosh(_ dosh: OIMdenominations) -> UInt64 { 422 dosh.reduce(0) { x, y in 423 x + y 424 } 425 } 426 427 func flyToTarget(target: FundState, index: Int, after delay: TimeInterval) { 428 let interval = fastAnimations ? 0.2 : 0.4 429 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 430 var fund = self.funds[index] 431 withAnimation(.move1) { 432 fund.state = target 433 self.updateFund(fund) 434 } 435 // print("flyToTarget", target, fund.id, fund.value, delay) 436 if target == .curve { 437 DispatchQueue.main.asyncAfter(deadline: .now() + interval) { 438 withAnimation(.move1) { 439 fund.state = .reveal 440 self.updateFund(fund) 441 } 442 DispatchQueue.main.asyncAfter(deadline: .now() + interval) { 443 withAnimation(.move1) { 444 do { 445 self.funds.remove(at: index) 446 } catch {} 447 } 448 } 449 } 450 } // curve 451 } 452 } 453 454 func flyOneByOne(to target: FundState, 455 _ increasing: Bool = false, 456 _ duration: TimeInterval? = nil 457 ) -> TimeInterval { 458 var count = funds.count 459 var initial: TimeInterval = 0.01 460 let interval = interval(count: count, 461 duration: duration ?? (fastAnimations ? 0.6 : 1.1), 462 initial: initial) 463 var index = 0 464 while count > 0 { // for each fund, small to high 465 count -= 1 466 flyToTarget(target: target, 467 index: increasing ? index : count, 468 after: initial) 469 index += 1 470 initial += interval 471 } 472 return initial - interval + (fastAnimations ? 0.1 : 0.2) 473 } 474 475 func setTarget(_ target: FundState, 476 _ increasing: Bool = false) { 477 var count = funds.count 478 var index = 0 479 while count > 0 { // for each fund, small to high 480 count -= 1 481 var fund = self.funds[increasing ? index : count] 482 fund.state = target 483 self.updateFund(fund) 484 index += 1 485 } 486 } 487 488 func interval(count: Int, 489 duration: TimeInterval, 490 initial: TimeInterval = 0 491 ) -> TimeInterval { 492 (duration <= initial) || (count == 0) ? 0 493 : (duration - initial) / Double(count) 494 } 495 496 func update2(_ intVal: UInt64, 497 state: FundState = .idle, 498 _ duration: TimeInterval = 0, 499 _ initial: TimeInterval = 0) { 500 // optimize/rebuild funds 501 let dosh = currency.notesCoins(intVal) 502 let count = Int(countDosh(dosh.0) + countDosh(dosh.1)) 503 let interval = interval(count: count, duration: duration, initial: initial) 504 let delay = update1(dosh.0, denominations: currency.bankNotes, state: state, interval, initial) 505 update1(dosh.1, denominations: currency.bankCoins, state: state, interval, delay) 506 } 507 508 @discardableResult 509 func update1(_ notesCoins: OIMdenominations, 510 denominations: OIMdenominations, 511 state: FundState = .idle, 512 _ interval: TimeInterval = 0, 513 _ initial: TimeInterval = 0) -> TimeInterval { 514 var array = funds 515 var changed = false 516 var accumulatedDelay = initial 517 for (index, value) in denominations.enumerated() { 518 let wanted = notesCoins[index] // number of notes which should be shown 519 var shownNotes = array.filter { $0.value == value } 520 var count = shownNotes.count 521 while count > wanted { 522 let note = shownNotes[0] 523 symLog.log("update: remove \(note.value), \(note.id)") 524 if let index = array.firstIndex(of: note) { 525 array.remove(at: index) 526 changed = true 527 } 528 shownNotes.remove(at: 0) 529 count -= 1 530 } 531 while count < wanted { 532 // if interval == 0 { // add all at once 533 let fund = OIMfund(id: ticker, 534 value: value, 535 currencyStr: currency.currencyStr, // currencyIndex: currencyIndex, 536 state: state, 537 delay: accumulatedDelay) 538 ticker += 1 539 symLog.log("update: add \(fund.value), \(fund.id)") 540 array.append(fund) 541 changed = true 542 // } else { // add each delayed 543 // DispatchQueue.main.asyncAfter(deadline: .now() + accumulatedDelay) { 544 // withAnimation(.move1) { 545 // self.addCash(value: value, state) 546 // return 547 // } 548 // } 549 accumulatedDelay += interval 550 // } 551 count += 1 552 } 553 } 554 if changed { 555 funds = array 556 } 557 return accumulatedDelay 558 } 559 }