Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ playground.xcworkspace

# Swift Package Manager
# Package.resolved kept for reproducible builds
.build/

# Carthage
Carthage/Build/
Expand Down
64 changes: 48 additions & 16 deletions Hana/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import AppKit

struct ContentView: View {
@Environment(HanaServices.self) private var services
@Environment(DisciplineModeStore.self) private var disciplineModeStore
@Environment(\.modelContext) private var modelContext
@Environment(\.scenePhase) private var scenePhase
@AppStorage(HanaSettingsKey.appearanceMode) private var appearanceMode = HanaAppearanceMode.system.rawValue
#if !os(macOS)
@AppStorage(HanaSettingsKey.themeColor) private var themeColor = HanaThemeColor.defaultValue
Expand All @@ -42,10 +44,43 @@ struct ContentView: View {
#endif

var body: some View {
let content = Group {
if disciplineModeStore.isLocked {
DisciplineModeLockScreen()
} else {
unlockedAppContent
}
}
.task {
await monitorDisciplineMode()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
disciplineModeStore.refresh()
}
}
.tint(appThemeColor)
.accentColor(appThemeColor)

#if os(macOS)
content
.onAppear {
applyMacOSAppearance()
}
.onChange(of: appearanceMode) { _, _ in
applyMacOSAppearance()
}
#else
content
.preferredColorScheme(HanaAppearanceMode(rawValue: appearanceMode)?.colorScheme)
#endif
}

private var unlockedAppContent: some View {
@Bindable var siteSession = services.siteSession
@Bindable var updateChecker = services.updateChecker

let appContent = rootContent
return rootContent
.sheet(item: $siteSession.activeFlow) { flow in
SiteWebSessionSheet(
flow: flow,
Expand All @@ -63,21 +98,6 @@ struct ContentView: View {
await services.updateChecker.checkAutomaticallyIfNeeded()
}
.hanaUpdateAlert(update: $updateChecker.availableUpdate)
.tint(appThemeColor)
.accentColor(appThemeColor)

#if os(macOS)
appContent
.onAppear {
applyMacOSAppearance()
}
.onChange(of: appearanceMode) { _, _ in
applyMacOSAppearance()
}
#else
appContent
.preferredColorScheme(HanaAppearanceMode(rawValue: appearanceMode)?.colorScheme)
#endif
}

#if os(macOS)
Expand Down Expand Up @@ -261,6 +281,17 @@ struct ContentView: View {
records: downloadQueue
)
}

private func monitorDisciplineMode() async {
while !Task.isCancelled {
disciplineModeStore.refresh()
do {
try await Task.sleep(for: .seconds(30))
} catch {
return
}
}
}
}

#if os(macOS)
Expand Down Expand Up @@ -405,6 +436,7 @@ private extension View {
#Preview {
ContentView()
.environment(HanaServices())
.environment(DisciplineModeStore())
.modelContainer(
for: [
WatchHistoryRecord.self,
Expand Down
128 changes: 128 additions & 0 deletions Hana/Features/DisciplineMode/Core/DisciplineModeConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation

public struct DisciplineModeConfiguration: Codable, Equatable, Sendable {
public var createdAt: Date
public var mode: DisciplineModeMode

public init(createdAt: Date = .now, mode: DisciplineModeMode) {
self.createdAt = createdAt
self.mode = mode
}
}

public enum DisciplineModeMode: Codable, Equatable, Sendable {
case single(until: Date)
case recurring(DisciplineModeRecurringConfiguration)
case weekly(DisciplineModeWeeklyConfiguration)
}

public struct DisciplineModeRecurringConfiguration: Codable, Equatable, Sendable {
public var cycleLengthDays: Int
public var allowedDayNumbers: Set<Int>

public init(cycleLengthDays: Int, allowedDayNumbers: Set<Int>) {
self.cycleLengthDays = cycleLengthDays
self.allowedDayNumbers = allowedDayNumbers
}

public var isValid: Bool {
cycleLengthDays >= 2
&& allowedDayNumbers.isEmpty == false
&& allowedDayNumbers.count < cycleLengthDays
&& allowedDayNumbers.allSatisfy { (1...cycleLengthDays).contains($0) }
}
}

public struct DisciplineModeWeeklyConfiguration: Codable, Equatable, Sendable {
public static let cycleLengthDays = 7

public var allowedWeekdayNumbers: Set<Int>

public init(allowedWeekdayNumbers: Set<Int>) {
self.allowedWeekdayNumbers = allowedWeekdayNumbers
}

public var isValid: Bool {
allowedWeekdayNumbers.isEmpty == false
&& allowedWeekdayNumbers.count < Self.cycleLengthDays
&& allowedWeekdayNumbers.allSatisfy { (1...Self.cycleLengthDays).contains($0) }
}
}

public struct DisciplineModeSingleDuration: Equatable, Sendable {
public var days: Int
public var hours: Int
public var minutes: Int

public init(days: Int, hours: Int, minutes: Int) {
self.days = days
self.hours = hours
self.minutes = minutes
}

public var totalSeconds: TimeInterval {
let clampedDays: Int = max(0, days)
let clampedHours: Int = max(0, hours)
let clampedMinutes: Int = max(0, minutes)
let daySeconds: Int = clampedDays * 24 * 60 * 60
let hourSeconds: Int = clampedHours * 60 * 60
let minuteSeconds: Int = clampedMinutes * 60
return TimeInterval(daySeconds + hourSeconds + minuteSeconds)
}

public var isValid: Bool {
days >= 0 && hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && totalSeconds > 0
}
}

public enum DisciplineModeAccess: Equatable, Sendable {
case unlocked
case locked
}

public enum DisciplineModeStateMessage: Equatable, Sendable {
case inactive
case invalidConfiguration
case active
}

public enum DisciplineModeScheduleKind: Equatable, Sendable {
case customCycle
case weekly
}

public struct DisciplineModeState: Equatable, Sendable {
public var access: DisciplineModeAccess
public var message: DisciplineModeStateMessage
public var scheduleKind: DisciplineModeScheduleKind?
public var currentCycleDay: Int?
public var cycleLengthDays: Int?
public var allowedDayNumbers: Set<Int>
public var nextAvailableAt: Date?
public var remainingInterval: TimeInterval?
public var allowsExit: Bool

public init(
access: DisciplineModeAccess,
message: DisciplineModeStateMessage,
scheduleKind: DisciplineModeScheduleKind? = nil,
currentCycleDay: Int? = nil,
cycleLengthDays: Int? = nil,
allowedDayNumbers: Set<Int> = [],
nextAvailableAt: Date? = nil,
remainingInterval: TimeInterval? = nil,
allowsExit: Bool = false
) {
self.access = access
self.message = message
self.scheduleKind = scheduleKind
self.currentCycleDay = currentCycleDay
self.cycleLengthDays = cycleLengthDays
self.allowedDayNumbers = allowedDayNumbers
self.nextAvailableAt = nextAvailableAt
self.remainingInterval = remainingInterval
self.allowsExit = allowsExit
}

public static let inactive = DisciplineModeState(access: .unlocked, message: .inactive)
}
Loading