diff --git a/CODEX_REPORT.md b/CODEX_REPORT.md
new file mode 100644
index 0000000..43ed92c
--- /dev/null
+++ b/CODEX_REPORT.md
@@ -0,0 +1,54 @@
+# Codex Report
+
+## Summary
+- Implemented Apple Calendar–style month + list sync with vertical month paging, unified German locale + Monday-first calendar config, and improved year view behavior.
+- Fixed multiple build warnings/errors related to Swift 6 actor isolation and AVAsset deprecation.
+- Added dynamic year title that updates based on which year occupies most of the year view.
+
+## Key Behavior Changes
+- Month view: vertical paging, large top-left month header (Month + Year), list syncs with calendar selection.
+- Year view: top-left large year title follows scroll position; scrolls to selected year on entry.
+- “Heute” button added to jump to today.
+- Calendar list now scrolls across all entries (not just current month) to enable list-mode feel.
+
+## Major File Changes
+- `videorem/ContentView.swift`
+ - Reworked month view to vertical paging `ScrollView` with `.scrollTargetBehavior(.paging)` and `scrollPosition`.
+ - Added `monthScrollID`, `yearTitle` tracking, `YearCenterPreferenceKey`, and year title UI logic.
+ - Added `setupMonthPages(focusDate:)` and ensured month ranges include selected month/year.
+ - Fixed month view not snapping back to today when coming from year view.
+ - Adjusted year view to use `viewModel.selectedDate` for scroll anchoring and title.
+- `videorem/CalendarConfig.swift` (new)
+ - Centralized locale/calendar config (`de_DE`, Monday-first) and formatters.
+- `videorem/Date+Extensions.swift`
+ - Switched to shared formatters from `CalendarConfig`.
+- `videorem/CalendarViewModel.swift`
+ - Uses `CalendarConfig.calendar` and starts `selectedDate` at start of month.
+- `videorem/MonthCalendarView.swift`
+ - Accepts `monthDate` and `selectedDay` for correct highlighting across months.
+- `videorem/MonthDayCell.swift`, `videorem/YearMonthCell.swift`, `videorem/YearCalendarView.swift`, `videorem/VideoListView.swift`, `videorem/HomeView.swift`
+ - Unified calendar usage; bumped typography sizes for legibility.
+
+## Build Fixes
+- `videorem/VideoListRow.swift`
+ - Replaced `AVAsset(url:)` with `AVURLAsset(url:)`.
+- `videorem/VideoRecorderView.swift`
+ - Marked `VideoRecorder` as `@MainActor`.
+ - `captureSession` is `nonisolated`, start running on a dedicated `sessionQueue`.
+ - Timer updates `currentDuration` on main actor; captured `recordingStartTime` outside Sendable closure.
+
+## Open Issues / Known Follow-Ups
+- Confirm year view title updates correctly while scrolling (uses geometry preference).
+- Verify month list + calendar sync feels right for months with no entries (current list only shows dates with entries).
+- Potential UI polish: sticky year headers, event list density like Apple Calendar.
+
+## Commands Run
+- `rg --files`
+- `rg -n ...`
+- `sed -n '...' ...`
+- `xcodebuild` failed due to CoreSimulator/DerivedData permission issues in sandbox.
+
+## Notes for Next Session
+- If year view still misaligns, check `YearCenterPreferenceKey` usage and `coordinateSpace("yearScroll")` in `ContentView`.
+- If month view still jumps to today, check `setupMonthPages(focusDate:)` and ensure `viewModel.selectedDate` is set before switching modes.
+- For builds, run locally with proper permissions or rerun `xcodebuild` with escalated privileges.
diff --git a/videorem.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/videorem.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..63f4177
--- /dev/null
+++ b/videorem.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/videorem.xcodeproj/xcuserdata/felixfoertsch.xcuserdatad/xcschemes/xcschememanagement.plist b/videorem.xcodeproj/xcuserdata/felixfoertsch.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 0000000..b462ec9
--- /dev/null
+++ b/videorem.xcodeproj/xcuserdata/felixfoertsch.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ SchemeUserState
+
+ videorem.xcscheme_^#shared#^_
+
+ orderHint
+ 0
+
+
+
+
diff --git a/videorem/BUILD_FIXES.md b/videorem/BUILD_FIXES.md
new file mode 100644
index 0000000..9d129e0
--- /dev/null
+++ b/videorem/BUILD_FIXES.md
@@ -0,0 +1,41 @@
+# Build Fixes Applied
+
+## Issues Fixed:
+
+### 1. Missing Imports
+- ✅ Added `import Combine` to VideoRecorderView.swift
+- ✅ Added `import SwiftData` to VideoRecorderView.swift
+
+### 2. ContentView Updated
+- ✅ Replaced `Item` with `VideoEntry` model
+- ✅ Updated `@Query` to use correct model
+- ✅ Fixed preview to use `VideoEntry.self`
+
+### 3. ObservableObject Conformance
+- ✅ Removed `@MainActor` from class declaration
+- ✅ Added `@MainActor` to individual UI-related methods
+- ✅ Made `setupCamera()` async to work with concurrency
+
+### 4. Thread Safety
+- ✅ All UI-updating methods now properly use `@MainActor`
+- ✅ AVCaptureSession operations run on background thread
+- ✅ Delegate callbacks properly dispatch to main thread
+
+## Files Modified:
+1. **ContentView.swift** - Completely rewritten for video journal interface
+2. **VideoRecorderView.swift** - Fixed concurrency and import issues
+3. **Item.swift** - Renamed to VideoEntry model
+4. **videoremApp.swift** - Updated model container
+
+## Next Steps:
+1. Add camera and microphone permissions to Info.plist
+2. Build and test the app
+3. Record your first video!
+
+## Required Info.plist Entries:
+```xml
+NSCameraUsageDescription
+We need access to your camera to record video journal entries.
+NSMicrophoneUsageDescription
+We need access to your microphone to record audio for your video journals.
+```
diff --git a/videorem/CalendarConfig.swift b/videorem/CalendarConfig.swift
new file mode 100644
index 0000000..87bb81a
--- /dev/null
+++ b/videorem/CalendarConfig.swift
@@ -0,0 +1,64 @@
+//
+// CalendarConfig.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import Foundation
+
+// MARK: - Calendar Config
+/// Shared calendar and formatters for consistent locale + weekday rules.
+enum CalendarConfig {
+ static let locale = Locale(identifier: "de_DE")
+ static let calendar: Calendar = {
+ var cal = Calendar(identifier: .gregorian)
+ cal.locale = locale
+ cal.firstWeekday = 2 // Monday
+ cal.minimumDaysInFirstWeek = 4
+ return cal
+ }()
+
+ static let monthYearFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = locale
+ formatter.dateFormat = "MMMM yyyy"
+ return formatter
+ }()
+
+ static let monthFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = locale
+ formatter.dateFormat = "MMMM"
+ return formatter
+ }()
+
+ static let yearFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = locale
+ formatter.dateFormat = "yyyy"
+ return formatter
+ }()
+
+ static let dateHeaderFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = locale
+ formatter.dateStyle = .long
+ return formatter
+ }()
+
+ static let timeFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = locale
+ formatter.dateFormat = "HH:mm"
+ return formatter
+ }()
+}
+
+// MARK: - Calendar Helpers
+extension Calendar {
+ func startOfMonth(for date: Date) -> Date {
+ let components = dateComponents([.year, .month], from: date)
+ return self.date(from: components) ?? date
+ }
+}
diff --git a/videorem/CalendarHeaderView.swift b/videorem/CalendarHeaderView.swift
new file mode 100644
index 0000000..fe37d16
--- /dev/null
+++ b/videorem/CalendarHeaderView.swift
@@ -0,0 +1,46 @@
+//
+// CalendarHeaderView.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+
+// MARK: - Calendar Header View
+/// The navigation header for the calendar showing month/year and navigation buttons.
+struct CalendarHeaderView: View {
+ let headerText: String
+ let onPrevious: () -> Void
+ let onHeaderTap: () -> Void
+ let onNext: () -> Void
+
+ var body: some View {
+ HStack {
+ Button(action: onPrevious) {
+ Image(systemName: "chevron.left")
+ .font(.title3)
+ .foregroundStyle(.blue)
+ }
+ .padding(.leading)
+
+ Spacer()
+
+ Button(action: onHeaderTap) {
+ Text(headerText)
+ .font(.title3.bold())
+ .foregroundStyle(.primary)
+ }
+
+ Spacer()
+
+ Button(action: onNext) {
+ Image(systemName: "chevron.right")
+ .font(.title3)
+ .foregroundStyle(.blue)
+ }
+ .padding(.trailing)
+ }
+ .padding(.vertical, 12)
+ }
+}
diff --git a/videorem/CalendarViewModel.swift b/videorem/CalendarViewModel.swift
new file mode 100644
index 0000000..9e64488
--- /dev/null
+++ b/videorem/CalendarViewModel.swift
@@ -0,0 +1,130 @@
+//
+// CalendarViewModel.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import Foundation
+import SwiftUI
+
+// MARK: - Calendar View Mode
+enum CalendarViewMode {
+ case month, year
+}
+
+// MARK: - Calendar View Model
+@Observable
+final class CalendarViewModel {
+ var selectedDate = CalendarConfig.calendar.startOfMonth(for: Date())
+ var calendarViewMode: CalendarViewMode = .month
+
+ // Use a stored property instead of computed to ensure consistency
+ private let calendar: Calendar = CalendarConfig.calendar
+
+ // MARK: - Computed Properties
+
+ var headerText: String {
+ switch calendarViewMode {
+ case .month:
+ return selectedDate.monthYearString
+ case .year:
+ return selectedDate.yearString
+ }
+ }
+
+ var monthYearText: String {
+ selectedDate.monthYearString
+ }
+
+ var monthOnlyText: String {
+ CalendarConfig.monthFormatter.string(from: selectedDate)
+ }
+
+ var yearText: String {
+ selectedDate.yearString
+ }
+
+ // MARK: - Navigation
+
+ func previousPeriod() {
+ let value = calendarViewMode == .month ? -1 : -1
+ let component: Calendar.Component = calendarViewMode == .month ? .month : .year
+
+ if let newDate = calendar.date(byAdding: component, value: value, to: selectedDate) {
+ selectedDate = newDate
+ }
+ }
+
+ func nextPeriod() {
+ let value = 1
+ let component: Calendar.Component = calendarViewMode == .month ? .month : .year
+
+ if let newDate = calendar.date(byAdding: component, value: value, to: selectedDate) {
+ selectedDate = newDate
+ }
+ }
+
+ func toggleViewMode() {
+ if calendarViewMode == .month {
+ calendarViewMode = .year
+ } else {
+ selectedDate = calendar.startOfMonth(for: Date())
+ calendarViewMode = .month
+ }
+ }
+
+ func selectMonth(_ month: Int) {
+ var components = calendar.dateComponents([.year], from: selectedDate)
+ components.month = month
+ components.day = 1
+
+ if let newDate = calendar.date(from: components) {
+ selectedDate = newDate
+ calendarViewMode = .month
+ }
+ }
+
+ // MARK: - Calendar Helpers
+
+ func daysInMonth(for date: Date) -> [Date?] {
+ guard let monthInterval = calendar.dateInterval(of: .month, for: date),
+ let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else {
+ return []
+ }
+
+ var days: [Date?] = []
+ var currentDate = monthFirstWeek.start
+
+ while days.count < 42 {
+ if calendar.isDate(currentDate, equalTo: date, toGranularity: .month) {
+ days.append(currentDate)
+ } else {
+ days.append(nil)
+ }
+ currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
+ }
+
+ return days
+ }
+
+ func isCurrentMonth(_ month: Int, in year: Int) -> Bool {
+ let now = Date()
+ let currentMonth = calendar.component(.month, from: now)
+ let currentYear = calendar.component(.year, from: now)
+
+ return month == currentMonth && year == currentYear
+ }
+
+ func groupEntries(_ entries: [VideoEntry]) -> [Date: [VideoEntry]] {
+ Dictionary(grouping: entries) { entry in
+ calendar.startOfDay(for: entry.date)
+ }
+ }
+
+ func entryFor(date: Date, in entries: [VideoEntry]) -> VideoEntry? {
+ entries.first { entry in
+ calendar.isDate(entry.date, inSameDayAs: date)
+ }
+ }
+}
diff --git a/videorem/ContentView.swift b/videorem/ContentView.swift
index d026fe2..3389f60 100644
--- a/videorem/ContentView.swift
+++ b/videorem/ContentView.swift
@@ -8,54 +8,368 @@
import SwiftUI
import SwiftData
+// MARK: - Content View
+/// The main view of the app, embedding the calendar interface.
struct ContentView: View {
- @Environment(\.modelContext) private var modelContext
- @Query private var items: [Item]
-
var body: some View {
- NavigationSplitView {
- List {
- ForEach(items) { item in
- NavigationLink {
- Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
- } label: {
- Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
- }
- }
- .onDelete(perform: deleteItems)
- }
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- EditButton()
- }
- ToolbarItem {
- Button(action: addItem) {
- Label("Add Item", systemImage: "plus")
- }
- }
- }
- } detail: {
- Text("Select an item")
- }
- }
-
- private func addItem() {
- withAnimation {
- let newItem = Item(timestamp: Date())
- modelContext.insert(newItem)
- }
- }
-
- private func deleteItems(offsets: IndexSet) {
- withAnimation {
- for index in offsets {
- modelContext.delete(items[index])
- }
- }
+ CalendarView()
}
}
-#Preview {
- ContentView()
- .modelContainer(for: Item.self, inMemory: true)
+// MARK: - Calendar View
+/// Main calendar view with month/year toggle and video list integration.
+struct CalendarView: View {
+ @Environment(\.modelContext) private var modelContext
+ @Query(sort: \VideoEntry.date, order: .reverse) private var videoEntries: [VideoEntry]
+
+ @State private var viewModel = CalendarViewModel()
+ @State private var selectedEntry: VideoEntry?
+ @State private var showingRecorder = false
+ @State private var monthPageDates: [Date] = []
+ @State private var selectedDay: Date = Date()
+ @State private var listTargetDate: Date?
+ @State private var isProgrammaticListScroll = false
+ @State private var monthScrollID: Date?
+ @State private var yearTitle: Int?
+
+ private let calendar = CalendarConfig.calendar
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if viewModel.calendarViewMode == .year {
+ yearView
+ } else {
+ monthView
+ }
+ }
+ .fullScreenCover(item: $selectedEntry) { entry in
+ VideoPlayerView(entry: entry)
+ }
+ .fullScreenCover(isPresented: $showingRecorder) {
+ VideoRecorderView()
+ }
+ .onAppear {
+ let today = Date()
+ setupMonthPages(focusDate: today)
+ selectedDay = calendar.startOfDay(for: today)
+ listTargetDate = closestEntryDate(for: selectedDay)
+ monthScrollID = viewModel.selectedDate
+ }
+ .onChange(of: videoEntries.count) { _, _ in
+ setupMonthPages(focusDate: viewModel.selectedDate)
+ monthScrollID = viewModel.selectedDate
+ }
+ }
+ }
+
+ private func setupMonthPages(focusDate: Date) {
+ let minEntryDate = videoEntries.min(by: { $0.date < $1.date })?.date
+ let maxEntryDate = videoEntries.max(by: { $0.date < $1.date })?.date
+
+ let minBase = [minEntryDate, focusDate].compactMap { $0 }.min() ?? focusDate
+ let maxBase = [maxEntryDate, focusDate].compactMap { $0 }.max() ?? focusDate
+
+ let start = calendar.startOfMonth(for: minBase)
+ let end = calendar.startOfMonth(for: maxBase)
+
+ let paddedStart = calendar.date(byAdding: .month, value: -12, to: start) ?? start
+ let paddedEnd = calendar.date(byAdding: .month, value: 12, to: end) ?? end
+
+ var dates: [Date] = []
+ var current = paddedStart
+ while current <= paddedEnd {
+ dates.append(current)
+ current = calendar.date(byAdding: .month, value: 1, to: current) ?? current
+ }
+ monthPageDates = dates
+
+ let currentMonth = calendar.startOfMonth(for: viewModel.selectedDate)
+ if !monthPageDates.contains(currentMonth) {
+ viewModel.selectedDate = calendar.startOfMonth(for: focusDate)
+ }
+ }
+
+ // MARK: - Year View
+
+ private var yearView: some View {
+ ScrollViewReader { proxy in
+ GeometryReader { container in
+ VStack(spacing: 0) {
+ HStack {
+ Text(yearTitleText)
+ .font(.largeTitle.bold())
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ .padding(.bottom, 8)
+
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ // Show 10 years before and after current year
+ let currentYear = calendar.component(.year, from: viewModel.selectedDate)
+ ForEach((currentYear - 10)...(currentYear + 10), id: \.self) { year in
+ VStack(spacing: 0) {
+ // Year title
+ HStack {
+ Text(String(format: "%d", year))
+ .font(.title2.bold())
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 16)
+ .padding(.top, year == currentYear - 10 ? 12 : 24)
+ .padding(.bottom, 12)
+ .background(Color(.systemGroupedBackground))
+
+ // 12 months grid for this year
+ LazyVGrid(columns: [
+ GridItem(.flexible(), spacing: 8),
+ GridItem(.flexible(), spacing: 8),
+ GridItem(.flexible(), spacing: 8)
+ ], spacing: 20) {
+ ForEach(1...12, id: \.self) { month in
+ YearMonthCell(
+ year: year,
+ month: month,
+ videoEntries: videoEntries,
+ isCurrentMonth: viewModel.isCurrentMonth(month, in: year)
+ )
+ .onTapGesture {
+ var components = DateComponents()
+ components.year = year
+ components.month = month
+ components.day = 1
+ if let newDate = calendar.date(from: components) {
+ setupMonthPages(focusDate: newDate)
+ let today = Date()
+ if calendar.isDate(today, equalTo: newDate, toGranularity: .month) {
+ selectedDay = calendar.startOfDay(for: today)
+ } else {
+ selectedDay = calendar.startOfDay(for: newDate)
+ }
+ viewModel.selectedDate = newDate
+ withAnimation {
+ viewModel.calendarViewMode = .month
+ }
+ listTargetDate = closestEntryDate(for: selectedDay)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ Divider()
+ .padding(.top, 16)
+ }
+ .id(year)
+ .background(
+ GeometryReader { geo in
+ Color.clear.preference(
+ key: YearCenterPreferenceKey.self,
+ value: [year: geo.frame(in: .named("yearScroll")).midY]
+ )
+ }
+ )
+ }
+ }
+ }
+ .coordinateSpace(name: "yearScroll")
+ .background(Color(.systemBackground))
+ .onPreferenceChange(YearCenterPreferenceKey.self) { centers in
+ let centerY = container.size.height / 2
+ if let best = centers.min(by: { abs($0.value - centerY) < abs($1.value - centerY) }) {
+ if yearTitle != best.key {
+ yearTitle = best.key
+ }
+ }
+ }
+ }
+ .onAppear {
+ let currentYear = calendar.component(.year, from: viewModel.selectedDate)
+ yearTitle = currentYear
+ DispatchQueue.main.async {
+ proxy.scrollTo(currentYear, anchor: .top)
+ }
+ }
+ .onChange(of: viewModel.selectedDate) { _, newValue in
+ let currentYear = calendar.component(.year, from: newValue)
+ yearTitle = currentYear
+ withAnimation(.easeInOut(duration: 0.25)) {
+ proxy.scrollTo(currentYear, anchor: .top)
+ }
+ }
+ }
+ }
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showingRecorder = true
+ } label: {
+ Image(systemName: "plus")
+ .font(.body.weight(.semibold))
+ }
+ }
+ }
+ }
+
+ // MARK: - Month View
+
+ private var monthView: some View {
+ VStack(spacing: 0) {
+ // Month header (Apple Calendar style)
+ HStack(alignment: .lastTextBaseline, spacing: 8) {
+ Text(viewModel.monthOnlyText)
+ .font(.largeTitle.bold())
+ Text(viewModel.yearText)
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 6)
+ .padding(.bottom, 8)
+
+ ScrollView(.vertical) {
+ LazyVStack(spacing: 0) {
+ ForEach(monthPageDates, id: \.self) { monthDate in
+ MonthCalendarView(
+ viewModel: viewModel,
+ videoEntries: videoEntries,
+ monthDate: monthDate,
+ selectedDay: selectedDay,
+ onDateTap: { date in
+ selectedDay = calendar.startOfDay(for: date)
+ listTargetDate = closestEntryDate(for: selectedDay)
+ }
+ )
+ .frame(height: 320)
+ .id(monthDate)
+ .background(Color(.systemGroupedBackground))
+ }
+ }
+ .scrollTargetLayout()
+ }
+ .scrollIndicators(.hidden)
+ .scrollTargetBehavior(.paging)
+ .scrollPosition(id: $monthScrollID)
+ .frame(height: 320)
+ .onChange(of: monthScrollID) { _, newValue in
+ guard let newValue else { return }
+ let start = calendar.startOfMonth(for: newValue)
+ if !calendar.isDate(start, equalTo: viewModel.selectedDate, toGranularity: .month) {
+ viewModel.selectedDate = start
+ }
+ if !calendar.isDate(start, equalTo: selectedDay, toGranularity: .month) {
+ selectedDay = start
+ listTargetDate = closestEntryDate(for: start)
+ }
+ }
+ .onChange(of: viewModel.selectedDate) { _, newValue in
+ let start = calendar.startOfMonth(for: newValue)
+ if !monthPageDates.contains(start) {
+ setupMonthPages(focusDate: start)
+ }
+ monthScrollID = start
+ if !calendar.isDate(start, equalTo: selectedDay, toGranularity: .month) {
+ selectedDay = start
+ listTargetDate = closestEntryDate(for: start)
+ }
+ }
+
+ Divider()
+
+ VideoListView(
+ viewModel: viewModel,
+ videoEntries: videoEntries,
+ selectedDay: $selectedDay,
+ listTargetDate: $listTargetDate,
+ isProgrammaticScroll: $isProgrammaticListScroll,
+ onVideoTap: { entry in
+ selectedEntry = entry
+ },
+ onRecordTap: {
+ showingRecorder = true
+ }
+ )
+ }
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarBackButtonHidden(true)
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button {
+ withAnimation {
+ viewModel.calendarViewMode = .year
+ }
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: "chevron.left")
+ .font(.body.weight(.semibold))
+ Text(viewModel.yearText)
+ .font(.body)
+ }
+ }
+ }
+
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showingRecorder = true
+ } label: {
+ Image(systemName: "plus")
+ .font(.body.weight(.semibold))
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("Heute") {
+ let today = Date()
+ let monthStart = calendar.startOfMonth(for: today)
+ viewModel.selectedDate = monthStart
+ selectedDay = calendar.startOfDay(for: today)
+ listTargetDate = closestEntryDate(for: selectedDay)
+ }
+ }
+ }
+ }
+
+ // MARK: - Computed Properties
+
+ /// Filters video entries for the selected month in month view
+ private func closestEntryDate(for targetDay: Date) -> Date? {
+ let allDays = videoEntries.map { calendar.startOfDay(for: $0.date) }
+ guard !allDays.isEmpty else { return nil }
+
+ let monthDays = allDays.filter { day in
+ calendar.isDate(day, equalTo: targetDay, toGranularity: .month)
+ }
+
+ let candidateDays = monthDays.isEmpty ? allDays : monthDays
+
+ if let exact = candidateDays.first(where: { calendar.isDate($0, inSameDayAs: targetDay) }) {
+ return exact
+ }
+ return candidateDays.min(by: { abs($0.timeIntervalSince(targetDay)) < abs($1.timeIntervalSince(targetDay)) })
+ }
+
+ private var yearTitleText: String {
+ String(yearTitle ?? calendar.component(.year, from: viewModel.selectedDate))
+ }
+}
+
+// MARK: - Safe Indexing
+extension Collection {
+ subscript(safe index: Index) -> Element? {
+ indices.contains(index) ? self[index] : nil
+ }
+}
+
+// MARK: - Year Title Preference
+private struct YearCenterPreferenceKey: PreferenceKey {
+ static var defaultValue: [Int: CGFloat] = [:]
+
+ static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
+ value.merge(nextValue(), uniquingKeysWith: { $1 })
+ }
}
diff --git a/videorem/Date+Extensions.swift b/videorem/Date+Extensions.swift
new file mode 100644
index 0000000..9c28de1
--- /dev/null
+++ b/videorem/Date+Extensions.swift
@@ -0,0 +1,42 @@
+//
+// Date+Extensions.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import Foundation
+
+// MARK: - Date Extensions
+extension Date {
+ /// Returns the month and year as a formatted string (e.g., "Februar 2026")
+ var monthYearString: String {
+ CalendarConfig.monthYearFormatter.string(from: self)
+ }
+
+ /// Returns the year as a string (e.g., "2026")
+ var yearString: String {
+ CalendarConfig.yearFormatter.string(from: self)
+ }
+
+ /// Returns the month name (e.g., "Februar")
+ var monthName: String {
+ CalendarConfig.monthFormatter.string(from: self)
+ }
+
+ /// Returns a formatted header text for the video list sections
+ func headerText(calendar: Calendar) -> String {
+ if calendar.isDateInToday(self) {
+ return "Heute"
+ } else if calendar.isDateInYesterday(self) {
+ return "Gestern"
+ } else {
+ return CalendarConfig.dateHeaderFormatter.string(from: self)
+ }
+ }
+
+ /// Returns the time as a string (e.g., "14:30")
+ var timeString: String {
+ CalendarConfig.timeFormatter.string(from: self)
+ }
+}
diff --git a/videorem/HomeView.swift b/videorem/HomeView.swift
new file mode 100644
index 0000000..03724bb
--- /dev/null
+++ b/videorem/HomeView.swift
@@ -0,0 +1,136 @@
+//
+// HomeView.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+import SwiftData
+
+struct HomeView: View {
+ @Environment(\.modelContext) private var modelContext
+ @Query(sort: \VideoEntry.date, order: .reverse) private var videoEntries: [VideoEntry]
+
+ @State private var showingRecorder = false
+ @State private var selectedEntry: VideoEntry?
+
+ var body: some View {
+ NavigationStack {
+ ZStack {
+ // Background gradient
+ LinearGradient(
+ colors: [.blue.opacity(0.1), .purple.opacity(0.1)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ .ignoresSafeArea()
+
+ VStack(spacing: 30) {
+ // Header
+ VStack(spacing: 10) {
+ Image(systemName: "video.circle.fill")
+ .font(.system(size: 80))
+ .foregroundStyle(.blue)
+
+ Text("Daily Video Journal")
+ .font(.largeTitle.bold())
+
+ Text("Ein Moment für deine Lieben")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding(.top, 60)
+
+ Spacer()
+
+ // Today's status
+ todayStatusCard
+
+ // Record button
+ Button(action: { showingRecorder = true }) {
+ HStack {
+ Image(systemName: "video.fill")
+ .font(.title2)
+ Text(hasRecordedToday ? "Nochmal aufnehmen" : "Heute aufnehmen")
+ .font(.title3.bold())
+ }
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(.blue, in: RoundedRectangle(cornerRadius: 15))
+ .shadow(color: .blue.opacity(0.3), radius: 10, y: 5)
+ }
+ .padding(.horizontal, 30)
+
+ // Video list
+ if !videoEntries.isEmpty {
+ VStack(alignment: .leading, spacing: 15) {
+ Text("Deine Videos (\(videoEntries.count))")
+ .font(.headline)
+ .padding(.horizontal, 30)
+
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(videoEntries) { entry in
+ VideoListRow(entry: entry)
+ .onTapGesture {
+ selectedEntry = entry
+ }
+ .padding(.horizontal, 30)
+ }
+ }
+ }
+ .frame(maxHeight: 200)
+ }
+ }
+
+ Spacer()
+ }
+ }
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ .fullScreenCover(isPresented: $showingRecorder) {
+ VideoRecorderView()
+ }
+ .fullScreenCover(item: $selectedEntry) { entry in
+ VideoPlayerView(entry: entry)
+ }
+ }
+ }
+
+ private var todayStatusCard: some View {
+ HStack {
+ Image(systemName: hasRecordedToday ? "checkmark.circle.fill" : "circle")
+ .font(.title)
+ .foregroundStyle(hasRecordedToday ? .green : .secondary)
+
+ VStack(alignment: .leading) {
+ Text("Heute")
+ .font(.headline)
+ Text(hasRecordedToday ? "Video aufgenommen ✓" : "Noch kein Video")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Text(Date.now, style: .date)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding()
+ .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 15))
+ .padding(.horizontal, 30)
+ }
+
+ private var hasRecordedToday: Bool {
+ let calendar = CalendarConfig.calendar
+ let today = calendar.startOfDay(for: Date())
+
+ return videoEntries.contains { entry in
+ calendar.isDate(entry.date, inSameDayAs: today)
+ }
+ }
+}
diff --git a/videorem/Info.plist b/videorem/Info.plist
index ca9a074..75512d1 100644
--- a/videorem/Info.plist
+++ b/videorem/Info.plist
@@ -2,6 +2,10 @@
+ NSCameraUsageDescription
+ "We need access to your camera to record video journal entries."
+ NSMicrophoneUsageDescription
+ "We need access to your microphone to record audio for your video journals."
UIBackgroundModes
remote-notification
diff --git a/videorem/Item.swift b/videorem/Item.swift
index 0bd800b..d7dbec6 100644
--- a/videorem/Item.swift
+++ b/videorem/Item.swift
@@ -1,5 +1,5 @@
//
-// Item.swift
+// VideoEntry.swift (formerly Item.swift)
// videorem
//
// Created by Felix Förtsch on 07.02.26.
@@ -8,11 +8,30 @@
import Foundation
import SwiftData
+// MARK: - Video Entry Model
+
@Model
-final class Item {
- var timestamp: Date
+final class VideoEntry {
+ var date: Date
+ var videoFileName: String
+ var duration: TimeInterval
+ var thumbnailFileName: String?
- init(timestamp: Date) {
- self.timestamp = timestamp
+ init(date: Date, videoFileName: String, duration: TimeInterval, thumbnailFileName: String? = nil) {
+ self.date = date
+ self.videoFileName = videoFileName
+ self.duration = duration
+ self.thumbnailFileName = thumbnailFileName
+ }
+
+ /// Returns the full URL for the video file
+ var videoURL: URL? {
+ VideoFileManager.shared.videoURL(for: videoFileName)
+ }
+
+ /// Returns the full URL for the thumbnail file
+ var thumbnailURL: URL? {
+ guard let thumbnailFileName else { return nil }
+ return VideoFileManager.shared.thumbnailURL(for: thumbnailFileName)
}
}
diff --git a/videorem/MonthCalendarView.swift b/videorem/MonthCalendarView.swift
new file mode 100644
index 0000000..62da70d
--- /dev/null
+++ b/videorem/MonthCalendarView.swift
@@ -0,0 +1,69 @@
+//
+// MonthCalendarView.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+
+// MARK: - Shared Calendar
+/// Provides a consistent calendar instance with Monday as the first weekday
+// Note: CalendarConfig lives in CalendarConfig.swift
+
+// MARK: - Month Calendar View
+/// The calendar grid displaying days for the selected month.
+struct MonthCalendarView: View {
+ let viewModel: CalendarViewModel
+ let videoEntries: [VideoEntry]
+ let monthDate: Date
+ let selectedDay: Date
+ let onDateTap: (Date) -> Void
+
+ private let calendar = CalendarConfig.calendar
+
+ var body: some View {
+ VStack(spacing: 6) {
+ // Weekday headers
+ HStack(spacing: 0) {
+ ForEach(0..<7, id: \.self) { index in
+ // Adjust index to start from Monday (index 1 in standard calendar)
+ let adjustedIndex = (index + calendar.firstWeekday - 1) % 7
+ Text(calendar.veryShortWeekdaySymbols[adjustedIndex])
+ .font(.caption.bold())
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity)
+ .lineLimit(1)
+ .minimumScaleFactor(0.7)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.top, 8)
+
+ // Days grid
+ let columns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7)
+ let days = viewModel.daysInMonth(for: monthDate)
+
+ LazyVGrid(columns: columns, spacing: 2) {
+ ForEach(Array(days.enumerated()), id: \.offset) { index, date in
+ if let date = date {
+ MonthDayCell(
+ date: date,
+ entry: viewModel.entryFor(date: date, in: videoEntries),
+ isSelected: calendar.isDate(date, inSameDayAs: selectedDay),
+ isToday: calendar.isDateInToday(date)
+ )
+ .onTapGesture {
+ onDateTap(date)
+ }
+ } else {
+ Color.clear
+ .frame(height: 44)
+ }
+ }
+ }
+ .padding(.horizontal, 12)
+ }
+ .padding(.bottom, 8)
+ }
+}
diff --git a/videorem/MonthDayCell.swift b/videorem/MonthDayCell.swift
new file mode 100644
index 0000000..cdb1a3f
--- /dev/null
+++ b/videorem/MonthDayCell.swift
@@ -0,0 +1,57 @@
+//
+// MonthDayCell.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+
+// MARK: - Month Day Cell
+/// A single day cell in the month calendar view.
+struct MonthDayCell: View {
+ let date: Date
+ let entry: VideoEntry?
+ let isSelected: Bool
+ let isToday: Bool
+
+ private let calendar = CalendarConfig.calendar
+
+ var body: some View {
+ VStack(spacing: 3) {
+ Text("\(calendar.component(.day, from: date))")
+ .font(.system(size: 16, weight: isToday ? .semibold : .regular))
+ .foregroundStyle(textColor)
+ .frame(width: 32, height: 32)
+ .background {
+ if isToday {
+ Circle()
+ .fill(.red)
+ } else if isSelected {
+ Circle()
+ .fill(.gray.opacity(0.2))
+ }
+ }
+
+ if entry != nil {
+ Circle()
+ .fill(.blue)
+ .frame(width: 6, height: 6)
+ } else {
+ Color.clear
+ .frame(width: 6, height: 6)
+ }
+ }
+ .frame(height: 44)
+ }
+
+ private var textColor: Color {
+ if isToday {
+ return .white
+ } else if entry != nil {
+ return .blue
+ } else {
+ return .primary
+ }
+ }
+}
diff --git a/videorem/PROJECT_STRUCTURE.md b/videorem/PROJECT_STRUCTURE.md
new file mode 100644
index 0000000..a8c7733
--- /dev/null
+++ b/videorem/PROJECT_STRUCTURE.md
@@ -0,0 +1,148 @@
+# 📁 Projektstruktur - videorem
+
+## ✅ Refactored (Februar 2026)
+
+Die App wurde vollständig refactored und in modulare Components aufgeteilt.
+
+### 🏗️ Architektur-Übersicht
+
+```
+videorem/
+│
+├── 📱 App
+│ └── videoremApp.swift # App entry point mit SwiftData setup
+│ └── ContentView.swift # Main wrapper → CalendarView
+│
+├── 🗂️ Models
+│ └── Item.swift # VideoEntry (SwiftData model)
+│
+├── 🎨 Views
+│ ├── Calendar/
+│ │ ├── CalendarView.swift # Main calendar view (in ContentView.swift)
+│ │ ├── MonthCalendarView.swift # Month grid with days
+│ │ ├── YearCalendarView.swift # Year view with 12 mini months
+│ │ ├── MonthDayCell.swift # Single day cell in month view
+│ │ └── YearMonthCell.swift # Mini month in year view
+│ │
+│ ├── VideoList/
+│ │ ├── VideoListView.swift # Scrollable video list
+│ │ └── VideoListRow.swift # Single video row with thumbnail
+│ │
+│ ├── Player/
+│ │ ├── VideoPlayerView.swift # UIViewControllerRepresentable player
+│ │ └── VideoPlayerCoordinator.swift # AVPlayer lifecycle management
+│ │
+│ └── Recorder/
+│ └── VideoRecorderView.swift # Camera recording interface
+│
+├── 🧠 ViewModels
+│ └── CalendarViewModel.swift # Business logic for calendar
+│
+├── 🛠️ Utilities
+│ ├── Extensions/
+│ │ └── Date+Extensions.swift # Date formatting helpers
+│ └── Helpers/
+│ └── VideoFileManager.swift # File operations singleton
+│
+└── 🧪 Tests
+ ├── videoremTests.swift
+ └── videoremUITestsLaunchTests.swift
+```
+
+---
+
+## 📦 Komponenten-Übersicht
+
+### Models
+- **VideoEntry** (`Item.swift`): SwiftData model mit `videoURL` und `thumbnailURL` properties
+
+### ViewModels
+- **CalendarViewModel**: Verwaltet `selectedDate`, `calendarViewMode`, Navigation, und Daten-Filterung
+
+### Views
+
+#### Calendar Views
+- **CalendarView**: Hauptansicht mit Month/Year Toggle
+- **MonthCalendarView**: 6-Wochen-Grid für einen Monat
+- **YearCalendarView**: 3x4 Grid mit 12 Mini-Monaten
+- **MonthDayCell**: Einzelne Tag-Zelle (mit Video-Indikator)
+- **YearMonthCell**: Mini-Kalender für einen Monat
+
+#### Video Views
+- **VideoListView**: Scrollbare Liste mit Gruppierung nach Datum
+- **VideoListRow**: Einzelne Zeile mit kreisförmigem Thumbnail
+- **VideoPlayerView**: Fullscreen AVPlayer
+- **VideoPlayerCoordinator**: Lifecycle & Delegate für AVPlayer
+- **VideoRecorderView**: Kamera-Recording mit AVFoundation
+
+### Utilities
+
+#### Extensions
+- **Date+Extensions**:
+ - `monthYearString` → "Februar 2026"
+ - `yearString` → "2026"
+ - `monthName` → "Februar"
+ - `timeString` → "14:30"
+ - `headerText(calendar:)` → "Heute", "Gestern", oder formatiertes Datum
+
+#### Helpers
+- **VideoFileManager**:
+ - Singleton für alle File-Operationen
+ - `videosDirectory`, `thumbnailsDirectory`
+ - `videoURL(for:)`, `thumbnailURL(for:)`
+ - `generateVideoFileName()`, `generateThumbnailFileName()`
+
+---
+
+## 🎯 Refactoring-Erfolge
+
+### Vorher
+- ❌ `ContentView.swift`: **699 Zeilen** (Monolithe)
+- ❌ Alle Logik in einer Datei
+- ❌ Keine Wiederverwendbarkeit
+- ❌ Schwer zu testen
+
+### Nachher
+- ✅ `ContentView.swift`: **187 Zeilen** (73% Reduktion!)
+- ✅ 11 modulare Components
+- ✅ MVVM-Pattern mit `@Observable`
+- ✅ Single Responsibility Principle
+- ✅ Einfach testbar und erweiterbar
+
+---
+
+## 🚀 Nächste Schritte (Optional)
+
+1. **Alte Files löschen** (falls nicht mehr benötigt):
+ - `HomeView.swift` (nicht mehr in Verwendung)
+
+2. **VideoRecorderView refactoren**:
+ - ViewModel für Recording-Logik erstellen
+ - Permissions in separates File auslagern
+
+3. **Tests schreiben**:
+ - Unit Tests für `CalendarViewModel`
+ - Integration Tests für File Operations
+
+4. **Accessibility verbessern**:
+ - VoiceOver Labels hinzufügen
+ - Dynamic Type Support
+
+5. **Performance-Optimierung**:
+ - Thumbnail-Caching implementieren
+ - LazyVStack für große Listen
+
+---
+
+## 📝 Coding Standards
+
+- **Sprache**: Swift 6.0+
+- **UI Framework**: SwiftUI
+- **Persistence**: SwiftData
+- **Concurrency**: Swift Concurrency (async/await)
+- **Architecture**: MVVM mit `@Observable`
+- **Naming**: Deutsch für UI-Texte, Englisch für Code
+
+---
+
+*Refactored am 07. Februar 2026*
diff --git a/videorem/SETUP_INSTRUCTIONS.md b/videorem/SETUP_INSTRUCTIONS.md
new file mode 100644
index 0000000..72bbc51
--- /dev/null
+++ b/videorem/SETUP_INSTRUCTIONS.md
@@ -0,0 +1,61 @@
+# Video Journal App - Setup Instructions
+
+## Required Privacy Permissions
+
+You need to add the following keys to your `Info.plist` file:
+
+1. **Camera Usage Description**
+ - Key: `NSCameraUsageDescription`
+ - Value: "We need access to your camera to record video journal entries."
+
+2. **Microphone Usage Description**
+ - Key: `NSMicrophoneUsageDescription`
+ - Value: "We need access to your microphone to record audio for your video journals."
+
+## How to Add Permissions in Xcode:
+
+1. Select your project in the Project Navigator
+2. Select your app target
+3. Go to the "Info" tab
+4. Click the "+" button to add a new row
+5. Start typing "Privacy - Camera" and select it
+6. Add your description text
+7. Repeat for "Privacy - Microphone"
+
+## Features Implemented:
+
+### 1. Video Recording Interface
+- Full-screen camera preview
+- Front/back camera flip
+- Record up to 20 seconds
+- Real-time duration counter
+- Progress bar during recording
+- Stop, retake, and save functionality
+
+### 2. Data Model (VideoEntry)
+- Stores video entries with dates
+- Saves video files to Documents/Videos directory
+- Tracks duration and timestamps
+- Ready for thumbnail support
+
+### 3. Main Interface (ContentView)
+- Shows if you've recorded today
+- Beautiful, clean design
+- List of all recorded videos
+- Quick access to recording
+
+## Next Steps:
+
+1. **Add permissions to Info.plist** (see above)
+2. **Test the recording** - tap "Record Today's Video"
+3. **Calendar View** - We'll create this next with circular video previews
+4. **Video Playback** - Tap entries to watch your videos
+5. **Sharing** - Export videos to share with family
+
+## Technical Notes:
+
+- Videos are saved in MP4/MOV format
+- Files stored in app's Documents directory
+- Uses AVFoundation for camera capture
+- SwiftData for persistence
+- Modern async/await patterns
diff --git a/videorem/VideoFileManager.swift b/videorem/VideoFileManager.swift
new file mode 100644
index 0000000..3d6128e
--- /dev/null
+++ b/videorem/VideoFileManager.swift
@@ -0,0 +1,76 @@
+//
+// VideoFileManager.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import Foundation
+
+// MARK: - Video File Manager
+/// Handles all video and thumbnail file operations.
+final class VideoFileManager {
+ static let shared = VideoFileManager()
+
+ private let fileManager = FileManager.default
+
+ private init() {}
+
+ // MARK: - Directories
+
+ /// Returns the Videos directory URL, creating it if needed
+ var videosDirectory: URL? {
+ guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
+ return nil
+ }
+ let videosURL = documentsURL.appendingPathComponent("Videos")
+ try? fileManager.createDirectory(at: videosURL, withIntermediateDirectories: true)
+ return videosURL
+ }
+
+ /// Returns the Thumbnails directory URL, creating it if needed
+ var thumbnailsDirectory: URL? {
+ guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
+ return nil
+ }
+ let thumbnailsURL = documentsURL.appendingPathComponent("Thumbnails")
+ try? fileManager.createDirectory(at: thumbnailsURL, withIntermediateDirectories: true)
+ return thumbnailsURL
+ }
+
+ // MARK: - File URLs
+
+ /// Returns the full URL for a video file
+ func videoURL(for fileName: String) -> URL? {
+ videosDirectory?.appendingPathComponent(fileName)
+ }
+
+ /// Returns the full URL for a thumbnail file
+ func thumbnailURL(for fileName: String) -> URL? {
+ thumbnailsDirectory?.appendingPathComponent(fileName)
+ }
+
+ // MARK: - File Operations
+
+ /// Deletes a video file
+ func deleteVideo(at fileName: String) throws {
+ guard let url = videoURL(for: fileName) else { return }
+ try fileManager.removeItem(at: url)
+ }
+
+ /// Deletes a thumbnail file
+ func deleteThumbnail(at fileName: String) throws {
+ guard let url = thumbnailURL(for: fileName) else { return }
+ try fileManager.removeItem(at: url)
+ }
+
+ /// Generates a unique filename for a video
+ func generateVideoFileName() -> String {
+ "video_\(UUID().uuidString).mov"
+ }
+
+ /// Generates a unique filename for a thumbnail
+ func generateThumbnailFileName() -> String {
+ "thumb_\(UUID().uuidString).jpg"
+ }
+}
diff --git a/videorem/VideoListRow.swift b/videorem/VideoListRow.swift
new file mode 100644
index 0000000..2642ff5
--- /dev/null
+++ b/videorem/VideoListRow.swift
@@ -0,0 +1,158 @@
+//
+// VideoListRow.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+import AVKit
+
+// MARK: - Thumbnail Cache
+/// Actor-based cache to prevent concurrent asset access issues
+actor ThumbnailCache {
+ static let shared = ThumbnailCache()
+
+ private var cache: [String: UIImage] = [:]
+ private var loadingTasks: [String: Task] = [:]
+
+ func getThumbnail(for fileName: String) -> UIImage? {
+ return cache[fileName]
+ }
+
+ func setThumbnail(_ image: UIImage, for fileName: String) {
+ cache[fileName] = image
+ }
+
+ func getOrCreateLoadingTask(for fileName: String, url: URL) -> Task {
+ if let existingTask = loadingTasks[fileName] {
+ return existingTask
+ }
+
+ let task = Task {
+ // Check cache first
+ if let cached = cache[fileName] {
+ return cached
+ }
+
+ // Add delay to prevent overwhelming the system
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
+
+ guard !Task.isCancelled else { return nil }
+
+ let asset = AVURLAsset(url: url)
+ let imageGenerator = AVAssetImageGenerator(asset: asset)
+ imageGenerator.appliesPreferredTrackTransform = true
+ imageGenerator.maximumSize = CGSize(width: 72, height: 72)
+ imageGenerator.requestedTimeToleranceBefore = CMTime(seconds: 1, preferredTimescale: 1)
+ imageGenerator.requestedTimeToleranceAfter = CMTime(seconds: 1, preferredTimescale: 1)
+
+ do {
+ let cgImage = try await imageGenerator.image(at: .zero).image
+ guard !Task.isCancelled else { return nil }
+
+ let image = UIImage(cgImage: cgImage)
+ cache[fileName] = image
+ return image
+ } catch {
+ if !Task.isCancelled {
+ print("Thumbnail error for \(fileName): \(error.localizedDescription)")
+ }
+ return nil
+ }
+ }
+
+ loadingTasks[fileName] = task
+
+ // Clean up task after completion
+ Task {
+ _ = await task.value
+ loadingTasks.removeValue(forKey: fileName)
+ }
+
+ return task
+ }
+}
+
+// MARK: - Video List Row
+/// A list row displaying a video entry with thumbnail and metadata.
+struct VideoListRow: View {
+ let entry: VideoEntry
+ @State private var thumbnail: UIImage?
+
+ var body: some View {
+ HStack(spacing: 12) {
+ // Circular Thumbnail
+ thumbnailView
+
+ // Info
+ VStack(alignment: .leading, spacing: 2) {
+ Text(entry.date.timeString)
+ .font(.body)
+ .foregroundStyle(.primary)
+
+ Text("\(Int(entry.duration))s")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+ .padding(.vertical, 6)
+ .task(id: entry.videoFileName) {
+ await loadThumbnail()
+ }
+ }
+
+ // MARK: - Thumbnail View
+
+ @ViewBuilder
+ private var thumbnailView: some View {
+ Group {
+ if let thumbnail = thumbnail {
+ Image(uiImage: thumbnail)
+ .resizable()
+ .scaledToFill()
+ } else {
+ Rectangle()
+ .fill(.blue.opacity(0.3))
+ .overlay {
+ ProgressView()
+ .tint(.white)
+ }
+ }
+ }
+ .frame(width: 36, height: 36)
+ .clipShape(Circle())
+ .overlay {
+ Circle()
+ .strokeBorder(.blue, lineWidth: 2)
+ }
+ .overlay {
+ Image(systemName: "play.fill")
+ .font(.system(size: 10))
+ .foregroundStyle(.white)
+ .shadow(radius: 1)
+ }
+ }
+
+ // MARK: - Thumbnail Loading
+
+ private func loadThumbnail() async {
+ guard let videoURL = entry.videoURL else { return }
+
+ // Check cache first
+ if let cached = await ThumbnailCache.shared.getThumbnail(for: entry.videoFileName) {
+ thumbnail = cached
+ return
+ }
+
+ // Use shared loading task to prevent duplicate requests
+ let task = await ThumbnailCache.shared.getOrCreateLoadingTask(
+ for: entry.videoFileName,
+ url: videoURL
+ )
+
+ thumbnail = await task.value
+ }
+}
diff --git a/videorem/VideoListView.swift b/videorem/VideoListView.swift
new file mode 100644
index 0000000..cdbe82f
--- /dev/null
+++ b/videorem/VideoListView.swift
@@ -0,0 +1,194 @@
+//
+// VideoListView.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+import SwiftData
+
+// MARK: - Video List View
+/// A sectioned list of video entries grouped by date.
+struct VideoListView: View {
+ let viewModel: CalendarViewModel
+ let videoEntries: [VideoEntry]
+ @Binding var selectedDay: Date
+ @Binding var listTargetDate: Date?
+ @Binding var isProgrammaticScroll: Bool
+ let onVideoTap: (VideoEntry) -> Void
+ let onRecordTap: () -> Void
+
+ @Environment(\.modelContext) private var modelContext
+ @State private var shareItem: ShareItem?
+ private let calendar = CalendarConfig.calendar
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Video count header (only if not empty)
+ if !videoEntries.isEmpty {
+ HStack {
+ Spacer()
+ Text("\(videoEntries.count) Videos")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal)
+ .padding(.top, 8)
+ .padding(.bottom, 4)
+ }
+ }
+
+ // Content
+ if videoEntries.isEmpty {
+ emptyStateView
+ } else {
+ videoList
+ }
+ }
+ .sheet(item: $shareItem) { item in
+ ShareSheet(items: [item.url])
+ }
+ }
+
+ // MARK: - Empty State
+
+ private var emptyStateView: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ Image(systemName: "video.slash")
+ .font(.system(size: 60))
+ .foregroundStyle(.secondary)
+ Text("Noch keine Videos")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ Button(action: onRecordTap) {
+ Text("Erstes Video aufnehmen")
+ .font(.headline)
+ }
+ .buttonStyle(.borderedProminent)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ // MARK: - Video List
+
+ private var videoList: some View {
+ ScrollViewReader { proxy in
+ List {
+ ForEach(groupedEntries.keys.sorted(by: >), id: \.self) { date in
+ Section {
+ ForEach(groupedEntries[date] ?? []) { entry in
+ VideoListRow(entry: entry)
+ .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onVideoTap(entry)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ Button(role: .destructive) {
+ deleteEntry(entry)
+ } label: {
+ Label("Löschen", systemImage: "trash")
+ }
+ }
+ .swipeActions(edge: .leading) {
+ Button {
+ shareEntry(entry)
+ } label: {
+ Label("Teilen", systemImage: "square.and.arrow.up")
+ }
+ .tint(.blue)
+ }
+ }
+ } header: {
+ Text(date.headerText(calendar: calendar))
+ .font(.subheadline.bold())
+ .textCase(nil)
+ .id(date)
+ .onAppear {
+ guard !isProgrammaticScroll else { return }
+ let day = calendar.startOfDay(for: date)
+ if !calendar.isDate(day, inSameDayAs: selectedDay) {
+ selectedDay = day
+ }
+ let monthStart = calendar.startOfMonth(for: day)
+ if !calendar.isDate(monthStart, equalTo: viewModel.selectedDate, toGranularity: .month) {
+ viewModel.selectedDate = monthStart
+ }
+ }
+ }
+ }
+ }
+ .listStyle(.plain)
+ .onChange(of: listTargetDate) { _, newValue in
+ guard let newValue else { return }
+ isProgrammaticScroll = true
+ withAnimation(.easeInOut(duration: 0.25)) {
+ proxy.scrollTo(newValue, anchor: .top)
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
+ isProgrammaticScroll = false
+ listTargetDate = nil
+ }
+ }
+ }
+ }
+
+ // MARK: - Computed Properties
+
+ private var groupedEntries: [Date: [VideoEntry]] {
+ viewModel.groupEntries(videoEntries)
+ }
+
+ // MARK: - Actions
+
+ private func deleteEntry(_ entry: VideoEntry) {
+ withAnimation {
+ // Delete video file if it exists
+ if let videoURL = entry.videoURL, FileManager.default.fileExists(atPath: videoURL.path) {
+ try? FileManager.default.removeItem(at: videoURL)
+ }
+
+ // Delete from SwiftData
+ modelContext.delete(entry)
+ }
+ }
+
+ private func shareEntry(_ entry: VideoEntry) {
+ guard let videoURL = entry.videoURL else { return }
+ shareItem = ShareItem(url: videoURL)
+ }
+}
+
+// MARK: - Share Item
+
+private struct ShareItem: Identifiable {
+ let id = UUID()
+ let url: URL
+}
+
+// MARK: - Share Sheet
+
+private struct ShareSheet: UIViewControllerRepresentable {
+ let items: [Any]
+
+ func makeUIViewController(context: Context) -> UIActivityViewController {
+ let controller = UIActivityViewController(
+ activityItems: items,
+ applicationActivities: nil
+ )
+
+ // Exclude some activities that might cause issues
+ controller.excludedActivityTypes = [
+ .addToReadingList,
+ .assignToContact
+ ]
+
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
+ // Nothing to update
+ }
+}
diff --git a/videorem/VideoPlayerCoordinator.swift b/videorem/VideoPlayerCoordinator.swift
new file mode 100644
index 0000000..0aa4f78
--- /dev/null
+++ b/videorem/VideoPlayerCoordinator.swift
@@ -0,0 +1,92 @@
+//
+// VideoPlayerCoordinator.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import AVKit
+import UIKit
+
+// MARK: - Video Player Coordinator
+/// Manages the AVPlayerViewController lifecycle and delegates video playback events.
+final class VideoPlayerCoordinator: NSObject, AVPlayerViewControllerDelegate {
+ var playerController: AVPlayerViewController?
+ var dismissAction: (() -> Void)?
+ private var videoEndObserver: NSObjectProtocol?
+ private var isCleanedUp = false
+
+ // MARK: - Setup
+
+ /// Creates and configures an AVPlayerViewController with the given video URL.
+ func setupPlayer(with url: URL) -> AVPlayerViewController {
+ let player = AVPlayer(url: url)
+ let playerController = AVPlayerViewController()
+
+ playerController.player = player
+ playerController.allowsPictureInPicturePlayback = true
+ playerController.showsPlaybackControls = true
+ playerController.modalPresentationStyle = .fullScreen
+ playerController.delegate = self
+
+ // Disable Visual Intelligence analysis to prevent VKCImageAnalyzerRequest errors
+ if #available(iOS 18.0, *) {
+ playerController.allowsVideoFrameAnalysis = false
+ }
+
+ self.playerController = playerController
+ setupVideoEndObserver(for: player)
+
+ return playerController
+ }
+
+ // MARK: - Video End Handling
+
+ private func setupVideoEndObserver(for player: AVPlayer) {
+ videoEndObserver = NotificationCenter.default.addObserver(
+ forName: .AVPlayerItemDidPlayToEndTime,
+ object: player.currentItem,
+ queue: .main
+ ) { [weak player] _ in
+ player?.seek(to: .zero)
+ }
+ }
+
+ // MARK: - AVPlayerViewControllerDelegate
+
+ func playerViewController(
+ _ playerViewController: AVPlayerViewController,
+ willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
+ ) {
+ coordinator.animate(alongsideTransition: nil) { [weak self] _ in
+ DispatchQueue.main.async {
+ self?.dismissAction?()
+ }
+ }
+ }
+
+ // MARK: - Cleanup
+
+ func cleanup() {
+ guard !isCleanedUp else { return }
+ isCleanedUp = true
+
+ // Stop playback
+ playerController?.player?.pause()
+ playerController?.player?.replaceCurrentItem(with: nil)
+ playerController?.player = nil
+
+ // Remove observer
+ if let observer = videoEndObserver {
+ NotificationCenter.default.removeObserver(observer)
+ videoEndObserver = nil
+ }
+
+ // Clear delegate to prevent further callbacks
+ playerController?.delegate = nil
+ }
+
+ deinit {
+ cleanup()
+ }
+}
diff --git a/videorem/VideoPlayerView.swift b/videorem/VideoPlayerView.swift
new file mode 100644
index 0000000..c23abce
--- /dev/null
+++ b/videorem/VideoPlayerView.swift
@@ -0,0 +1,60 @@
+//
+// VideoPlayerView.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+import AVKit
+
+// MARK: - Video Player View
+/// A fullscreen video player that automatically plays the video and dismisses when the user exits fullscreen mode.
+struct VideoPlayerView: UIViewControllerRepresentable {
+ let entry: VideoEntry
+ @Environment(\.dismiss) private var dismiss
+
+ func makeUIViewController(context: Context) -> UIViewController {
+ let hostingController = UIViewController()
+ hostingController.view.backgroundColor = .clear
+ hostingController.modalPresentationStyle = .overFullScreen
+
+ guard let videoURL = entry.videoURL else {
+ return hostingController
+ }
+
+ let playerController = context.coordinator.setupPlayer(with: videoURL)
+ context.coordinator.dismissAction = {
+ dismiss()
+ }
+
+ // Present fullscreen immediately
+ DispatchQueue.main.async {
+ playerController.player?.play()
+ hostingController.present(playerController, animated: false)
+ }
+
+ return hostingController
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
+ // Nothing to update
+ }
+
+ func makeCoordinator() -> VideoPlayerCoordinator {
+ VideoPlayerCoordinator()
+ }
+
+ static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: VideoPlayerCoordinator) {
+ // Clean up before dismissing
+ coordinator.cleanup()
+
+ // Dismiss the player controller if presented
+ if let playerController = coordinator.playerController, playerController.presentingViewController != nil {
+ playerController.dismiss(animated: false) {
+ // Ensure cleanup is complete after dismissal
+ coordinator.playerController = nil
+ }
+ }
+ }
+}
diff --git a/videorem/VideoRecorderView.swift b/videorem/VideoRecorderView.swift
new file mode 100644
index 0000000..32e8cfe
--- /dev/null
+++ b/videorem/VideoRecorderView.swift
@@ -0,0 +1,416 @@
+//
+// VideoRecorderView.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+import SwiftData
+import AVFoundation
+import Combine
+
+struct VideoRecorderView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @StateObject private var recorder = VideoRecorder()
+ @State private var showingSaveSuccess = false
+ @State private var errorMessage: String?
+
+ let maxDuration: TimeInterval = 20.0
+
+ var body: some View {
+ ZStack {
+ // Camera Preview
+ CameraPreview(session: recorder.captureSession)
+ .ignoresSafeArea()
+
+ // Controls Overlay
+ VStack {
+ // Top bar with time and close button
+ HStack {
+ Button(action: { dismiss() }) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title)
+ .foregroundStyle(.white)
+ .shadow(radius: 2)
+ }
+
+ Spacer()
+
+ if recorder.isRecording {
+ Text(timeString(from: recorder.currentDuration))
+ .font(.title2.monospacedDigit())
+ .foregroundStyle(.white)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(.red.opacity(0.8), in: Capsule())
+ }
+ }
+ .padding()
+
+ Spacer()
+
+ // Bottom controls
+ VStack(spacing: 20) {
+ // Progress bar
+ if recorder.isRecording {
+ ProgressView(value: recorder.currentDuration, total: maxDuration)
+ .tint(.red)
+ .padding(.horizontal, 40)
+ }
+
+ // Record/Stop/Save buttons
+ HStack(spacing: 40) {
+ // Flip camera button
+ if !recorder.isRecording && !recorder.hasRecorded {
+ Button(action: { recorder.flipCamera() }) {
+ Image(systemName: "camera.rotate")
+ .font(.title)
+ .foregroundStyle(.white)
+ .frame(width: 60, height: 60)
+ .background(Circle().fill(.black.opacity(0.5)))
+ }
+ }
+
+ // Main record button
+ Button(action: handleMainButtonTap) {
+ ZStack {
+ Circle()
+ .stroke(lineWidth: 4)
+ .foregroundStyle(.white)
+ .frame(width: 70, height: 70)
+
+ if recorder.isRecording {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.red)
+ .frame(width: 30, height: 30)
+ } else if recorder.hasRecorded {
+ Image(systemName: "checkmark")
+ .font(.title)
+ .foregroundStyle(.white)
+ } else {
+ Circle()
+ .fill(.red)
+ .frame(width: 60, height: 60)
+ }
+ }
+ }
+
+ // Retake button (when recorded)
+ if recorder.hasRecorded {
+ Button(action: { recorder.retake() }) {
+ Image(systemName: "arrow.counterclockwise")
+ .font(.title)
+ .foregroundStyle(.white)
+ .frame(width: 60, height: 60)
+ .background(Circle().fill(.black.opacity(0.5)))
+ }
+ } else if !recorder.isRecording {
+ Spacer()
+ .frame(width: 60, height: 60)
+ }
+ }
+ .padding(.bottom, 40)
+ }
+ }
+
+ // Error message
+ if let error = errorMessage {
+ VStack {
+ Text(error)
+ .padding()
+ .background(.red.opacity(0.9))
+ .foregroundStyle(.white)
+ .cornerRadius(10)
+ .padding()
+ Spacer()
+ }
+ .transition(.move(edge: .top))
+ }
+ }
+ .task {
+ await recorder.checkPermissionsAndSetup()
+ }
+ .onChange(of: recorder.currentDuration) { oldValue, newValue in
+ if newValue >= maxDuration {
+ recorder.stopRecording()
+ }
+ }
+ .alert("Video Saved!", isPresented: $showingSaveSuccess) {
+ Button("OK") {
+ dismiss()
+ }
+ } message: {
+ Text("Your daily video has been saved.")
+ }
+ }
+
+ private func handleMainButtonTap() {
+ if recorder.hasRecorded {
+ // Save the video
+ Task {
+ do {
+ try await saveVideo()
+ showingSaveSuccess = true
+ } catch {
+ errorMessage = "Failed to save: \(error.localizedDescription)"
+ }
+ }
+ } else if recorder.isRecording {
+ recorder.stopRecording()
+ } else {
+ do {
+ try recorder.startRecording()
+ } catch {
+ errorMessage = "Failed to start recording: \(error.localizedDescription)"
+ }
+ }
+ }
+
+ private func saveVideo() async throws {
+ guard let videoURL = recorder.recordedVideoURL else {
+ throw VideoRecorderError.noVideoRecorded
+ }
+
+ // Create directories if needed
+ let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ let videosPath = documentsPath.appendingPathComponent("Videos")
+ try? FileManager.default.createDirectory(at: videosPath, withIntermediateDirectories: true)
+
+ // Create unique filename
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyy-MM-dd"
+ let dateString = dateFormatter.string(from: Date())
+ let fileName = "\(dateString).mov"
+
+ let destinationURL = videosPath.appendingPathComponent(fileName)
+
+ // Move or copy the video file
+ if FileManager.default.fileExists(atPath: destinationURL.path) {
+ try FileManager.default.removeItem(at: destinationURL)
+ }
+ try FileManager.default.copyItem(at: videoURL, to: destinationURL)
+
+ // Create video entry
+ let videoEntry = VideoEntry(
+ date: Date(),
+ videoFileName: fileName,
+ duration: recorder.currentDuration
+ )
+
+ modelContext.insert(videoEntry)
+ try modelContext.save()
+ }
+
+ private func timeString(from timeInterval: TimeInterval) -> String {
+ let time = Int(timeInterval)
+ let seconds = time % 60
+ return String(format: "%02d", seconds)
+ }
+}
+
+// MARK: - Camera Preview
+struct CameraPreview: UIViewRepresentable {
+ let session: AVCaptureSession
+
+ func makeUIView(context: Context) -> UIView {
+ let view = UIView(frame: .zero)
+ view.backgroundColor = .black
+
+ let previewLayer = AVCaptureVideoPreviewLayer(session: session)
+ previewLayer.videoGravity = .resizeAspectFill
+ view.layer.addSublayer(previewLayer)
+
+ context.coordinator.previewLayer = previewLayer
+
+ return view
+ }
+
+ func updateUIView(_ uiView: UIView, context: Context) {
+ if let previewLayer = context.coordinator.previewLayer {
+ DispatchQueue.main.async {
+ previewLayer.frame = uiView.bounds
+ }
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator()
+ }
+
+ class Coordinator {
+ var previewLayer: AVCaptureVideoPreviewLayer?
+ }
+}
+
+// MARK: - Video Recorder
+@MainActor
+class VideoRecorder: NSObject, ObservableObject {
+ @Published var isRecording = false
+ @Published var hasRecorded = false
+ @Published var currentDuration: TimeInterval = 0
+ @Published var recordedVideoURL: URL?
+
+ nonisolated let captureSession = AVCaptureSession()
+ private let sessionQueue = DispatchQueue(label: "videorem.capture.session")
+ private var videoOutput: AVCaptureMovieFileOutput?
+ private var currentCamera: AVCaptureDevice.Position = .front
+ private var timer: Timer?
+ private var recordingStartTime: Date?
+
+ @MainActor
+ func checkPermissionsAndSetup() async {
+ // Check camera permission
+ let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video)
+ let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
+
+ var cameraGranted = cameraStatus == .authorized
+ var micGranted = micStatus == .authorized
+
+ if cameraStatus == .notDetermined {
+ cameraGranted = await AVCaptureDevice.requestAccess(for: .video)
+ }
+
+ if micStatus == .notDetermined {
+ micGranted = await AVCaptureDevice.requestAccess(for: .audio)
+ }
+
+ if cameraGranted && micGranted {
+ await setupCamera()
+ }
+ }
+
+ private func setupCamera() async {
+ captureSession.beginConfiguration()
+
+ // Remove existing inputs
+ captureSession.inputs.forEach { captureSession.removeInput($0) }
+ captureSession.outputs.forEach { captureSession.removeOutput($0) }
+
+ // Set preset
+ if captureSession.canSetSessionPreset(.high) {
+ captureSession.sessionPreset = .high
+ }
+
+ // Add video input
+ guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: currentCamera),
+ let videoInput = try? AVCaptureDeviceInput(device: videoDevice),
+ captureSession.canAddInput(videoInput) else {
+ captureSession.commitConfiguration()
+ return
+ }
+ captureSession.addInput(videoInput)
+
+ // Add audio input
+ if let audioDevice = AVCaptureDevice.default(for: .audio),
+ let audioInput = try? AVCaptureDeviceInput(device: audioDevice),
+ captureSession.canAddInput(audioInput) {
+ captureSession.addInput(audioInput)
+ }
+
+ // Add movie file output
+ let movieOutput = AVCaptureMovieFileOutput()
+ if captureSession.canAddOutput(movieOutput) {
+ captureSession.addOutput(movieOutput)
+ videoOutput = movieOutput
+
+ // Set max duration
+ movieOutput.maxRecordedDuration = CMTime(seconds: 20, preferredTimescale: 600)
+ }
+
+ captureSession.commitConfiguration()
+
+ // Start session on background thread
+ sessionQueue.async { [weak self] in
+ self?.captureSession.startRunning()
+ }
+ }
+
+ @MainActor
+ func flipCamera() {
+ currentCamera = currentCamera == .front ? .back : .front
+ Task {
+ await setupCamera()
+ }
+ }
+
+ @MainActor
+ func startRecording() throws {
+ guard let videoOutput = videoOutput else {
+ throw VideoRecorderError.outputNotConfigured
+ }
+
+ let tempURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("mov")
+
+ recordedVideoURL = tempURL
+ videoOutput.startRecording(to: tempURL, recordingDelegate: self)
+
+ isRecording = true
+ recordingStartTime = Date()
+
+ // Start timer
+ let startTime = recordingStartTime
+ timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
+ guard let self = self, let startTime else { return }
+ Task { @MainActor in
+ self.currentDuration = Date().timeIntervalSince(startTime)
+ }
+ }
+ }
+
+ @MainActor
+ func stopRecording() {
+ videoOutput?.stopRecording()
+ isRecording = false
+ timer?.invalidate()
+ timer = nil
+ }
+
+ @MainActor
+ func retake() {
+ if let url = recordedVideoURL {
+ try? FileManager.default.removeItem(at: url)
+ }
+ recordedVideoURL = nil
+ hasRecorded = false
+ currentDuration = 0
+ recordingStartTime = nil
+ }
+}
+
+// MARK: - AVCaptureFileOutputRecordingDelegate
+extension VideoRecorder: AVCaptureFileOutputRecordingDelegate {
+ nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
+ Task { @MainActor in
+ if let error = error {
+ print("Recording error: \(error.localizedDescription)")
+ } else {
+ hasRecorded = true
+ }
+ }
+ }
+}
+
+// MARK: - Errors
+enum VideoRecorderError: LocalizedError {
+ case noVideoRecorded
+ case outputNotConfigured
+
+ var errorDescription: String? {
+ switch self {
+ case .noVideoRecorded:
+ return "No video has been recorded yet"
+ case .outputNotConfigured:
+ return "Camera output is not configured"
+ }
+ }
+}
+
+#Preview {
+ VideoRecorderView()
+ .modelContainer(for: VideoEntry.self, inMemory: true)
+}
diff --git a/videorem/YearCalendarView.swift b/videorem/YearCalendarView.swift
new file mode 100644
index 0000000..332703b
--- /dev/null
+++ b/videorem/YearCalendarView.swift
@@ -0,0 +1,46 @@
+//
+// YearCalendarView.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+
+// MARK: - Year Calendar View
+/// A grid of mini calendars displaying all 12 months for the selected year.
+struct YearCalendarView: View {
+ let viewModel: CalendarViewModel
+ let videoEntries: [VideoEntry]
+ let selectedDate: Date
+ let onMonthTap: (Int) -> Void
+
+ private let calendar = CalendarConfig.calendar
+
+ var body: some View {
+ ScrollView {
+ LazyVGrid(columns: [
+ GridItem(.flexible(), spacing: 8),
+ GridItem(.flexible(), spacing: 8),
+ GridItem(.flexible(), spacing: 8)
+ ], spacing: 20) {
+ ForEach(0..<12, id: \.self) { monthIndex in
+ YearMonthCell(
+ year: calendar.component(.year, from: selectedDate),
+ month: monthIndex + 1,
+ videoEntries: videoEntries,
+ isCurrentMonth: viewModel.isCurrentMonth(
+ monthIndex + 1,
+ in: calendar.component(.year, from: selectedDate)
+ )
+ )
+ .onTapGesture {
+ onMonthTap(monthIndex + 1)
+ }
+ }
+ }
+ .padding(16)
+ }
+ .background(Color(.systemGroupedBackground))
+ }
+}
diff --git a/videorem/YearMonthCell.swift b/videorem/YearMonthCell.swift
new file mode 100644
index 0000000..11d6570
--- /dev/null
+++ b/videorem/YearMonthCell.swift
@@ -0,0 +1,135 @@
+//
+// YearMonthCell.swift
+// videorem
+//
+// Created by Felix Förtsch on 07.02.26.
+//
+
+import SwiftUI
+
+// MARK: - Year Month Cell
+/// A mini calendar cell representing one month in the year view.
+struct YearMonthCell: View {
+ let year: Int
+ let month: Int
+ let videoEntries: [VideoEntry]
+ let isCurrentMonth: Bool
+
+ private let calendar = CalendarConfig.calendar
+
+ var body: some View {
+ VStack(alignment: .center, spacing: 8) {
+ // Month name
+ Text(monthName)
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(isCurrentMonth ? .red : .primary)
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ // Weekday headers
+ HStack(spacing: 2) {
+ ForEach(0..<7, id: \.self) { index in
+ let adjustedIndex = (index + calendar.firstWeekday - 1) % 7
+ Text(calendar.veryShortWeekdaySymbols[adjustedIndex])
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ // Days grid
+ let grid = monthGrid
+ ForEach(0.. some View {
+ let hasVideo = videoEntries.contains { entry in
+ calendar.isDate(entry.date, inSameDayAs: date)
+ }
+ let isToday = calendar.isDateInToday(date)
+
+ ZStack {
+ // Green circle for videos
+ if hasVideo {
+ Circle()
+ .strokeBorder(.green, lineWidth: 1.5)
+ .frame(width: 21, height: 21)
+ }
+
+ // Red background for today
+ if isToday {
+ Circle()
+ .fill(.red)
+ .frame(width: 19, height: 19)
+ }
+
+ Text("\(calendar.component(.day, from: date))")
+ .font(.system(size: 11, weight: .regular))
+ .foregroundStyle(isToday ? .white : .primary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: 24)
+ }
+
+ // MARK: - Computed Properties
+
+ private var monthName: String {
+ var components = DateComponents()
+ components.year = year
+ components.month = month
+ components.day = 1
+
+ if let date = calendar.date(from: components) {
+ return date.monthName
+ }
+ return ""
+ }
+
+ private var monthGrid: [[Date?]] {
+ var components = DateComponents()
+ components.year = year
+ components.month = month
+ components.day = 1
+
+ guard let firstDay = calendar.date(from: components),
+ let monthInterval = calendar.dateInterval(of: .month, for: firstDay),
+ let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else {
+ return []
+ }
+
+ var weeks: [[Date?]] = []
+ var currentWeek: [Date?] = []
+ var currentDate = monthFirstWeek.start
+
+ while weeks.count < 6 {
+ if calendar.isDate(currentDate, equalTo: firstDay, toGranularity: .month) {
+ currentWeek.append(currentDate)
+ } else {
+ currentWeek.append(nil)
+ }
+
+ if currentWeek.count == 7 {
+ weeks.append(currentWeek)
+ currentWeek = []
+ }
+
+ currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
+ }
+
+ return weeks
+ }
+}
diff --git a/videorem/videoremApp.swift b/videorem/videoremApp.swift
index 914aa5d..f408481 100644
--- a/videorem/videoremApp.swift
+++ b/videorem/videoremApp.swift
@@ -12,7 +12,7 @@ import SwiftData
struct videoremApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
- Item.self,
+ VideoEntry.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)