Natives iOS-App-Gerüst (Xcode 26, synchronisierte Ordner, iOS 18+). Features: - Gewohnheiten anlegen (Name, SF-Symbol, Farbe), heute abhaken, Streaks, Löschen - Detailansicht mit Monatskalender (Tage nachtragbar) und Statistiken - Tägliche Erinnerungen via lokale Notifications - Home-Screen-Widget (klein/mittel) mit App-Group-Datenaustausch
129 lines
4 KiB
Swift
129 lines
4 KiB
Swift
import SwiftUI
|
|
|
|
struct MonthCalendarView: View {
|
|
let habit: Habit
|
|
let onToggle: (Date) -> Void
|
|
|
|
@State private var monthStart = Calendar.german.startOfMonth(for: .now)
|
|
|
|
private let weekdaySymbols = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
|
private let columns = Array(repeating: GridItem(.flexible()), count: 7)
|
|
|
|
private var calendar: Calendar { .german }
|
|
private var today: Date { calendar.startOfDay(for: .now) }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
header
|
|
|
|
LazyVGrid(columns: columns, spacing: 8) {
|
|
ForEach(weekdaySymbols, id: \.self) { symbol in
|
|
Text(symbol)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
ForEach(Array(dayCells.enumerated()), id: \.offset) { _, date in
|
|
if let date {
|
|
dayCell(for: date)
|
|
} else {
|
|
Color.clear.frame(height: 36)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Button {
|
|
shiftMonth(by: -1)
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(monthTitle)
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
shiftMonth(by: 1)
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
}
|
|
.disabled(isCurrentMonthOrLater)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
|
|
private func dayCell(for date: Date) -> some View {
|
|
let isDone = habit.isCompleted(on: date, calendar: calendar)
|
|
let isToday = calendar.isDate(date, inSameDayAs: today)
|
|
let isFuture = date > today
|
|
let color = Color(hex: habit.colorHex)
|
|
|
|
return Text("\(calendar.component(.day, from: date))")
|
|
.font(.callout)
|
|
.frame(width: 36, height: 36)
|
|
.background {
|
|
if isDone {
|
|
Circle().fill(color)
|
|
} else if isToday {
|
|
Circle().strokeBorder(color, lineWidth: 1.5)
|
|
}
|
|
}
|
|
.foregroundStyle(isDone ? .white : (isFuture ? Color.secondary.opacity(0.4) : .primary))
|
|
.contentShape(Circle())
|
|
.onTapGesture {
|
|
if !isFuture { onToggle(date) }
|
|
}
|
|
}
|
|
|
|
private var monthTitle: String {
|
|
let formatter = DateFormatter()
|
|
formatter.calendar = calendar
|
|
formatter.locale = Locale(identifier: "de_DE")
|
|
formatter.dateFormat = "LLLL yyyy"
|
|
return formatter.string(from: monthStart)
|
|
}
|
|
|
|
/// Leading nil padding for the weekday offset, then one date per day in the month.
|
|
private var dayCells: [Date?] {
|
|
guard let range = calendar.range(of: .day, in: .month, for: monthStart) else { return [] }
|
|
let weekday = calendar.component(.weekday, from: monthStart)
|
|
let leadingBlanks = (weekday - calendar.firstWeekday + 7) % 7
|
|
|
|
var cells: [Date?] = Array(repeating: nil, count: leadingBlanks)
|
|
for day in range {
|
|
cells.append(calendar.date(byAdding: .day, value: day - 1, to: monthStart))
|
|
}
|
|
return cells
|
|
}
|
|
|
|
private var isCurrentMonthOrLater: Bool {
|
|
monthStart >= calendar.startOfMonth(for: .now)
|
|
}
|
|
|
|
private func shiftMonth(by value: Int) {
|
|
if let next = calendar.date(byAdding: .month, value: value, to: monthStart) {
|
|
monthStart = next
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Calendar {
|
|
/// Gregorian calendar with Monday as the first weekday (German convention).
|
|
static var german: Calendar {
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.firstWeekday = 2
|
|
return calendar
|
|
}
|
|
|
|
func startOfMonth(for date: Date) -> Date {
|
|
let components = dateComponents([.year, .month], from: date)
|
|
return self.date(from: components) ?? startOfDay(for: date)
|
|
}
|
|
}
|