taler-ios

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

DiscountPasses.swift (9986B)


      1 /*
      2  * This file is part of GNU Taler, ©2022-26 Taler Systems S.A.
      3  * See LICENSE.md
      4  */
      5 /**
      6  * @author Marc Stibane
      7  */
      8 import SwiftUI
      9 import os.log
     10 import taler_swift
     11 import SymLog
     12 
     13 /// This view shows ...
     14 struct DiscountsPassesItem: View {
     15     private let symLog = SymLogV(0)
     16     let stack: CallStack
     17     let token: TalerToken
     18     let isPass: Bool                        // false = isDiscount
     19     let activeExpired: Int
     20     let index: Int
     21     let deleteAction: (String) -> Void
     22 
     23     @Environment(\.colorScheme) private var colorScheme
     24     @Environment(\.colorSchemeContrast) private var colorSchemeContrast
     25     @AppStorage("minimalistic") var minimalistic: Bool = false
     26 
     27     @State private var dateToggled: Bool = false
     28 
     29     func validDate(_ isValid: Bool, isExpired: Bool) -> (String, String) {
     30         if isExpired {
     31             let (endDateString, endDate) = TalerDater.dateString(token.validityEnd, minimalistic)
     32             let endA11yDate = TalerDater.accessibilityDate(endDate) ?? endDateString
     33 
     34             let (dateString, date) = TalerDater.dateString(token.validityStart, minimalistic)
     35             let a11yDate = TalerDater.accessibilityDate(date) ?? dateString
     36 
     37             let result0 = String(localized: "Validity period: \(dateString) - \(endDateString)")
     38             let result1 = String(localized: "Validity period: \(a11yDate) to \(endA11yDate)")
     39             return (result0, result1)
     40         } else if isValid != dateToggled {
     41             let (dateString, date) = TalerDater.dateString(token.validityEnd, minimalistic)
     42             let a11yDate = TalerDater.accessibilityDate(date) ?? dateString
     43             let result0 = String(localized: "Valid until \(dateString)")
     44             let result1 = String(localized: "Valid until \(a11yDate)")
     45             return (result0, result1)
     46         } else {
     47             let (dateString, date) = TalerDater.dateString(token.validityStart, minimalistic)
     48             let a11yDate = TalerDater.accessibilityDate(date) ?? dateString
     49             let result0 = String(localized: "Valid from \(dateString)")
     50             let result1 = String(localized: "Valid from \(a11yDate)")
     51             return (result0, result1)
     52         }
     53     }
     54     var body: some View {
     55         let isValid = token.validityStart < .now()
     56         let isExpired = token.validityEnd < .now()
     57         let primary = WalletColors().primary(colorSchemeContrast)
     58         let secondary = WalletColors().secondary(colorScheme, colorSchemeContrast)
     59         let talerColor = WalletColors().talerColor
     60 
     61         let name = Text(token.name)
     62             .talerFont(.title)
     63             .foregroundColor(primary)
     64         let desc = Text(token.description)
     65             .talerFont(.body)
     66             .foregroundColor(primary)
     67 
     68         Section {
     69             let swipe = Button {
     70                     symLog.log("delete \(token.id)")
     71                     deleteAction(token.id)
     72                 } label: {
     73                     Label("Delete", systemImage: "trash")
     74                 }.tint(WalletColors().negative)
     75             let row = HStack(alignment: .top) {
     76                 name
     77                 Spacer()
     78                 if let available = token.tokensAvailable {
     79                     // Discount
     80                     Text("x\(available)")
     81                         .foregroundColor(talerColor)
     82                 } else {
     83                     // Pass
     84                     if isValid {
     85                         Text("active")
     86                             .foregroundColor(talerColor)
     87                     }
     88                 }
     89             }
     90             if isValid {
     91                 row
     92             } else {
     93                 row.swipeActions(edge: .trailing) { swipe }
     94             }
     95 
     96             let merchant = token.merchantInfo?.name ?? "Unknown"
     97             if isExpired {
     98                 Text(isPass ? "Provided by: \(merchant)"
     99                   : isValid ? "Was redeemable at: \(merchant)"
    100                             : "Redeemed at: \(merchant)")
    101                     .talerFont(.footnote)
    102                     .foregroundColor(secondary)
    103 
    104             } else {
    105                 Text(isPass ? "Provided by: \(merchant)"
    106                             : "Redeemable at: \(merchant)")
    107                     .talerFont(.footnote)
    108                     .foregroundColor(secondary)
    109             }
    110             let dateStrings = validDate(isValid, isExpired: isExpired)
    111             VStack {
    112                 Text(dateStrings.0)
    113                     .accessibilityLabel(dateStrings.1)
    114                     .talerFont(.body)
    115             }.frame(maxWidth: .infinity)
    116                 .background(talerColor.opacity(isValid ? 0.1 : 0))
    117                 .onTapGesture {
    118                     if !isExpired {
    119                         dateToggled.toggle()
    120                     }
    121                 }
    122         }
    123     }
    124 }
    125 // MARK: -
    126 struct DiscountsPassesList: View {
    127     private let symLog = SymLogV(0)
    128     let stack: CallStack
    129     let isPass: Bool
    130     let navTitle: String
    131 
    132     @EnvironmentObject private var model: WalletModel
    133     @EnvironmentObject private var controller: Controller
    134     @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
    135     @State private var activeExpired: Int = 0
    136 
    137     func delete(_ tokenID: String) async {
    138         if isPass {
    139             try? await model.deleteSubscription(tokenID)
    140         } else {
    141             try? await model.deleteDiscount(tokenID)
    142         }
    143     }
    144     func refresh() async {
    145 //        symLog.log("refreshing balances")
    146         if isPass {
    147             await controller.loadSubscriptions(stack.push("refreshing passes"), model)
    148         } else {
    149             await controller.loadDiscounts(stack.push("refreshing discounts"), model)
    150         }
    151     }
    152 
    153     func talerTokens() -> [TalerToken] {
    154         let tokens = isPass ? controller.subscriptions
    155                             : controller.discounts
    156         let active = activeExpired == 0
    157 
    158         return tokens.filter {
    159             let isValid = $0.validityEnd > .now()
    160             return active ? isValid
    161                           : !isValid
    162         }
    163     }
    164     var body: some View {
    165         let tokens = talerTokens()
    166         let count = tokens.count
    167         let list = List {
    168             if count == 0 {
    169                 Text(isPass ? "No passes yet"
    170                             : "No discounts yet")
    171                     .talerFont(.headline)
    172             } else {
    173                 let comment: StaticString = "segmented control for passes & discounts"
    174                 let strings = [String(localized: "Active", comment: comment),
    175                                String(localized: "Expired", comment: comment)]
    176                 SegmentControl2(value: $activeExpired, strings: strings)
    177                     .listRowSeparator(.hidden)
    178                     .padding(.bottom, 10)
    179 
    180                 ForEach(0..<count, id: \.self) { index in
    181                     let token = tokens[index]
    182                     DiscountsPassesItem(stack: stack.push(),
    183                                         token: token,
    184                                        isPass: isPass,
    185                                 activeExpired: activeExpired,
    186                                         index: index
    187                     ) { deletionID in
    188                         Task { // runs on MainActor
    189                             let _ = await delete(deletionID)
    190                             await refresh()
    191                         }
    192                     }
    193                 }
    194             }
    195         }
    196             .listStyle(myListStyle.style).anyView
    197             .background(FullBackground())
    198             .refreshable {
    199                 controller.hapticNotification(.success)
    200                 await refresh()
    201             }
    202         list
    203             .navigationTitle(navTitle)
    204             .task {
    205                 await refresh()
    206             }
    207     }
    208 }
    209 // MARK: -
    210 struct DiscountPassesSection: View {
    211     let stack: CallStack
    212     @EnvironmentObject private var controller: Controller
    213     @Environment(\.colorScheme) private var colorScheme
    214     @Environment(\.colorSchemeContrast) private var colorSchemeContrast
    215 
    216     @State private var discounts: Bool = false
    217     @State private var passes: Bool = false
    218 
    219     var body: some View {
    220         let discText = String(localized: "Discounts")
    221         let passText = String(localized: "Passes")
    222         let activeColor = WalletColors().talerColor
    223         let inactiveColor = WalletColors().secondary(colorScheme, colorSchemeContrast)
    224         Section {
    225             let discCount = controller.discounts.count
    226             let discColor = discCount > 0 ? activeColor : inactiveColor
    227             let discountsLabel = Label {
    228                                      Text(discText)
    229                                  } icon: {
    230                                      Image(ICONNAME_DISCOUNTS, SYSTEM_DISCOUNTS)
    231                                          .foregroundColor(discColor)
    232                                  }
    233 
    234             let passCount = controller.subscriptions.count
    235             let passColor = passCount > 0 ? activeColor : inactiveColor
    236             let passesLabel = Label {
    237                                   Text(passText)
    238                               } icon: {
    239                                   Image(ICONNAME_PASSES, SYSTEM_PASSES)
    240                                       .foregroundColor(passColor)
    241                               }
    242 
    243             let discountsDest = DiscountsPassesList(stack: stack.push(),
    244                                                    isPass: false,
    245                                                  navTitle: discText)
    246             let passesDest = DiscountsPassesList(stack: stack.push(),
    247                                                 isPass: true,
    248                                               navTitle: passText)
    249             NavigationLink(destination: discountsDest) { discountsLabel }
    250             // TODO: a11y for discCount > 0
    251             NavigationLink(destination: passesDest) { passesLabel }
    252             // TODO: a11y for passCount > 0
    253         }
    254         .listRowSeparator(.hidden)
    255         .talerFont(.title2)
    256     }
    257 }