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 }