add video reminder app prototype with calendar sync
This commit is contained in:
54
CODEX_REPORT.md
Normal file
54
CODEX_REPORT.md
Normal file
@@ -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.
|
||||||
10
videorem.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
10
videorem.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:CalendarView.swift">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>videorem.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
41
videorem/BUILD_FIXES.md
Normal file
41
videorem/BUILD_FIXES.md
Normal file
@@ -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
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>We need access to your camera to record video journal entries.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>We need access to your microphone to record audio for your video journals.</string>
|
||||||
|
```
|
||||||
64
videorem/CalendarConfig.swift
Normal file
64
videorem/CalendarConfig.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
46
videorem/CalendarHeaderView.swift
Normal file
46
videorem/CalendarHeaderView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
videorem/CalendarViewModel.swift
Normal file
130
videorem/CalendarViewModel.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,54 +8,368 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
// MARK: - Content View
|
||||||
|
/// The main view of the app, embedding the calendar interface.
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
@Query private var items: [Item]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
CalendarView()
|
||||||
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
// MARK: - Calendar View
|
||||||
ContentView()
|
/// Main calendar view with month/year toggle and video list integration.
|
||||||
.modelContainer(for: Item.self, inMemory: true)
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
videorem/Date+Extensions.swift
Normal file
42
videorem/Date+Extensions.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
videorem/HomeView.swift
Normal file
136
videorem/HomeView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>"We need access to your camera to record video journal entries."</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>"We need access to your microphone to record audio for your video journals."</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Item.swift
|
// VideoEntry.swift (formerly Item.swift)
|
||||||
// videorem
|
// videorem
|
||||||
//
|
//
|
||||||
// Created by Felix Förtsch on 07.02.26.
|
// Created by Felix Förtsch on 07.02.26.
|
||||||
@@ -8,11 +8,30 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
// MARK: - Video Entry Model
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class Item {
|
final class VideoEntry {
|
||||||
var timestamp: Date
|
var date: Date
|
||||||
|
var videoFileName: String
|
||||||
|
var duration: TimeInterval
|
||||||
|
var thumbnailFileName: String?
|
||||||
|
|
||||||
init(timestamp: Date) {
|
init(date: Date, videoFileName: String, duration: TimeInterval, thumbnailFileName: String? = nil) {
|
||||||
self.timestamp = timestamp
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
videorem/MonthCalendarView.swift
Normal file
69
videorem/MonthCalendarView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
videorem/MonthDayCell.swift
Normal file
57
videorem/MonthDayCell.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
videorem/PROJECT_STRUCTURE.md
Normal file
148
videorem/PROJECT_STRUCTURE.md
Normal file
@@ -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*
|
||||||
61
videorem/SETUP_INSTRUCTIONS.md
Normal file
61
videorem/SETUP_INSTRUCTIONS.md
Normal file
@@ -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
|
||||||
76
videorem/VideoFileManager.swift
Normal file
76
videorem/VideoFileManager.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
158
videorem/VideoListRow.swift
Normal file
158
videorem/VideoListRow.swift
Normal file
@@ -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<UIImage?, Never>] = [:]
|
||||||
|
|
||||||
|
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<UIImage?, Never> {
|
||||||
|
if let existingTask = loadingTasks[fileName] {
|
||||||
|
return existingTask
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = Task<UIImage?, Never> {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
194
videorem/VideoListView.swift
Normal file
194
videorem/VideoListView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
92
videorem/VideoPlayerCoordinator.swift
Normal file
92
videorem/VideoPlayerCoordinator.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
60
videorem/VideoPlayerView.swift
Normal file
60
videorem/VideoPlayerView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
416
videorem/VideoRecorderView.swift
Normal file
416
videorem/VideoRecorderView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
46
videorem/YearCalendarView.swift
Normal file
46
videorem/YearCalendarView.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
135
videorem/YearMonthCell.swift
Normal file
135
videorem/YearMonthCell.swift
Normal file
@@ -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..<grid.count, id: \.self) { weekIndex in
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ForEach(0..<7, id: \.self) { dayIndex in
|
||||||
|
if let date = grid[weekIndex][dayIndex] {
|
||||||
|
dayCell(for: date)
|
||||||
|
} else {
|
||||||
|
Color.clear
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: 22)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Cell
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func dayCell(for date: Date) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import SwiftData
|
|||||||
struct videoremApp: App {
|
struct videoremApp: App {
|
||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
let schema = Schema([
|
let schema = Schema([
|
||||||
Item.self,
|
VideoEntry.self,
|
||||||
])
|
])
|
||||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user