Skip to content

Instantly share code, notes, and snippets.

@Codelaby
Last active January 9, 2025 09:42
Show Gist options
  • Select an option

  • Save Codelaby/ec26d47a547bdc0e11831f0ed986bd4e to your computer and use it in GitHub Desktop.

Select an option

Save Codelaby/ec26d47a547bdc0e11831f0ed986bd4e to your computer and use it in GitHub Desktop.
Streak Manager Swift
/*
Third Library based -> https://github.com/lukerobertsapps/LRStreakKit
*/
import Foundation
/// Data for a streak, codable so it can be persisted
struct Streak: Codable {
/// The length of the streak in days
var length: Int = 0
/// When the streak was last updated
var lastDate: Date?
/// Determines an outcome for a given date and whether a given date is valid to continue the streak
/// - Parameter date: The date to test against
/// - Returns: An outcome such as nextDay, sameDay or invalid
func determineOutcome(for date: Date) -> Outcome {
guard let lastDate else { return .streakContinues }
let calendar = Calendar.current
if calendar.isDate(date, inSameDayAs: lastDate) {
return .alreadyCompletedToday
}
if let dayAfterLast = calendar.date(byAdding: .day, value: 1, to: lastDate) {
if calendar.isDate(date, inSameDayAs: dayAfterLast) {
return .streakContinues
}
}
return .streakBroken
}
/// Represents different outcomes for a streak
enum Outcome {
/// The streak was already completed for the day
case alreadyCompletedToday
/// The streak should continue
case streakContinues
/// The streak has been broken
case streakBroken
}
}
actor StreakActor {
private let key: String
init(key: String) {
self.key = key
}
public func updateStreak(onDate date: Date = .now) async {
var streak = await loadStreak()
let outcome = streak.determineOutcome(for: date)
switch outcome {
case .alreadyCompletedToday:
break
case .streakContinues:
streak.length += 1
streak.lastDate = date
case .streakBroken:
streak.length = 1
streak.lastDate = date
}
await save(streak: streak)
}
public func getStreakLength(forDate date: Date = .now) async -> Int {
let streak = await loadStreak()
return streak.length
}
public func hasCompletedStreak(onDate date: Date = .now) async -> Bool {
let streak = await loadStreak()
let outcome = streak.determineOutcome(for: date)
return outcome == .alreadyCompletedToday
}
public func resetStreak() async {
await save(streak: Streak())
}
private func loadStreak(decoder: JSONDecoder = .init()) async -> Streak {
guard let data = UserDefaults.standard.data(forKey: key) else {
return Streak()
}
do {
let fetched = try decoder.decode(Streak.self, from: data)
return fetched
} catch {
print("Failed to decode streak. Error: \(error.localizedDescription)")
return Streak()
}
}
private func save(streak: Streak, encoder: JSONEncoder = .init()) async {
guard let encoded = try? encoder.encode(streak) else {
print("Failed to encode current streak")
return
}
UserDefaults.standard.set(encoded, forKey: key)
}
}
import SwiftUI
@MainActor
final class StreakManager: ObservableObject {
@Published var streakLength: Int = 0
@Published var hasCompletedToday: Bool = false
private var streakActor: StreakActor
init(key: String) {
self.streakActor = StreakActor(key: key)
loadStreakData()
}
func updateStreak() {
Task {
//await streakActor.updateStreak(onDate: Date().addingTimeInterval(-1 * 24 * 60 * 60)) // 1 day ago
await streakActor.updateStreak() //today
loadStreakData()
}
}
func resetStreak() {
Task {
await streakActor.resetStreak()
loadStreakData()
}
}
private func loadStreakData() {
Task {
streakLength = await streakActor.getStreakLength()
hasCompletedToday = await streakActor.hasCompletedStreak()
}
}
}
import SwiftUI
extension View {
func stroke(color: Color, width: CGFloat = 1) -> some View {
modifier(StrokeModifier(strokeSize: width, strokeColor: color))
}
}
struct StrokeModifier: ViewModifier {
private let id = UUID()
var strokeSize: CGFloat = 1
var strokeColor: Color = .blue
func body(content: Content) -> some View {
if strokeSize > 0 {
appliedStrokeBackground(content: content)
} else {
content
}
}
private func appliedStrokeBackground(content: Content) -> some View {
content
.padding(strokeSize * 2)
.background(
Rectangle()
.foregroundColor(strokeColor)
.mask(alignment: .center) {
mask(content: content)
}
)
}
func mask(content: Content) -> some View {
Canvas { context, size in
context.addFilter(.alphaThreshold(min: 0.01))
if let resolvedView = context.resolveSymbol(id: id) {
context.draw(resolvedView, at: .init(x: size.width/2, y: size.height/2))
}
} symbols: {
content
.tag(id)
.blur(radius: strokeSize)
}
}
}
struct StreakView: View {
@StateObject private var streakManager = StreakManager(key: "DailyStreak")
var body: some View {
VStack {
Spacer()
Image(systemName: "flame.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 128)
.foregroundStyle(.orange.gradient)
//.symbolEffect(.bounce)
.overlay(alignment: .bottom) {
VStack(spacing: 0) {
Text("\(streakManager.streakLength)")
.font(.system(size: 72, weight: .heavy, design: .rounded))
.fixedSize()
.foregroundStyle(.white)
.stroke(color: .black, width: 2)
Text("day streak!")
}
.alignmentGuide(.bottom) { dim in
dim.height / 2
}
}
Spacer()
if streakManager.hasCompletedToday {
Text("Congratulations, you completed day!")
.foregroundStyle(.green)
} else {
Text("Complete a lesson every day to build your streak")
}
Spacer()
HStack {
Button("reset") {
streakManager.resetStreak()
}
Button("update streak") {
streakManager.updateStreak() // for update streak
}
}
}
.padding()
}
}
#Preview {
StreakView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment