Initial commit: KisaniCal iOS app

Task management, Eisenhower matrix, workout logging with persistence,
and calendar with 6 view modes (Month, List, Year, Week, 3 Day, Day).
Custom floating tab bar, dynamic calendar icon, and workout data seeded.
This commit is contained in:
kutesir
2026-05-27 02:09:44 +03:00
commit f67429de0b
22 changed files with 6797 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
import SwiftUI
import UserNotifications
final class NotificationManager: NSObject, ObservableObject {
static let shared = NotificationManager()
@Published var isAuthorized = false
private let center = UNUserNotificationCenter.current()
private let workoutPrefix = "kisani.workout.weekday"
private let tasksId = "kisani.tasks.evening"
override private init() {
super.init()
center.delegate = self
refreshAuthStatus()
}
// MARK: - Permission
func requestPermission() {
center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in
DispatchQueue.main.async { self?.isAuthorized = granted }
}
}
private func refreshAuthStatus() {
center.getNotificationSettings { [weak self] settings in
DispatchQueue.main.async {
self?.isAuthorized = (settings.authorizationStatus == .authorized
|| settings.authorizationStatus == .provisional)
}
}
}
// MARK: - Reschedule (call on foreground + state changes)
func reschedule(workoutVM: WorkoutViewModel, taskVM: TaskViewModel) {
center.getNotificationSettings { [weak self] settings in
guard let self else { return }
let ok = settings.authorizationStatus == .authorized
|| settings.authorizationStatus == .provisional
guard ok else { return }
self.scheduleWorkout(workoutVM)
self.scheduleTasks(taskVM)
}
}
// MARK: - Workout (7:00 PM on each scheduled weekday)
private func scheduleWorkout(_ workoutVM: WorkoutViewModel) {
let existingIds = (1...7).map { "\(workoutPrefix).\($0)" }
center.removePendingNotificationRequests(withIdentifiers: existingIds)
let todayWeekday = Calendar.current.component(.weekday, from: Date())
for (weekday, pid) in workoutVM.schedule {
guard let program = workoutVM.programs.first(where: { $0.id == pid }) else { continue }
// Today's workout is already done skip so notification won't fire this evening
if weekday == todayWeekday && workoutVM.progress >= 1.0 { continue }
let content = UNMutableNotificationContent()
content.title = "\(program.emoji) Workout check-in"
content.body = "Did you finish your \(program.name)? Mark your sets complete."
content.sound = .default
content.userInfo = ["deeplink": "workout"]
var comps = DateComponents()
comps.weekday = weekday
comps.hour = 19
comps.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
let request = UNNotificationRequest(
identifier: "\(workoutPrefix).\(weekday)",
content: content,
trigger: trigger
)
center.add(request)
}
}
// MARK: - Urgent tasks (7:30 PM daily, only when there are open urgent tasks)
private func scheduleTasks(_ taskVM: TaskViewModel) {
center.removePendingNotificationRequests(withIdentifiers: [tasksId])
let count = taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count
guard count > 0 else { return }
let content = UNMutableNotificationContent()
content.title = "Evening task check-in"
content.body = "\(count) urgent task\(count == 1 ? "" : "s") still open — mark done or reschedule."
content.sound = .default
content.userInfo = ["deeplink": "tasks"]
var comps = DateComponents()
comps.hour = 19
comps.minute = 30
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
let request = UNNotificationRequest(identifier: tasksId, content: content, trigger: trigger)
center.add(request)
}
}
// MARK: - UNUserNotificationCenterDelegate
extension NotificationManager: UNUserNotificationCenterDelegate {
// Show banners even when app is foregrounded
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
completionHandler()
}
}