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 }