feat(ios): toggle month view between scroll feed and paged swipe
New device-local setting "Monatsansicht seitenweise wischen" (@AppStorage, no server sync). Off keeps the continuous vertical scroll feed; on switches the month view to a paged TabView — one month per screen, six height-filling week rows, swipe left/right to change month (bounded ±monthsBack/Ahead range to keep the TabView light). WeekRow gains a fillHeight flag so the same row renders at a fixed height (scroll) or fills the page evenly (paged); its inner cells/divider now size to the actual geometry. Paged selection is two-way bound to the store's current date so prev/next/today and the title stay in sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -127,6 +127,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Montag",
|
"settings.monday": "Montag",
|
||||||
"settings.sunday": "Sonntag",
|
"settings.sunday": "Sonntag",
|
||||||
"settings.dimpast": "Vergangene Termine ausgrauen",
|
"settings.dimpast": "Vergangene Termine ausgrauen",
|
||||||
|
"settings.month_paged": "Monatsansicht seitenweise wischen",
|
||||||
"settings.default_duration": "Standard-Termindauer",
|
"settings.default_duration": "Standard-Termindauer",
|
||||||
"settings.nav.profile": "Profil",
|
"settings.nav.profile": "Profil",
|
||||||
"settings.privacy": "Privatsphäre",
|
"settings.privacy": "Privatsphäre",
|
||||||
@@ -458,6 +459,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Monday",
|
"settings.monday": "Monday",
|
||||||
"settings.sunday": "Sunday",
|
"settings.sunday": "Sunday",
|
||||||
"settings.dimpast": "Dim past events",
|
"settings.dimpast": "Dim past events",
|
||||||
|
"settings.month_paged": "Swipe month view as pages",
|
||||||
"settings.default_duration": "Default event duration",
|
"settings.default_duration": "Default event duration",
|
||||||
"settings.nav.profile": "Profile",
|
"settings.nav.profile": "Profile",
|
||||||
"settings.privacy": "Privacy",
|
"settings.privacy": "Privacy",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import SwiftUI
|
|||||||
// past 2100 — enough room for any vacation that's actually getting planned.
|
// past 2100 — enough room for any vacation that's actually getting planned.
|
||||||
private let weeksBack = 520
|
private let weeksBack = 520
|
||||||
private let weeksAhead = 4000
|
private let weeksAhead = 4000
|
||||||
|
// Paged mode uses one page per month over a bounded range (keeps the TabView light).
|
||||||
|
private let monthsBack = 120
|
||||||
|
private let monthsAhead = 600
|
||||||
private let weekdayHeaderHeight: CGFloat = 28
|
private let weekdayHeaderHeight: CGFloat = 28
|
||||||
private let dayNumberRowHeight: CGFloat = 22
|
private let dayNumberRowHeight: CGFloat = 22
|
||||||
private let laneHeight: CGFloat = 16
|
private let laneHeight: CGFloat = 16
|
||||||
@@ -26,12 +29,28 @@ struct MonthView: View {
|
|||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
@AppStorage("textContrast") private var textContrast = 3
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
@AppStorage("monthViewPaged") private var monthPaged = false
|
||||||
|
|
||||||
@State private var scrolledWeek: Date? = nil
|
@State private var scrolledWeek: Date? = nil
|
||||||
@State private var didInitialScroll = false
|
@State private var didInitialScroll = false
|
||||||
|
@State private var pagedMonth: Date = Calendar.current.date(
|
||||||
|
from: Calendar.current.dateComponents([.year, .month], from: .now)) ?? .now
|
||||||
|
|
||||||
private var cal: Calendar { store.userCalendar }
|
private var cal: Calendar { store.userCalendar }
|
||||||
|
|
||||||
|
/// First day of the month containing `date`.
|
||||||
|
private func monthStart(for date: Date) -> Date {
|
||||||
|
cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry per month across the bounded paged range.
|
||||||
|
private var monthStarts: [Date] {
|
||||||
|
let base = monthStart(for: .now)
|
||||||
|
return (-monthsBack...monthsAhead).compactMap {
|
||||||
|
cal.date(byAdding: .month, value: $0, to: base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var weekStarts: [Date] {
|
private var weekStarts: [Date] {
|
||||||
let today = cal.startOfDay(for: .now)
|
let today = cal.startOfDay(for: .now)
|
||||||
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
|
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
|
||||||
@@ -51,20 +70,16 @@ struct MonthView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
headerRow
|
headerRow
|
||||||
Divider()
|
Divider()
|
||||||
|
if monthPaged { pagedBody } else { scrollBody }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuous vertical scroll feed (default).
|
||||||
|
private var scrollBody: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in
|
LazyVStack(spacing: 0) {
|
||||||
WeekRow(weekStart: ws,
|
ForEach(weekStarts, id: \.self) { ws in
|
||||||
store: store,
|
weekRow(for: ws, fillHeight: false)
|
||||||
dividerColor: Color(hex: dividerHex),
|
|
||||||
labelColor: Color(hex: labelHex),
|
|
||||||
textColor: Color(hex: textHex),
|
|
||||||
lineColor: Color(hex: lineHex),
|
|
||||||
language: appLang,
|
|
||||||
onDayTap: onDayTap,
|
|
||||||
onEventTap: onEventTap,
|
|
||||||
onCreateEvent: onCreateEvent,
|
|
||||||
onShowWeek: onShowWeek,
|
|
||||||
onShowDay: onShowDay)
|
|
||||||
.id(ws)
|
.id(ws)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +106,56 @@ struct MonthView: View {
|
|||||||
publishVisibleMonth(from: newWeek)
|
publishVisibleMonth(from: newWeek)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One month per page, swipe left/right to change month.
|
||||||
|
private var pagedBody: some View {
|
||||||
|
TabView(selection: $pagedMonth) {
|
||||||
|
ForEach(monthStarts, id: \.self) { m in
|
||||||
|
monthGrid(for: m).tag(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.onAppear {
|
||||||
|
pagedMonth = monthStart(for: store.currentDate)
|
||||||
|
if store.visibleMonth != pagedMonth { store.visibleMonth = pagedMonth }
|
||||||
|
}
|
||||||
|
.onChange(of: pagedMonth) { _, m in
|
||||||
|
if store.visibleMonth != m { store.visibleMonth = m }
|
||||||
|
// Drive the shared date so events for the new month load; guard the
|
||||||
|
// month so we don't fight the reverse onChange below.
|
||||||
|
if monthStart(for: store.currentDate) != m { store.currentDate = m }
|
||||||
|
}
|
||||||
|
.onChange(of: store.currentDate) { _, d in
|
||||||
|
let m = monthStart(for: d)
|
||||||
|
if pagedMonth != m { withAnimation(.easeInOut(duration: 0.25)) { pagedMonth = m } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single month page: six height-filling week rows.
|
||||||
|
private func monthGrid(for month: Date) -> some View {
|
||||||
|
let firstWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: month)) ?? month
|
||||||
|
let weeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: firstWeek) }
|
||||||
|
return VStack(spacing: 0) {
|
||||||
|
ForEach(weeks, id: \.self) { ws in
|
||||||
|
weekRow(for: ws, fillHeight: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weekRow(for ws: Date, fillHeight: Bool) -> some View {
|
||||||
|
WeekRow(weekStart: ws,
|
||||||
|
fillHeight: fillHeight,
|
||||||
|
store: store,
|
||||||
|
dividerColor: Color(hex: dividerHex),
|
||||||
|
labelColor: Color(hex: labelHex),
|
||||||
|
textColor: Color(hex: textHex),
|
||||||
|
lineColor: Color(hex: lineHex),
|
||||||
|
language: appLang,
|
||||||
|
onDayTap: onDayTap,
|
||||||
|
onEventTap: onEventTap,
|
||||||
|
onCreateEvent: onCreateEvent,
|
||||||
|
onShowWeek: onShowWeek,
|
||||||
|
onShowDay: onShowDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var headerRow: some View {
|
private var headerRow: some View {
|
||||||
@@ -126,6 +191,8 @@ struct MonthView: View {
|
|||||||
|
|
||||||
private struct WeekRow: View {
|
private struct WeekRow: View {
|
||||||
let weekStart: Date
|
let weekStart: Date
|
||||||
|
// Scroll mode uses a fixed row height; paged mode fills the page evenly.
|
||||||
|
var fillHeight: Bool = false
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
let dividerColor: Color
|
let dividerColor: Color
|
||||||
let labelColor: Color
|
let labelColor: Color
|
||||||
@@ -252,7 +319,7 @@ private struct WeekRow: View {
|
|||||||
onCreateEvent: { onCreateEvent(day) },
|
onCreateEvent: { onCreateEvent(day) },
|
||||||
onShowWeek: { onShowWeek(day) },
|
onShowWeek: { onShowWeek(day) },
|
||||||
onShowDay: { onShowDay(day) })
|
onShowDay: { onShowDay(day) })
|
||||||
.frame(width: cellW, height: rowHeight)
|
.frame(width: cellW, height: geo.size.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,12 +338,13 @@ private struct WeekRow: View {
|
|||||||
if let b = midRowBoundaryCol {
|
if let b = midRowBoundaryCol {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(dividerColor)
|
.fill(dividerColor)
|
||||||
.frame(width: 1.5, height: rowHeight)
|
.frame(width: 1.5, height: geo.size.height)
|
||||||
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
|
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: rowHeight)
|
.frame(height: fillHeight ? nil : rowHeight)
|
||||||
|
.frame(maxHeight: fillHeight ? .infinity : nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage("defaultView") private var defaultView = "month"
|
@AppStorage("defaultView") private var defaultView = "month"
|
||||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||||
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||||||
|
// Device-local: month view as horizontal paged (swipe) vs. scroll feed.
|
||||||
|
@AppStorage("monthViewPaged") private var monthViewPaged = false
|
||||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
|
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
|
||||||
|
|
||||||
@@ -387,6 +389,8 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
|
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
|
||||||
.tint(Color.accentColor)
|
.tint(Color.accentColor)
|
||||||
|
Toggle(L10n.t("settings.month_paged", appLang), isOn: $monthViewPaged)
|
||||||
|
.tint(Color.accentColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user