SupporterBadge.swift (8702B)
1 // 2 // SupporterBadge.swift 3 // damus 4 // 5 // Created by William Casarin on 2023-05-15. 6 // 7 8 import SwiftUI 9 10 struct SupporterBadge: View { 11 let percent: Int? 12 let purple_account: DamusPurple.Account? 13 let style: Style 14 let text_color: Color 15 var badge_variant: BadgeVariant { 16 if purple_account?.attributes.contains(.memberForMoreThanOneYear) == true { 17 return .oneYearSpecial 18 } 19 else { 20 return .normal 21 } 22 } 23 24 init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) { 25 self.percent = percent 26 self.purple_account = purple_account 27 self.style = style 28 self.text_color = text_color 29 } 30 31 let size: CGFloat = 17 32 33 var body: some View { 34 HStack { 35 if let purple_account, purple_account.active == true { 36 HStack(spacing: 1) { 37 switch self.badge_variant { 38 case .normal: 39 StarShape() 40 .frame(width:size, height:size) 41 .foregroundStyle(GoldGradient) 42 case .oneYearSpecial: 43 DoubleStar(size: size) 44 } 45 46 if self.style == .full, 47 let ordinal = self.purple_account?.ordinal() { 48 Text(ordinal) 49 .foregroundStyle(text_color) 50 .font(.caption) 51 } 52 } 53 } 54 else if let percent, percent < 100 { 55 Image("star.fill") 56 .resizable() 57 .frame(width:size, height:size) 58 .foregroundColor(support_level_color(percent)) 59 } else if let percent, percent == 100 { 60 Image("star.fill") 61 .resizable() 62 .frame(width:size, height:size) 63 .foregroundStyle(GoldGradient) 64 } 65 } 66 } 67 68 enum Style { 69 case full // Shows the entire badge with a purple subscriber number if present 70 case compact // Does not show purple subscriber number. Only shows the star (if applicable) 71 } 72 73 enum BadgeVariant { 74 /// A normal badge that people are used to 75 case normal 76 /// A special badge for users who have been members for more than a year 77 case oneYearSpecial 78 } 79 } 80 81 82 struct StarShape: Shape { 83 func path(in rect: CGRect) -> Path { 84 var path = Path() 85 let center = CGPoint(x: rect.midX, y: rect.midY) 86 let radius: CGFloat = min(rect.width, rect.height) / 2 87 let points = 5 88 let adjustment: CGFloat = .pi / 2 89 90 for i in 0..<points * 2 { 91 let angle = (CGFloat(i) * .pi / CGFloat(points)) - adjustment 92 let pointRadius = i % 2 == 0 ? radius : radius * 0.4 93 let point = CGPoint(x: center.x + pointRadius * cos(angle), y: center.y + pointRadius * sin(angle)) 94 if i == 0 { 95 path.move(to: point) 96 } else { 97 path.addLine(to: point) 98 } 99 } 100 path.closeSubpath() 101 return path 102 } 103 } 104 105 struct DoubleStar: View { 106 let size: CGFloat 107 var starOffset: CGFloat = 5 108 109 var body: some View { 110 if #available(iOS 17.0, *) { 111 DoubleStarShape(starOffset: starOffset) 112 .frame(width: size, height: size) 113 .foregroundStyle(GoldGradient) 114 .padding(.trailing, starOffset) 115 } else { 116 Fallback(size: size, starOffset: starOffset) 117 } 118 } 119 120 @available(iOS 17.0, *) 121 struct DoubleStarShape: Shape { 122 var strokeSize: CGFloat = 3 123 var starOffset: CGFloat 124 125 func path(in rect: CGRect) -> Path { 126 let normalSizedStarPath = StarShape().path(in: rect) 127 let largerStarPath = StarShape().path(in: rect.insetBy(dx: -strokeSize, dy: -strokeSize)) 128 129 let finalPath = normalSizedStarPath 130 .subtracting( 131 largerStarPath.offsetBy(dx: starOffset, dy: 0) 132 ) 133 .union( 134 normalSizedStarPath.offsetBy(dx: starOffset, dy: 0) 135 ) 136 137 return finalPath 138 } 139 } 140 141 /// A fallback view for those who cannot run iOS 17 142 struct Fallback: View { 143 var size: CGFloat 144 var starOffset: CGFloat 145 146 var body: some View { 147 HStack { 148 StarShape() 149 .frame(width: size, height: size) 150 .foregroundStyle(GoldGradient) 151 152 StarShape() 153 .fill(GoldGradient) 154 .overlay( 155 StarShape() 156 .stroke(Color.damusAdaptableWhite, lineWidth: 1) 157 ) 158 .frame(width: size + 1, height: size + 1) 159 .padding(.leading, -size - starOffset) 160 } 161 .padding(.trailing, -3) 162 } 163 } 164 } 165 166 167 168 func support_level_color(_ percent: Int) -> Color { 169 if percent == 0 { 170 return .gray 171 } 172 173 let percent_f = Double(percent) / 100.0 174 let cutoff = 0.5 175 let h = cutoff + (percent_f * cutoff); // Hue (note 0.2 = Green, see huge chart below) 176 let s = 0.9; // Saturation 177 let b = 0.9; // Brightness 178 179 return Color(hue: h, saturation: s, brightness: b) 180 } 181 182 struct SupporterBadge_Previews: PreviewProvider { 183 static func Level(_ p: Int) -> some View { 184 HStack(alignment: .center) { 185 SupporterBadge(percent: p, style: .full) 186 .frame(width: 50) 187 Text(verbatim: p.formatted()) 188 .frame(width: 50) 189 } 190 } 191 192 static func Purple(_ subscriber_number: Int) -> some View { 193 HStack(alignment: .center) { 194 SupporterBadge( 195 percent: nil, 196 purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true, attributes: []), 197 style: .full 198 ) 199 .frame(width: 100) 200 } 201 } 202 203 static var previews: some View { 204 VStack(spacing: 0) { 205 VStack(spacing: 0) { 206 Level(1) 207 Level(10) 208 Level(20) 209 Level(30) 210 Level(40) 211 Level(50) 212 } 213 Level(60) 214 Level(70) 215 Level(80) 216 Level(90) 217 Level(100) 218 Purple(1) 219 Purple(2) 220 Purple(3) 221 Purple(99) 222 Purple(100) 223 Purple(1971) 224 } 225 } 226 } 227 228 #Preview("1 yr badge") { 229 VStack { 230 HStack(alignment: .center) { 231 SupporterBadge( 232 percent: nil, 233 purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: 3, active: true, attributes: []), 234 style: .full 235 ) 236 .frame(width: 100) 237 } 238 239 HStack(alignment: .center) { 240 SupporterBadge( 241 percent: nil, 242 purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: 3, active: true, attributes: [.memberForMoreThanOneYear]), 243 style: .full 244 ) 245 .frame(width: 100) 246 } 247 248 Text(verbatim: "Double star (just shape itself, with alt background color, to show it adapts to background well)") 249 .multilineTextAlignment(.center) 250 251 if #available(iOS 17.0, *) { 252 HStack(alignment: .center) { 253 DoubleStar.DoubleStarShape(starOffset: 5) 254 .frame(width: 17, height: 17) 255 .padding(.trailing, -8) 256 } 257 .background(Color.blue) 258 } 259 260 Text(verbatim: "Double star (fallback for iOS 16 and below)") 261 262 HStack(alignment: .center) { 263 DoubleStar.Fallback(size: 17, starOffset: 5) 264 } 265 266 Text(verbatim: "Double star (fallback for iOS 16 and below, with alt color limitation shown)") 267 .multilineTextAlignment(.center) 268 269 HStack(alignment: .center) { 270 DoubleStar.Fallback(size: 17, starOffset: 5) 271 } 272 .background(Color.blue) 273 } 274 } 275 276