taler-ios

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

DiscountPasses.swift (12247B)


      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 primary2 = WalletColors().primary2(colorScheme, colorSchemeContrast)
     58         let secondary = WalletColors().secondary2(colorScheme, colorSchemeContrast)
     59         let primaryAccent = WalletColors().primaryAccent
     60         let onPrimary = WalletColors().onPrimary
     61 
     62         let name = Text(token.name)
     63             .talerFont(.title)
     64             .foregroundColor(primary2)
     65         let desc = Text(token.description)
     66             .talerFont(.body)
     67             .foregroundColor(primary2)
     68 
     69         Section {
     70             VStack(alignment: .leading, spacing: 2) {
     71                 let row = HStack(alignment: .top) {
     72                     name
     73                     Spacer()
     74                     if let available = token.tokensAvailable {
     75                         // Discount
     76                         Text("x\(available)")
     77                             .foregroundColor(isValid ? onPrimary : primaryAccent)
     78                             .padding(.horizontal, 8)
     79                             .padding(.vertical, 4)
     80                             .background(Capsule().fill(isValid ? primaryAccent : .clear))
     81                     } else {
     82                         // Pass
     83                         if isValid {
     84                             Text("active")
     85                                 .foregroundColor(onPrimary)
     86                                 .padding(.horizontal, 8)
     87                                 .padding(.vertical, 4)
     88                                 .background(Capsule().fill(primaryAccent))
     89                         }
     90                     }
     91                 }
     92                     .padding(.horizontal)
     93                     .padding(.top, 6)
     94                 if isValid {
     95                     row
     96                 } else {
     97                     let swipe = Button {
     98                         symLog.log("delete \(token.id)")
     99                         deleteAction(token.id)
    100                     } label: {
    101                         Label("Delete", systemImage: "trash")
    102                     }.tint(WalletColors().negative)
    103                     row.swipeActions(edge: .trailing) { swipe }
    104                 }
    105 //                Divider()
    106                 desc
    107                     .padding(.horizontal)
    108                     .padding(.vertical, 4)
    109 //                Divider()
    110                 let merchant = token.merchantInfo?.name ?? "Unknown"
    111                 Text(isPass    ? "Provided by: \(merchant)"
    112                   : !isExpired ? "Redeemable at: \(merchant)"
    113                      : isValid ? "Was redeemable at: \(merchant)"
    114                                : "Redeemed at: \(merchant)")
    115                     .talerFont(.body)
    116                     .foregroundColor(secondary)
    117                     .padding(.horizontal)
    118                     .padding(.vertical, 4)
    119             }
    120 
    121             ContainerRelativeShape()
    122                 .fill(isValid ? primaryAccent.opacity(0.1) : .clear)
    123                 .overlay {
    124                     let dateStrings = validDate(isValid, isExpired: isExpired)
    125                     Text(dateStrings.0)
    126                         .accessibilityLabel(dateStrings.1)
    127                         .talerFont(.body)
    128                 }
    129                 .onTapGesture {
    130                     if !isExpired {
    131                         dateToggled.toggle()
    132                     }
    133                 }
    134         }
    135         .listRowSeparator(.hidden)
    136         .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
    137     }
    138 }
    139 // MARK: -
    140 struct DiscountsPassesList: View {
    141     private let symLog = SymLogV(0)
    142     let stack: CallStack
    143     let isPass: Bool
    144     let navTitle: String
    145 
    146     @EnvironmentObject private var model: WalletModel
    147     @EnvironmentObject private var controller: Controller
    148     @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic
    149     @AppStorage("preferredColorScheme") var preferredColorScheme: Int = 0
    150     @State private var activeExpired: Int = 0
    151 
    152     func delete(_ tokenID: String) async {
    153         if isPass {
    154             try? await model.deleteSubscription(tokenID)
    155         } else {
    156             try? await model.deleteDiscount(tokenID)
    157         }
    158     }
    159     func refresh() async {
    160 //        symLog.log("refreshing balances")
    161         if isPass {
    162             await controller.loadSubscriptions(stack.push("refreshing passes"))
    163         } else {
    164             await controller.loadDiscounts(stack.push("refreshing discounts"))
    165         }
    166     }
    167 
    168     func talerTokens() -> [TalerToken] {
    169         let tokens = isPass ? controller.subscriptions
    170                             : controller.discounts
    171         let active = activeExpired == 0
    172 
    173         return tokens.filter {
    174             let isValid = $0.validityEnd > .now()
    175             return active ? isValid
    176                           : !isValid
    177         }
    178     }
    179 
    180     @ViewBuilder func section(_ token: TalerToken, index: Int) -> some View {
    181         DiscountsPassesItem(stack: stack.push(),
    182                             token: token,
    183                            isPass: isPass,
    184                     activeExpired: activeExpired,
    185                             index: index
    186         ) { deletionID in
    187             Task { // runs on MainActor
    188                 await delete(deletionID)
    189                 await refresh()
    190             }
    191         }
    192     }
    193 
    194     var body: some View {
    195         let tokens = talerTokens()
    196         let count = tokens.count
    197         let comment: StaticString = "segmented control for passes & discounts"
    198         let strings = [String(localized: "Active", comment: comment),
    199                        String(localized: "Expired", comment: comment)]
    200         let list = List {
    201             Section {
    202                 if count == 0 {
    203                     Text(isPass ? ( activeExpired == 0 ? "No active passes yet" : "No expired / used passes yet")
    204                                 : ( activeExpired == 0 ? "No active discounts yet" : "No expired / used discounts yet"))
    205                     .talerFont(.headline)
    206                 } else {
    207                     let token = tokens[0]
    208                     section(token, index: 0)
    209                 }
    210             } header: {
    211                 SegmentControl2(value: $activeExpired, strings: strings)
    212             }
    213             if count > 1 {
    214                 ForEach(1..<count, id: \.self) { index in
    215                     let token = tokens[index]
    216                     section(token, index: index)
    217                 }
    218             }
    219         }
    220             .listStyle(myListStyle.style).anyView
    221             .background(FullBackground())
    222             .navigationTitle(navTitle)
    223             .refreshable {
    224                 controller.hapticNotification(.success)
    225                 await refresh()
    226             }
    227             .task {
    228                 await refresh()
    229             }
    230         if #available(iOS 17.0, *) {
    231             list
    232                 .scrollContentBackground(preferredColorScheme == 3 ? .hidden : .visible)
    233         } else {
    234             list
    235         }
    236     }
    237 }
    238 struct TokenLabel: View {
    239     let stack: CallStack?
    240     let title: String
    241     let isActive: Bool
    242     let isPass: Bool                        // false = isDiscount
    243 
    244     @Environment(\.colorScheme) private var colorScheme
    245     @Environment(\.colorSchemeContrast) private var colorSchemeContrast
    246 
    247     init(stack: CallStack? = nil, title: String, isActive: Bool, isPass: Bool) {
    248         self.stack = stack
    249         self.title = title
    250         self.isActive = isActive
    251         self.isPass = isPass
    252     }
    253     var body: some View {
    254         let color = isActive ? WalletColors().primaryAccent
    255                              : WalletColors().secondary2(colorScheme, colorSchemeContrast)
    256         let iconName = isPass ? ICONNAME_PASSES : ICONNAME_DISCOUNTS
    257         let iconSystem = isPass ? SYSTEM_PASSES : SYSTEM_DISCOUNTS
    258         Label {
    259             Text(title)
    260         } icon: {
    261             Image(iconName, iconSystem)
    262                 .renderingMode(.template)
    263                 .foregroundColor(color)
    264         }
    265     }
    266 }
    267 // MARK: -
    268 struct DiscountPassesSection: View {
    269     let stack: CallStack
    270     @EnvironmentObject private var controller: Controller
    271 
    272     var body: some View {
    273         let discCount = controller.discounts.count
    274         let passCount = controller.subscriptions.count
    275         if discCount > 0 || passCount > 0 {
    276             Section {
    277                 if discCount > 0 {
    278                     let discText = String(localized: "Discounts", comment: "Label, View heading")
    279                     // TODO: only show active color if at least 1 discount is active
    280                     let discLabel = TokenLabel(stack: stack.push(),
    281                                                title: discText,
    282                                             isActive: discCount > 0,
    283                                               isPass: false)
    284                     let discDest = DiscountsPassesList(stack: stack.push(),
    285                                                       isPass: false,
    286                                                     navTitle: discText)
    287                     NavigationLink(destination: discDest) { discLabel }
    288                     // TODO: a11y for discCount > 0
    289                 }
    290                 if passCount > 0 {
    291                     let passText = String(localized: "Passes", comment: "Label, View heading")
    292                     // TODO: only show active color if at least 1 pass is active
    293                     let passLabel = TokenLabel(stack: stack.push(),
    294                                                title: passText,
    295                                             isActive: passCount > 0,
    296                                               isPass: true)
    297                     let passDest = DiscountsPassesList(stack: stack.push(),
    298                                                       isPass: true,
    299                                                     navTitle: passText)
    300                     NavigationLink(destination: passDest) { passLabel }
    301                     // TODO: a11y for passCount > 0
    302                 }
    303             }
    304             .listRowSeparator(.hidden)
    305             .talerFont(.title2)
    306         }
    307     }
    308 }