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
This commit is contained in:
commit
22b8f5d806
24 changed files with 1448 additions and 0 deletions
129
HabitTracker/Views/MonthCalendarView.swift
Normal file
129
HabitTracker/Views/MonthCalendarView.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue