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:
rene 2026-05-29 21:12:45 +02:00
commit 22b8f5d806
24 changed files with 1448 additions and 0 deletions

View file

@ -0,0 +1,101 @@
import SwiftUI
import SwiftData
struct AddHabitView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var symbolName = "star.fill"
@State private var colorHex = "#34C759"
@State private var reminderTime: Date?
private let symbols = [
"star.fill", "drop.fill", "flame.fill", "book.fill", "dumbbell.fill",
"leaf.fill", "heart.fill", "moon.fill", "cup.and.saucer.fill", "figure.run"
]
private let colors = ["#34C759", "#007AFF", "#FF9500", "#FF2D55", "#AF52DE", "#5AC8FA"]
private let columns = [GridItem(.adaptive(minimum: 44), spacing: 12)]
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("z. B. Wasser trinken", text: $name)
}
Section("Symbol") {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(symbols, id: \.self) { symbol in
Image(systemName: symbol)
.font(.title2)
.frame(width: 44, height: 44)
.background(symbol == symbolName ? Color(hex: colorHex).opacity(0.2) : Color.clear)
.foregroundStyle(symbol == symbolName ? Color(hex: colorHex) : Color.secondary)
.clipShape(RoundedRectangle(cornerRadius: 10))
.onTapGesture { symbolName = symbol }
}
}
.padding(.vertical, 4)
}
Section("Farbe") {
HStack(spacing: 12) {
ForEach(colors, id: \.self) { hex in
Circle()
.fill(Color(hex: hex))
.frame(width: 32, height: 32)
.overlay {
if hex == colorHex {
Image(systemName: "checkmark")
.font(.caption.bold())
.foregroundStyle(.white)
}
}
.onTapGesture { colorHex = hex }
}
}
.padding(.vertical, 4)
}
Section("Erinnerung") {
ReminderEditor(reminderTime: $reminderTime)
}
}
.navigationTitle("Neue Gewohnheit")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern") { save() }
.disabled(trimmedName.isEmpty)
}
}
}
}
private var trimmedName: String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func save() {
let habit = Habit(
name: trimmedName,
symbolName: symbolName,
colorHex: colorHex,
reminderTime: reminderTime
)
context.insert(habit)
NotificationManager.reschedule(for: habit)
WidgetSync.refresh(context)
dismiss()
}
}
#Preview {
AddHabitView()
.modelContainer(for: [Habit.self, HabitEntry.self], inMemory: true)
}

View file

@ -0,0 +1,82 @@
import SwiftUI
import SwiftData
struct HabitDetailView: View {
@Environment(\.modelContext) private var context
@Bindable var habit: Habit
var body: some View {
ScrollView {
VStack(spacing: 24) {
header
HStack(spacing: 12) {
StatTile(value: "\(habit.currentStreak)", label: "Aktuell", systemImage: "flame.fill", color: color)
StatTile(value: "\(habit.longestStreak)", label: "Rekord", systemImage: "trophy.fill", color: color)
StatTile(value: "\(habit.totalCompletions)", label: "Gesamt", systemImage: "checkmark.seal.fill", color: color)
}
MonthCalendarView(habit: habit) { day in
context.toggleCompletion(for: habit, on: day)
}
.padding()
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
VStack(spacing: 12) {
ReminderEditor(reminderTime: $habit.reminderTime)
}
.padding()
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
.onChange(of: habit.reminderTime) {
NotificationManager.reschedule(for: habit)
}
}
.padding()
}
.navigationTitle(habit.name)
.navigationBarTitleDisplayMode(.inline)
}
private var color: Color { Color(hex: habit.colorHex) }
private var header: some View {
VStack(spacing: 8) {
Image(systemName: habit.symbolName)
.font(.system(size: 44))
.foregroundStyle(color)
.frame(width: 88, height: 88)
.background(color.opacity(0.15), in: Circle())
Text(habit.name)
.font(.title2.bold())
}
}
}
private struct StatTile: View {
let value: String
let label: String
let systemImage: String
let color: Color
var body: some View {
VStack(spacing: 6) {
Image(systemName: systemImage)
.foregroundStyle(color)
Text(value)
.font(.title3.bold())
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
}
}
#Preview {
NavigationStack {
HabitDetailView(habit: Habit(name: "Wasser trinken", symbolName: "drop.fill", colorHex: "#007AFF"))
}
.modelContainer(for: [Habit.self, HabitEntry.self], inMemory: true)
}

