taler-ios

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

DiscountPasses.swift (10480B)


      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 primaryAccent = WalletColors().primaryAccent
     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(primaryAccent)
     82                 } else {
     83                     // Pass
     84                     if isValid {
     85                         Text("active")
     86                             .foregroundColor(primaryAccent)
     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(primaryAccent.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     @AppStorage("preferredColorScheme") var preferredColorScheme: Int = 0
    136     @State private var activeExpired: Int = 0
    137 
    138     func delete(_ tokenID: String) async {
    139         if isPass {
    140             try? await model.deleteSubscription(tokenID)
    141         } else {
    142             try? await model.deleteDiscount(tokenID)
    143         }
    144     }
    145     func refresh() async {
    146 //        symLog.log("refreshing balances")
    147         if isPass {
    148             await controller.loadSubscriptions(stack.push("refreshing passes"), model)
    149         } else {
    150             await controller.loadDiscounts(stack.push("refreshing discounts"), model)
    151         }
    152     }
    153 
    154     func talerTokens() -> [TalerToken] {
    155         let tokens = isPass ? controller.subscriptions
    156                             : controller.discounts
    157         let active = activeExpired == 0
    158 
    159         return tokens.filter {
    160             let isValid = $0.validityEnd > .now()
    161             return active ? isValid
    162                           : !isValid
    163         }
    164     }
    165 
    166     @ViewBuilder func section(_ token: TalerToken, index: Int) -> some View {
    167         DiscountsPassesItem(stack: stack.push(),
    168                             token: token,
    169                            isPass: isPass,
    170                     activeExpired: activeExpired,
    171                             index: index
    172         ) { deletionID in
    173             Task { // runs on MainActor
    174                 let _ = await delete(deletionID)
    175                 await refresh()
    176             }
    177         }
    178     }
    179 
    180     var body: some View {
    181         let tokens = talerTokens()
    182         let count = tokens.count
    183         let comment: StaticString = "segmented control for passes & discounts"
    184         let strings = [String(localized: "Active", comment: comment),
    185                        String(localized: "Expired", comment: comment)]
    186         let list = List {
    187             Section {
    188                 if count == 0 {
    189                     Text(isPass ? ( activeExpired == 0 ? "No active passes yet" : "No expired / used passes yet")
    190                                 : ( activeExpired == 0 ? "No active discounts yet" : "No expired / used discounts yet"))
    191                     .talerFont(.headline)
    192                 } else {
    193                     let token = tokens[0]
    194                     section(token, index: 0)
    195                 }
    196             } header: {
    197                 SegmentControl2(value: $activeExpired, strings: strings)
    198             }
    199             if count > 1 {
    200                 ForEach(1..<count, id: \.self) { index in
    201                     let token = tokens[index]
    202                     section(token, index: index)
    203                 }
    204             }
    205         }
    206             .listStyle(myListStyle.style).anyView
    207             .background(FullBackground())
    208             .navigationTitle(navTitle)
    209             .refreshable {
    210                 controller.hapticNotification(.success)
    211                 await refresh()
    212             }
    213             .task {
    214                 await refresh()
    215             }
    216         if #available(iOS 17.0, *) {
    217             list
    218                 .scrollContentBackground(preferredColorScheme == 3 ? .hidden : .visible)
    219         } else {
    220             list
    221         }
    222     }
    223 }
    224 // MARK: -
    225 struct DiscountPassesSection: View {
    226     let stack: CallStack
    227     @EnvironmentObject private var controller: Controller
    228     @Environment(\.colorScheme) private var colorScheme
    229     @Environment(\.colorSchemeContrast) private var colorSchemeContrast
    230 
    231     @State private var discounts: Bool = false
    232     @State private var passes: Bool = false
    233 
    234     var body: some View {
    235         let discText = String(localized: "Discounts")
    236         let passText = String(localized: "Passes")
    237         let activeColor = WalletColors().primaryAccent
    238         let inactiveColor = WalletColors().secondary(colorScheme, colorSchemeContrast)
    239         Section {
    240             let discCount = controller.discounts.count
    241             let discColor = discCount > 0 ? activeColor : inactiveColor
    242             let discountsLabel = Label {
    243                                      Text(discText)
    244                                  } icon: {
    245                                      Image(ICONNAME_DISCOUNTS, SYSTEM_DISCOUNTS)
    246                                          .foregroundColor(discColor)
    247                                  }
    248 
    249             let passCount = controller.subscriptions.count
    250             let passColor = passCount > 0 ? activeColor : inactiveColor
    251             let passesLabel = Label {
    252                                   Text(passText)
    253                               } icon: {
    254                                   Image(ICONNAME_PASSES, SYSTEM_PASSES)
    255                                       .foregroundColor(passColor)
    256                               }
    257 
    258             let discountsDest = DiscountsPassesList(stack: stack.push(),
    259                                                    isPass: false,
    260                                                  navTitle: discText)
    261             let passesDest = DiscountsPassesList(stack: stack.push(),
    262                                                 isPass: true,
    263                                               navTitle: passText)
    264             NavigationLink(destination: discountsDest) { discountsLabel }
    265             // TODO: a11y for discCount > 0
    266             NavigationLink(destination: passesDest) { passesLabel }
    267             // TODO: a11y for passCount > 0
    268         }
    269         .listRowSeparator(.hidden)
    270         .talerFont(.title2)
    271     }
    272 }