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 }