ios-tracker/HabitTracker/Views/MonthCalendarView.swift
rene 22b8f5d806 Initiales HabitTracker-Projekt: SwiftUI + SwiftData Gewohnheiten-Tracker
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
2026-05-29 21:12:45 +02:00

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)
}
}