taler-ios

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

OIMlayout.swift (17403B)


      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 SymLog
     10 import os.log
     11 
     12 struct OIMid: LayoutValueKey {
     13     static let defaultValue = 0
     14 }
     15 struct OIMmorph: LayoutValueKey {
     16     static let defaultValue = 0
     17 }
     18 struct OIMvalue: LayoutValueKey {
     19     static let defaultValue: UInt64 = 0
     20 }
     21 struct OIMflippedval: LayoutValueKey {
     22     static let defaultValue: UInt64 = 0
     23 }
     24 struct OIMfundState: LayoutValueKey {
     25     static let defaultValue: FundState = .idle
     26 }
     27 
     28 @available(iOS 16.4, *)
     29 extension View {
     30     func oimID(_ value: Int) -> some View {
     31         self.layoutValue(key: OIMid.self, value: value)
     32     }
     33     func oimMorph(_ value: Int) -> some View {
     34         self.layoutValue(key: OIMmorph.self, value: value)
     35     }
     36     func oimValue(_ value: UInt64) -> some View {
     37         self.layoutValue(key: OIMvalue.self, value: value)
     38     }
     39     func oimFlippedVal(_ value: UInt64) -> some View {
     40         self.layoutValue(key: OIMflippedval.self, value: value)
     41     }
     42     func oimFundState(_ value: FundState) -> some View {
     43         self.layoutValue(key: OIMfundState.self, value: value)
     44     }
     45 }
     46 
     47 @available(iOS 16.4, *)
     48 extension LayoutSubview {
     49     var oimID: Int { self[OIMid.self] }
     50     var oimValue: UInt64 { self[OIMvalue.self] }
     51     var oimFlippedVal: UInt64 { self[OIMflippedval.self] }
     52     var oimFundState: FundState { self[OIMfundState.self] }
     53 }
     54 
     55 // MARK: -
     56 // renders a stack of funds (banknotes, coins)
     57 @available(iOS 16.4, *)
     58 struct OIMlayoutView: View {
     59     private let symLog = SymLogV(0)
     60 //    let logger = Logger(subsystem: "net.taler.gnu", category: "OIMlayoutView")
     61     let stack: CallStack
     62     let cash: OIMcash
     63     let funds: OIMfunds
     64     @Binding var amountVal: UInt64
     65     let canEdit: Bool
     66 
     67     @EnvironmentObject private var wrapper: NamespaceWrapper
     68     @State private var checkStacks: UInt64 = 0
     69 
     70     func endFlying(_ index: Int, after delay: Double) {
     71         var fund = funds[index]
     72         DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
     73             symLog.log("*** end flying:\(fund.value) \(fund.id)")
     74             withAnimation(.move1) {
     75                 fund.state = .idle              // move onto stack
     76                 cash.updateFund(fund)
     77             }
     78             if checkStacks > fund.value || checkStacks == 0 {
     79                 symLog.log("*** checkStacks:\(fund.value)")
     80                 checkStacks = fund.value
     81             }
     82         }
     83     }
     84 
     85     func startFlying(fundID: Int, fromChest: Bool = false) {
     86         if let index = funds.firstIndex(where: { $0.id == fundID }) {
     87             var fund = funds[index]
     88             symLog.log("*** start flying:\(fund.value) \(fundID)")
     89             if fromChest && fund.delay > 0 {
     90                 DispatchQueue.main.asyncAfter(deadline: .now() + fund.delay) {
     91                     withAnimation(.move1) {
     92                         fund.state = .curve
     93                         cash.updateFund(fund)
     94                     }
     95                     endFlying(index, after: flyDelay / 3)
     96                 }
     97             } else {
     98                 endFlying(index, after: flyDelay)
     99             }
    100             withAnimation(.move1) {
    101                 fund.state = fromChest ? .reveal : .isFlying               // switch off matching
    102                 cash.updateFund(fund)
    103             }
    104         }
    105     }
    106 
    107     func flyBack(fundID: Int) {
    108         if let index = funds.firstIndex(where: { $0.id == fundID }) {
    109             var fund = funds[index]
    110             symLog.log("*** fly back:\(fund.value) \(fundID)")
    111             withAnimation(.move1) {
    112                 fund.state = .removing
    113                 cash.updateFund(fund)
    114             }
    115             DispatchQueue.main.asyncAfter(deadline: .now() + flyDelay) {
    116                 symLog.log("*** remove:\(fund.value) \(fundID)")
    117                 withAnimation(.move1) {
    118                     cash.removeCash(id: fundID, value: fund.value)
    119                 }
    120             }
    121         }
    122     }
    123 
    124     var body: some View {
    125         OIMlayout {
    126             ForEach(funds) { fund in
    127                 let value = fund.value
    128                 let fundID = fund.id
    129                 let fundState = fund.state
    130                 let shouldFly = fundState.shouldFly
    131 //                let willMutate = fundState == .arriving || fundState == .mutating
    132                 OIMcurrencyButton(stack: stack.push(),
    133                                    fund: fund,
    134                                currency: cash.currency,
    135                            availableVal: value,
    136                                 canEdit: canEdit,
    137                                isDrawer: false,
    138                                     pct: shouldFly ? 0.0 : 1.0
    139                 ) {   // remove on button press
    140                     if amountVal >= fund.value {
    141                         amountVal -= fund.value
    142                     } else {
    143                         symLog.log("  ❗️Yikes - trying to subtract \(fund.value) from amount \(amountVal)")
    144                         amountVal = 0
    145                     }
    146                     flyBack(fundID: fundID)
    147                 }
    148                 .zIndex(Double(fundID))
    149                 .id(fundID)
    150                 .oimValue(value)
    151                 .oimFlippedVal(fund.flippedVal ?? 0)
    152                 .oimID(fundID)
    153                 .oimFundState(fundState)
    154                 .matchedGeometryEffect(id: fund.targetID, in: wrapper.namespace, isSource: false)
    155                 .onAppear {
    156                     if fundState.chestOpening {
    157                         startFlying(fundID: fundID, fromChest: true)
    158                     } else if fundState.shouldFly {
    159                         startFlying(fundID: fundID)
    160                     } else {
    161 //                      print("    ->OIMlayout ForEach fund.onAppear ignore \(value), \(fundID)")
    162                     }
    163                 }
    164             }
    165         }
    166         .onChange(of: checkStacks) { value in
    167             if value > 0 {
    168                 symLog.log("*** onChange(of: checkStacks) \(value)")
    169                 var firstCheck = value
    170                 if let moreThan4 = cash.checkStacks(first: firstCheck) {
    171                     firstCheck = 0
    172                     cash.compactStacks(moreThan4)
    173                 }
    174                 checkStacks = 0
    175             }
    176         }
    177     }
    178 }
    179 // MARK: -
    180 @available(iOS 16.4, *)
    181 struct OIMlayout: Layout {
    182 
    183     func sortByValue(_ subviews: LayoutSubviews) -> [LayoutSubview] {
    184         // sorts DESCENDING - we render the denominations top-down
    185         subviews.sorted(by: {
    186             let state0 = $0.oimFundState
    187             let state1 = $1.oimFundState
    188             let value0 = $0.oimValue
    189             let value1 = $1.oimValue
    190             let flipV0 = $0.oimFlippedVal
    191             let flipV1 = $1.oimFlippedVal
    192             let id0 = $0.oimID
    193             let id1 = $1.oimID
    194             let isSameVal = value0 == value1
    195             let id0isSmaller = id0 < id1
    196             let id0isBigger = id1 < id0
    197 
    198             let isMorphing1 = state1 == .position
    199                            || state1 == .morphing
    200                            || state1 == .arriving
    201                            || state1 == .mutating
    202             if isMorphing1 {
    203                 if flipV1 > 0 {
    204                     let firstIsBigger = value0 > flipV1
    205                     let sameFlip = value0 == flipV1
    206 
    207                     let sameFlip_ID0smaller = sameFlip && id0isSmaller
    208                     let result = firstIsBigger || sameFlip_ID0smaller
    209                     return result
    210                 }
    211             }
    212 
    213             let isMorphing0 = state0 == .position
    214                            || state0 == .morphing
    215                            || state0 == .arriving
    216                            || state0 == .mutating
    217             if isMorphing0 {
    218                 if flipV0 > 0 {
    219                     let firstIsBigger = flipV0 > value1
    220                     let sameFlip = value1 == flipV0
    221 
    222                     let sameFlip_ID0smaller = sameFlip && id0isSmaller
    223                     let result = firstIsBigger || sameFlip_ID0smaller
    224                     return result
    225                 }
    226             }
    227             let firstIsBigger = value0 > value1
    228             // but keep the order of the IDs for identical values
    229             let sameVal_ID0smaller = isSameVal && id0isSmaller
    230             let result = firstIsBigger || sameVal_ID0smaller
    231             return result
    232         })
    233     }
    234 
    235     func computeSpaces(_ sorted: [LayoutSubview], _ max: Int = MAXSTACK) -> [Int] {
    236         /// same algorithm as OIMcash.checkStacks
    237         /// returns array of stackIndexes
    238         /// 0  ==> next view starts a new stack
    239         /// 1..3  ==> next view is on the stack
    240         /// -1 ==> view is invisible, will be deleted
    241         var spaces: [Int] = []
    242 
    243         var stackIndex = 0
    244         var lastValue: UInt64 = 0
    245         for (index, subview) in sorted.enumerated() {
    246             let value = subview.oimValue
    247             let state = subview.oimFundState
    248             let id = subview.oimID
    249 //            let flippedVal = subview.oimFlippedVal ?? 0
    250             var ignore = 0
    251             if state.shouldFly || state.isFlying                // flying can not go on the stack
    252                                || state.position                // nor can the position where we morph...
    253                                || state.morphing  {             // ...or the morphing fund
    254                 // we always morph out from the end, thus there will be no more idling funds with the same value
    255                 lastValue = 0                                   // let the next subview...
    256                 stackIndex = 0                                  // ...start a new stack
    257             } else if state.moving || state.hiding {
    258                 ignore = -1
    259             } else if lastValue != value {                      // different value?
    260                 lastValue = value                               // save this value for the next subview
    261                 stackIndex = 0                                  // start a new stack
    262             } else {
    263                 stackIndex += 1                                 // Yay, we found one to add to this stack
    264                 if stackIndex == max {                          // max 4 subviews per stack
    265                     stackIndex = 0                              // stack is full, start a new one but keep the value
    266             }   }
    267             if index > 0 {
    268                 spaces.append(ignore == 0 ? stackIndex : ignore)
    269             }
    270         }
    271         return spaces
    272     }
    273 
    274     func accumulate(spaces: [Int], _ viewSizes: [CGSize], spacing: CGFloat, xOffset: CGFloat) -> CGFloat {
    275         var accumulatedSpaces: CGFloat = .zero
    276         for (idx, stackIndex) in spaces.enumerated() {
    277             if stackIndex > 0 {             // next view is on the stack, add xOffset, subtract width
    278                 accumulatedSpaces += (xOffset - viewSizes[idx].width)
    279             } else if stackIndex == 0 {     // start a new stack, add spacing
    280                 accumulatedSpaces += spacing
    281             } // else ignore view
    282         }
    283         return accumulatedSpaces
    284     }
    285 
    286     func spacing(for proposal: ProposedViewSize) -> (CGFloat, CGPoint) {
    287         switch proposal {
    288             case .infinity:                         // max size: just place all views in one row
    289                 (10, CGPoint(x: 10, y: 0))
    290             case .zero:                             // minimum size: tight spacing
    291                 (8, CGPoint(x: 8, y: 20))
    292             default:                                // TODO: compute offset - in the meantime => ideal size
    293 //            case .unspecified:                      // => ideal size
    294                 (10, CGPoint(x: 16, y: 16))
    295         }
    296     }
    297 
    298     func accumulate(views: [LayoutSubview], sizes: [CGSize]) -> CGFloat {
    299         var result = CGFloat.zero
    300         for (index, subview) in views.enumerated() {
    301             let state = subview.oimFundState
    302             if state != .moving && state != .hiding {
    303                 let size = sizes[index]
    304                 result += size.width
    305             }
    306         }
    307         return result
    308     }
    309 
    310     func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    311         guard subviews.count > 0 else { return CGSize.zero }
    312         let (spacing, offset) = spacing(for: proposal)
    313         let sorted = sortByValue(subviews)
    314         let viewSizes = sorted.map { $0.sizeThatFits(proposal) }        // <- THIS takes time...
    315         let maxHeight = viewSizes.reduce(0) { max($0, $1.height) }      // get the max height
    316         let viewWidths = accumulate(views: sorted, sizes: viewSizes)    // add up all widths
    317 
    318         let spaces = computeSpaces(sorted)
    319         let maxStackIndex = spaces.reduce(0) { max($0, $1) }
    320 
    321         var accumulatedSpaces: CGFloat = .zero
    322         var stackHeight: CGFloat = .zero
    323         switch proposal {
    324             case .infinity:         // max size: just place all views in one row
    325 //                print("MAX:", proposal)
    326                 accumulatedSpaces = spacing * CGFloat(sorted.count - 1)
    327                 break
    328             case .zero:             // minimum size:
    329 //                print("min:", proposal)
    330                 // all stacks with offset x = 8, y = 20
    331                 stackHeight = offset.y * CGFloat(maxStackIndex)
    332                 accumulatedSpaces = accumulate(spaces: spaces, viewSizes, spacing: spacing, xOffset: offset.x)
    333                 break
    334             case .unspecified:    // => ideal size
    335 //                print("Ideal:", proposal)
    336                 stackHeight = offset.y * CGFloat(maxStackIndex)
    337                 accumulatedSpaces = accumulate(spaces: spaces, viewSizes, spacing: spacing, xOffset: offset.x)
    338                 break
    339             default:
    340                 if var spaceToFill = proposal.width {
    341                     if var heightToFill = proposal.height {
    342             //            print("Yikes❗️ width + height:", spaceToFill, heightToFill)
    343 //                      // TODO: compute the inner-stack offsets
    344 //                      spaceToFill -= viewWidths       // might be negative
    345                     } else { // unspecified
    346 //                        print("Yikes❗️ width only:", spaceToFill)
    347                     }
    348                 } else if var heightToFill = proposal.height {
    349 //                    print("Yikes❗️ height only:", heightToFill)
    350                 } else { // unspecified
    351 //                    print("Yikes❗️ should NEVER happen:", proposal)
    352                 }
    353 
    354                 // while inner-stack offsets is not yet implemented, return ideal size
    355                 stackHeight = offset.y * CGFloat(maxStackIndex)
    356                 accumulatedSpaces = accumulate(spaces: spaces, viewSizes, spacing: spacing, xOffset: offset.x)
    357                 break
    358         }
    359         let result = CGSize(width: viewWidths + accumulatedSpaces,
    360                       height: maxHeight + stackHeight)
    361 //        print("   ***sizeThatFits:", result)
    362         return result
    363     }
    364 
    365     func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    366         let (spacing, offset) = spacing(for: proposal)
    367         let sorted = sortByValue(subviews)
    368         let spaces = computeSpaces(sorted)
    369         let isMax = proposal == .infinity
    370 
    371         var pt = CGPoint(x: bounds.minX, y: bounds.minY)
    372         var morphPt = CGPoint.zero
    373         for idx in sorted.indices {
    374             let subview = sorted[idx]
    375             let id = subview.oimID
    376             let state = subview.oimFundState
    377             if state.position || state.morphing {
    378 //                if morphPt != pt { print("set morphPt \(morphPt) to ク\(subview.oimValue),\(subview.oimID) at", pt) }
    379                 morphPt = pt
    380             }
    381             subview.place(at: pt, anchor: .topLeading, proposal: proposal)
    382             if state.moving || state.hiding  {
    383                 if morphPt != CGPoint.zero {
    384 //                    print("morphing ク\(subview.oimValue) \(subview.oimID) \(state) from \(pt) to", morphPt)
    385                     subview.place(at: morphPt, anchor: .topLeading, proposal: proposal)
    386                 } else {
    387 //                    symLog.log("Yikes: no morphing point for ク", subview.oimValue, subview.oimID, state)
    388                 }
    389 //            } else if state != .morphingIn && state != .morphedIn {
    390 //                print("placing ク\(subview.oimValue),\(subview.oimID) at", pt)
    391             }
    392 
    393             if idx < sorted.count - 1 {
    394                 let space = spaces[idx]
    395                 let nextView = sorted[idx+1]
    396                 let state = nextView.oimFundState
    397                 if state != .moving && state != .hiding {
    398                     if space == 0 || isMax {
    399 //                        print("Start new stack for ク", nextView.oimValue, nextView.oimID, state)
    400                         let width = subview.sizeThatFits(proposal).width
    401                         pt.x += width + spacing
    402                         pt.y = bounds.minY
    403                     } else if space > 0 {
    404 //                        print("  place on stack ク", nextView.oimValue, nextView.oimID, state)
    405                         pt.x += offset.x
    406                         pt.y += offset.y
    407 //                    }  else {
    408 //                        print("ignore ク", nextView.oimValue, nextView.oimID, state)
    409                     }
    410                 } // place subviews morphing out at position of view morphing in
    411             }
    412         }
    413     }
    414 }