Skip to content

Commit c1979db

Browse files
Ask for a tip at good moments, say what TABOR costs, and thank supporters on their cards
The servers aren't free, but the only asks were a card on ME at 25 catches that led to Settings, and the tips at the top of Settings. Now a short sheet comes up after the reveal of a catch worth celebrating: the first legendary, a new badge, or the 50th, 150th or 500th catch. It waits for the reveal and any badge toasts to go, says what TABOR costs to run (about 55 zł a month), and offers the three tips. It asks three times at most, two weeks apart, and never once someone has tipped (a real tip, so debug builds still get asked). ME's card opens the same sheet and counts as one of the three. Settings says the same cost line. A tip now also puts a gold rim and a SUPPORTER mark on the cards you share (catch, badge and stats), so the thank-you goes wherever they post them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2f01c67 commit c1979db

11 files changed

Lines changed: 472 additions & 72 deletions

File tree

‎Tabor/App/TaborApp.swift‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,35 @@ final class Router {
8888
func revealGone() {
8989
landing = pendingLanding
9090
pendingLanding = nil
91+
revealOpen = false
92+
revealClosed = Date()
9193
}
9294

95+
/// A reveal is on screen: nothing else may be presented over the app meanwhile.
96+
private(set) var revealOpen = false
97+
/// When the last one went, so a badge it earned can still count as its moment.
98+
private(set) var revealClosed: Date?
99+
100+
func revealShown() { revealOpen = true }
101+
102+
/// A catch worth asking for a tip on (`SupportAsk`), held until the reveal and any badge
103+
/// toasts have gone. The first reason wins.
104+
private(set) var supportPending: SupportAsk.Reason?
105+
/// The tip sheet, presented from RootView so it's never on top of the reveal.
106+
var supportSheet: SupportAsk.Reason?
107+
108+
func askForSupport(_ reason: SupportAsk.Reason) {
109+
if supportPending == nil { supportPending = reason }
110+
}
111+
112+
/// From a badge earned by a catch just made (the badges are worked out once it settles).
113+
func badgeEarned() {
114+
guard let revealClosed, Date().timeIntervalSince(revealClosed) < 30 else { return }
115+
askForSupport(.newBadge)
116+
}
117+
118+
func supportAsked() { supportPending = nil }
119+
93120
/// Its vehicles on HUNT's map. The filter is already set; the book keeps its place.
94121
func showOnHunt(_ modelId: String) {
95122
huntModel = modelId
@@ -207,6 +234,21 @@ struct RootView: View {
207234
.transition(.move(edge: .top).combined(with: .opacity))
208235
}
209236
}
237+
// The tip sheet, once the reveal and the badge toasts are out of the way: a catch's
238+
// badges arrive a couple of seconds after it settles, so give them time to start.
239+
.task(id: router.supportPending) {
240+
guard let reason = router.supportPending else { return }
241+
try? await Task.sleep(for: .seconds(3.5))
242+
for _ in 0..<40 where router.revealOpen || badges.busy {
243+
try? await Task.sleep(for: .milliseconds(500))
244+
}
245+
guard !Task.isCancelled else { return }
246+
router.supportAsked()
247+
guard !router.revealOpen, !badges.busy, SupportAsks.due else { return }
248+
SupportAsks.record()
249+
router.supportSheet = reason
250+
}
251+
.sheet(item: $router.supportSheet) { reason in TipSheet(reason: reason) }
210252
.task { await FleetUpdater.checkIfDue() }
211253
.task { TipJar.shared.start() }
212254
.task { await Task.detached(priority: .background) { StorageUsage.sweep() }.value }
@@ -224,7 +266,7 @@ struct RootView: View {
224266
guard await settled() else { return }
225267
let earned = await Task.detached(priority: .utility) { Achievements.evaluate(records, catalog: catalog) }.value
226268
guard !Task.isCancelled else { return }
227-
badges.update(earned)
269+
if badges.update(earned) { router.badgeEarned() }
228270
await WeatherService.backfill(sightings, context: context)
229271
}
230272
// Refresh the Home / Lock Screen widgets whenever what they show could have changed.

‎Tabor/Core/SupportAsk.swift‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Foundation
2+
3+
/// When TABOR asks for a tip: right after a catch worth celebrating, never more than three
4+
/// times, and two weeks apart at least, so a supporter-to-be gets asked at a happy moment and
5+
/// everyone else is left alone.
6+
public enum SupportAsk {
7+
public enum Reason: Hashable, Sendable {
8+
case firstLegendary
9+
case newBadge
10+
/// The catch that reached this many.
11+
case milestone(Int)
12+
/// The card on ME.
13+
case meCard
14+
}
15+
16+
public static let milestones = [50, 150, 500]
17+
public static let maxAsks = 3
18+
public static let gap: TimeInterval = 14 * 24 * 3600
19+
20+
/// What makes this catch worth asking on, if anything. `catches` leave second cars out,
21+
/// as the stats do; the other vehicles in a shot count.
22+
public static func reason(catchesBefore: Int, catchesAfter: Int, firstLegendary: Bool) -> Reason? {
23+
if firstLegendary { return .firstLegendary }
24+
return milestones.first { catchesBefore < $0 && $0 <= catchesAfter }.map(Reason.milestone)
25+
}
26+
27+
/// The first legendary: one in this catch, none in the book before it.
28+
public static func isFirstLegendary(before: CollectionStats, caught modelIds: [String], catalog: FleetCatalog) -> Bool {
29+
let legendary = { (id: String) in catalog.model(id: id)?.tier == .legendary }
30+
return modelIds.contains(where: legendary) && !before.vehicles.contains { legendary($0.modelId) }
31+
}
32+
33+
/// `tipped` is a real tip, not debug builds' stand-in supporter, so they still get asked.
34+
public static func due(asks: Int, lastAsked: Date?, now: Date, tipped: Bool) -> Bool {
35+
guard !tipped, asks < maxAsks else { return false }
36+
return lastAsked.map { now.timeIntervalSince($0) >= gap } ?? true
37+
}
38+
}

‎Tabor/Resources/Localizable.xcstrings‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,76 @@
11
{
22
"sourceLanguage": "en",
33
"strings": {
4+
"TABOR costs about 55 zł a month to run: the server behind HUNT's live map and Apple's developer fee. It's free with no ads, so tips are what keep it going.": {
5+
"localizations": {
6+
"pl": {
7+
"stringUnit": {
8+
"state": "translated",
9+
"value": "Utrzymanie TABORU kosztuje około 55 zł miesięcznie: serwer, z którego HUNT bierze mapę na żywo, i opłata deweloperska dla Apple. Aplikacja jest darmowa i bez reklam, więc to napiwki pozwalają jej działać."
10+
}
11+
}
12+
}
13+
},
14+
"Any tip puts a heart next to ME, unlocks three extra app icons and a gold rim on the cards you share.": {
15+
"localizations": {
16+
"pl": {
17+
"stringUnit": {
18+
"state": "translated",
19+
"value": "Każdy napiwek dodaje serduszko przy ME, odblokowuje trzy dodatkowe ikony aplikacji i złotą ramkę na kartach, które udostępniasz."
20+
}
21+
}
22+
}
23+
},
24+
"Badges and everything else stay free.": {
25+
"localizations": {
26+
"pl": {
27+
"stringUnit": {
28+
"state": "translated",
29+
"value": "Odznaki i cała reszta zostają za darmo."
30+
}
31+
}
32+
}
33+
},
34+
"Keep TABOR going": {
35+
"localizations": {
36+
"pl": {
37+
"stringUnit": {
38+
"state": "translated",
39+
"value": "Pomóż utrzymać TABOR"
40+
}
41+
}
42+
}
43+
},
44+
"YOUR FIRST LEGENDARY": {
45+
"localizations": {
46+
"pl": {
47+
"stringUnit": {
48+
"state": "translated",
49+
"value": "TWÓJ PIERWSZY LEGENDARNY"
50+
}
51+
}
52+
}
53+
},
54+
"NEW BADGE": {
55+
"localizations": {
56+
"pl": {
57+
"stringUnit": {
58+
"state": "translated",
59+
"value": "NOWA ODZNAKA"
60+
}
61+
}
62+
}
63+
},
64+
"NOT NOW": {
65+
"localizations": {
66+
"pl": {
67+
"stringUnit": {
68+
"state": "translated",
69+
"value": "NIE TERAZ"
70+
}
71+
}
72+
}
73+
},
474
"Export as spreadsheet (CSV)": {
575
"localizations": {
676
"pl": {

‎Tabor/Screens/Badges.swift‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -556,21 +556,27 @@ final class BadgeTracker {
556556
private var showing = false
557557
private static let key = "badgeLevelsSeen"
558558

559-
func update(_ badges: [Achievement]) {
559+
/// A toast is up or waiting.
560+
var busy: Bool { showing || current != nil || !queue.isEmpty }
561+
562+
/// Queues a toast for anything newly earned; true if there was any.
563+
@discardableResult
564+
func update(_ badges: [Achievement]) -> Bool {
560565
let defaults = UserDefaults.standard
561566
let now = Dictionary(badges.map { ($0.id, $0.level) }, uniquingKeysWith: max)
562567
guard let seen = defaults.dictionary(forKey: Self.key) as? [String: Int] else {
563568
// First launch with badges: what's already earned arrives quietly.
564569
defaults.set(now, forKey: Self.key)
565-
return
570+
return false
566571
}
567572
let fresh = badges.filter { $0.level > (seen[$0.id] ?? 0) }
568573
// Remember the best level ever reached, so deleting and re-adding a catch doesn't replay it.
569574
defaults.set(seen.merging(now, uniquingKeysWith: max), forKey: Self.key)
570-
guard !fresh.isEmpty else { return }
575+
guard !fresh.isEmpty else { return false }
571576
let best = fresh.sorted { ($0.secret ? 1 : 0, $0.level) > ($1.secret ? 1 : 0, $1.level) }
572577
queue += best.count > 3 ? [Unlock(badge: best[0], others: best.count - 1)] : best.map { Unlock(badge: $0) }
573578
if !showing { Task { await playQueue() } }
579+
return true
574580
}
575581

576582
func dismiss() {

‎Tabor/Screens/MeView.swift‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,11 @@ struct MeView: View {
9292
}
9393
.padding(.top, 18)
9494
.padding(.horizontal, 22)
95-
} else if !supportTipSeen, !TipJar.shared.tipped, sightings.count >= SupportTip.afterCatches {
95+
} else if !supportTipSeen, !TipJar.shared.tipped, SupportAsks.count < SupportAsk.maxAsks,
96+
sightings.count >= SupportTip.afterCatches {
9697
TipCard(icon: "heart.fill", kicker: "ENJOYING TABOR?", title: "Consider supporting it with a tip") {
97-
showSettings = true
98+
SupportAsks.record()
99+
router.supportSheet = .meCard
98100
supportTipSeen = true
99101
} dismiss: {
100102
withAnimation(.snappy) { supportTipSeen = true }

‎Tabor/Screens/RevealView.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ struct RevealView: View {
341341
}
342342
.onChange(of: draft.partner) { _, _ in refreshAlso() }
343343
.animation(.spring(response: 0.45, dampingFraction: 0.6), value: draft.wrap)
344+
.onAppear { router.revealShown() }
344345
// Only now may the book scroll to the catch: CATCH (and this cover with it) goes as the
345346
// tab switches, so the cover's own onDismiss never runs.
346347
.onDisappear { router.revealGone() }
@@ -1035,6 +1036,13 @@ struct RevealView: View {
10351036
context.assign(number: number, to: model.id, existing: manual)
10361037
}
10371038
try? context.save()
1039+
// A moment worth asking for a tip on; the sheet waits until the reveal has gone.
1040+
let catches = (try? context.fetchCount(FetchDescriptor<Sighting>(predicate: #Predicate { $0.pairedWith == nil }))) ?? 0
1041+
let added = cars.filter { $0.pairedWith == nil }.count
1042+
let firstLegendary = SupportAsk.isFirstLegendary(before: draft.book.stats, caught: cars.map(\.modelId), catalog: catalog)
1043+
if let reason = SupportAsk.reason(catchesBefore: catches - added, catchesAfter: catches, firstLegendary: firstLegendary) {
1044+
router.askForSupport(reason)
1045+
}
10381046
// Shrinking a full-size shot takes a moment: off the main thread, then each sighting
10391047
// points at its own small copy (HEIC, so a new name) and the full-size one goes.
10401048
let files = cars.map(\.photoFile)

‎Tabor/Screens/ShareCard.swift‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@ struct ShareCard: View {
5959
let accent = wrap != nil ? Palette.foilInk : tier == .common ? Palette.yellow : tier.color
6060

6161
VStack(alignment: .leading, spacing: 0) {
62-
HStack {
62+
HStack(spacing: 8) {
6363
Text("TABOR")
6464
.font(TaborFont.grotesk(15, 700))
6565
.em(0.02, size: 15)
6666
.foregroundStyle(Palette.ink)
67+
SupporterMark()
6768
Spacer()
6869
Mono("CAUGHT · \(Self.day.string(from: sighting.date).uppercased())", size: 9.5, spacing: 0.14,
6970
color: accent)
@@ -133,6 +134,7 @@ struct ShareCard: View {
133134
], center: .init(x: 0.5, y: 0.42), startRadius: 8, endRadius: 260)
134135
}
135136
}
137+
.supporterRim()
136138
.environment(\.colorScheme, .dark)
137139
}
138140

@@ -289,11 +291,12 @@ struct BadgeShareCard: View {
289291
let medal = badge.medal
290292

291293
VStack(spacing: 0) {
292-
HStack {
294+
HStack(spacing: 8) {
293295
Text("TABOR")
294296
.font(TaborFont.grotesk(15, 700))
295297
.em(0.02, size: 15)
296298
.foregroundStyle(Palette.ink)
299+
SupporterMark()
297300
Spacer()
298301
Mono(badge.kicker, size: 9.5, spacing: 0.14, color: medal.ink)
299302
}
@@ -346,6 +349,7 @@ struct BadgeShareCard: View {
346349
], center: .init(x: 0.5, y: 0.22), startRadius: 8, endRadius: 280)
347350
}
348351
}
352+
.supporterRim()
349353
.environment(\.colorScheme, .dark)
350354
}
351355

@@ -376,3 +380,34 @@ struct BadgeShareCard: View {
376380
}
377381
}
378382
}
383+
384+
/// Supporters' share cards: a gold edge, and ♥ SUPPORTER by the wordmark, a thank-you that
385+
/// travels with what they share.
386+
struct SupporterMark: View {
387+
var body: some View {
388+
if TipJar.shared.isSupporter {
389+
HStack(spacing: 3) {
390+
Image(systemName: "heart.fill").font(.system(size: 7.5, weight: .bold))
391+
Mono("SUPPORTER", size: 8.5, weight: 700, spacing: 0.12, color: Palette.yellow)
392+
}
393+
.foregroundStyle(Palette.yellow)
394+
.padding(.horizontal, 6)
395+
.padding(.vertical, 2.5)
396+
.background(Palette.yellow.opacity(0.14), in: Capsule())
397+
.fixedSize()
398+
}
399+
}
400+
}
401+
402+
extension View {
403+
func supporterRim() -> some View {
404+
overlay {
405+
if TipJar.shared.isSupporter {
406+
Rectangle().strokeBorder(
407+
LinearGradient(colors: [Palette.goldWash, Palette.yellow, Palette.brass, Palette.yellow, Palette.goldWash],
408+
startPoint: .topLeading, endPoint: .bottomTrailing),
409+
lineWidth: 5)
410+
}
411+
}
412+
}
413+
}

‎Tabor/Screens/StatsView.swift‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -954,8 +954,9 @@ private struct StatsShareCard: View {
954954
let s = stats
955955
let rarest = s.records.rarest.flatMap { r in catalog.model(id: r.modelId).map { (r, $0) } }
956956
VStack(alignment: .leading, spacing: 0) {
957-
HStack {
957+
HStack(spacing: 8) {
958958
Text("TABOR").font(TaborFont.mono(13, 700)).em(0.2, size: 13)
959+
SupporterMark()
959960
Spacer()
960961
Mono(dateLine, size: 10.5, color: Palette.sub)
961962
}
@@ -993,6 +994,7 @@ private struct StatsShareCard: View {
993994
.padding(.bottom, 22)
994995
.frame(width: 360, height: 640, alignment: .topLeading)
995996
.background(Palette.bg)
997+
.supporterRim()
996998
.environment(\.colorScheme, .dark)
997999
}
9981000

0 commit comments

Comments
 (0)