View file

@ -0,0 +1,69 @@
import SwiftUI
import SwiftData
struct HabitListView: View {
@Environment(\.modelContext) private var context
@Environment(\.scenePhase) private var scenePhase
@Query(sort: \Habit.createdAt) private var habits: [Habit]
@State private var showingAdd = false
var body: some View {
NavigationStack {
Group {
if habits.isEmpty {
ContentUnavailableView(
"Keine Gewohnheiten",
systemImage: "checklist",
description: Text("Tippe auf +, um deine erste Gewohnheit anzulegen.")
)
} else {
List {
ForEach(habits) { habit in
NavigationLink {
HabitDetailView(habit: habit)
} label: {
HabitRowView(habit: habit)
}
}
.onDelete(perform: delete)
}
}
}
.navigationTitle("Heute")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAdd = true
} label: {
Label("Hinzufügen", systemImage: "plus")
}
}
}
.sheet(isPresented: $showingAdd) {
AddHabitView()
}
}
.task {
WidgetSync.refresh(context)
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
WidgetSync.refresh(context)
}
}
}
private func delete(at offsets: IndexSet) {
for index in offsets {
let habit = habits[index]
NotificationManager.cancel(for: habit)
context.delete(habit)
}
WidgetSync.refresh(context)
}
}
#Preview {
HabitListView()
.modelContainer(for: [Habit.self, HabitEntry.self], inMemory: true)
}

View file

@ -0,0 +1,44 @@
import SwiftUI
import SwiftData
struct HabitRowView: View {
@Environment(\.modelContext) private var context
@Bindable var habit: Habit
var body: some View {
HStack(spacing: 12) {
Image(systemName: habit.symbolName)
.font(.title2)
.foregroundStyle(Color(hex: habit.colorHex))
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(habit.name)
.font(.headline)
if habit.currentStreak > 0 {
Text(streakText)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button {
context.toggleCompletion(for: habit, on: .now)
} label: {
Image(systemName: habit.isCompletedToday ? "checkmark.circle.fill" : "circle")
.font(.title)
.foregroundStyle(habit.isCompletedToday ? Color(hex: habit.colorHex) : Color.secondary)
}
.buttonStyle(.borderless)
}
.padding(.vertical, 4)
}
private var streakText: String {
let days = habit.currentStreak
let unit = days == 1 ? "Tag" : "Tage"
return "\(days) \(unit) Streak 🔥"
}
}

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

View file

@ -0,0 +1,38 @@
import SwiftUI
/// Toggle + time picker that drives an optional reminder time. Designed to sit
/// inside a Form Section or a VStack. Requests notification permission when the
/// user first switches the reminder on. Scheduling is the caller's job.
struct ReminderEditor: View {
@Binding var reminderTime: Date?
@State private var enabled: Bool
@State private var time: Date
init(reminderTime: Binding<Date?>) {
_reminderTime = reminderTime
_enabled = State(initialValue: reminderTime.wrappedValue != nil)
_time = State(initialValue: reminderTime.wrappedValue ?? NotificationManager.defaultTime)
}
var body: some View {
Group {
Toggle(isOn: $enabled) {
Label("Tägliche Erinnerung", systemImage: "bell.fill")
}
.onChange(of: enabled) { _, isOn in
reminderTime = isOn ? time : nil
if isOn {
Task { await NotificationManager.requestAuthorization() }
}
}
if enabled {
DatePicker("Uhrzeit", selection: $time, displayedComponents: .hourAndMinute)
.onChange(of: time) { _, newValue in
reminderTime = newValue
}
}
}
}
}