taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }