From a5550877a3a28a3fd25e0cbad17574f276a3d5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 8 Apr 2026 11:04:17 +0200 Subject: [PATCH] add video reminder app prototype with calendar sync --- CODEX_REPORT.md | 54 +++ .../contents.xcworkspacedata | 10 + .../xcschemes/xcschememanagement.plist | 14 + videorem/BUILD_FIXES.md | 41 ++ videorem/CalendarConfig.swift | 64 +++ videorem/CalendarHeaderView.swift | 46 ++ videorem/CalendarViewModel.swift | 130 ++++++ videorem/ContentView.swift | 404 +++++++++++++++-- videorem/Date+Extensions.swift | 42 ++ videorem/HomeView.swift | 136 ++++++ videorem/Info.plist | 4 + videorem/Item.swift | 29 +- videorem/MonthCalendarView.swift | 69 +++ videorem/MonthDayCell.swift | 57 +++ videorem/PROJECT_STRUCTURE.md | 148 +++++++ videorem/SETUP_INSTRUCTIONS.md | 61 +++ videorem/VideoFileManager.swift | 76 ++++ videorem/VideoListRow.swift | 158 +++++++ videorem/VideoListView.swift | 194 ++++++++ videorem/VideoPlayerCoordinator.swift | 92 ++++ videorem/VideoPlayerView.swift | 60 +++ videorem/VideoRecorderView.swift | 416 ++++++++++++++++++ videorem/YearCalendarView.swift | 46 ++ videorem/YearMonthCell.swift | 135 ++++++ videorem/videoremApp.swift | 2 +- 25 files changed, 2437 insertions(+), 51 deletions(-) create mode 100644 CODEX_REPORT.md create mode 100644 videorem.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 videorem.xcodeproj/xcuserdata/felixfoertsch.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 videorem/BUILD_FIXES.md create mode 100644 videorem/CalendarConfig.swift create mode 100644 videorem/CalendarHeaderView.swift create mode 100644 videorem/CalendarViewModel.swift create mode 100644 videorem/Date+Extensions.swift create mode 100644 videorem/HomeView.swift create mode 100644 videorem/MonthCalendarView.swift create mode 100644 videorem/MonthDayCell.swift create mode 100644 videorem/PROJECT_STRUCTURE.md create mode 100644 videorem/SETUP_INSTRUCTIONS.md create mode 100644 videorem/VideoFileManager.swift create mode 100644 videorem/VideoListRow.swift create mode 100644 videorem/VideoListView.swift create mode 100644 videorem/VideoPlayerCoordinator.swift create mode 100644 videorem/VideoPlayerView.swift create mode 100644 videorem/VideoRecorderView.swift create mode 100644 videorem/YearCalendarView.swift create mode 100644 videorem/YearMonthCell.swift 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)