iOS: left side drawer (calendars + groups + nav) and opt-in top-bar colour
Replace the top-bar menu popup with an off-canvas left drawer, opened by the hamburger or a narrow left-edge swipe (kept off the content area so month/week swipe stay free). The drawer hosts the calendar visibility list, a view switcher, group switching, and quick access to Settings/Accounts/Profile/ Groups/Server plus sync and logout. - extract the filter list into reusable CalendarFilterContent (shared by the drawer and the still-present CalendarFilterSheet); the sheet is now a thin wrapper - new CalendarDrawer view; CalendarHostView hosts it as a ZStack overlay with a scrim and edge-swipe gesture, and presents drawer destinations as sheets - opt-in surface colour: surfaceColor (device-local) tints the flat top bar; empty keeps the translucent .bar material. Settings row with an Auto reset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
151
Calendarr iOS/Views/Calendar/CalendarDrawer.swift
Normal file
151
Calendarr iOS/Views/Calendar/CalendarDrawer.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Destinations the drawer can open (presented as sheets by the host).
|
||||
enum DrawerDestination: Int, Identifiable {
|
||||
case profile, settings, accounts, groups, server
|
||||
var id: Int { rawValue }
|
||||
}
|
||||
|
||||
/// The left side drawer: central navigation + calendar visibility + group
|
||||
/// switching. Replaces the old menu popup and filter sheet.
|
||||
struct CalendarDrawer: View {
|
||||
let api: CalendarrAPI
|
||||
let store: CalendarStore
|
||||
let groups: [CalGroup]
|
||||
let onSwitchGroup: (CalGroup?) -> Void
|
||||
let onSelectView: (CalViewType) -> Void
|
||||
let onOpenDestination: (DrawerDestination) -> Void
|
||||
let onSync: () -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
@Environment(AppState.self) private var appState
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
viewSwitcher
|
||||
if !groups.isEmpty {
|
||||
Divider()
|
||||
groupSwitcher
|
||||
}
|
||||
Divider()
|
||||
CalendarFilterContent(api: api, store: store)
|
||||
Divider()
|
||||
navFooter
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
|
||||
// MARK: – Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
Text(appState.username.prefix(1).uppercased())
|
||||
.font(.headline).foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(appState.username).font(.headline).lineLimit(1)
|
||||
Text(appState.serverURL
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
.replacingOccurrences(of: "http://", with: ""))
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Button { onClose() } label: {
|
||||
Image(systemName: "xmark").font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.plain).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10)
|
||||
}
|
||||
|
||||
// MARK: – View switcher
|
||||
|
||||
private var viewSwitcher: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(CalViewType.allCases, id: \.self) { vt in
|
||||
let selected = store.viewType == vt
|
||||
Button { onSelectView(vt) } label: {
|
||||
Label(vt.label(appLang), systemImage: vt.systemImage)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
|
||||
.foregroundStyle(selected ? .white : .primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Group switcher
|
||||
|
||||
private var groupSwitcher: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
chip(label: L10n.t("groups.personal", appLang),
|
||||
systemImage: "person",
|
||||
selected: store.activeGroup == nil) { onSwitchGroup(nil) }
|
||||
ForEach(groups) { g in
|
||||
chip(label: g.name,
|
||||
systemImage: GroupIcons.symbol(g.icon),
|
||||
selected: store.activeGroup?.id == g.id) { onSwitchGroup(g) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func chip(label: String, systemImage: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Label(label, systemImage: systemImage)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
|
||||
.foregroundStyle(selected ? .white : .primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: – Nav footer (quick access)
|
||||
|
||||
private var navFooter: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
navButton(L10n.t("menu.appearance", appLang), "paintpalette") { onOpenDestination(.settings) }
|
||||
navButton(L10n.t("menu.accounts", appLang), "tray.2") { onOpenDestination(.accounts) }
|
||||
navButton(L10n.t("menu.profile", appLang), "person.circle") { onOpenDestination(.profile) }
|
||||
navButton(L10n.t("groups.title", appLang), "person.2") { onOpenDestination(.groups) }
|
||||
navButton(L10n.t("menu.server", appLang), "server.rack") { onOpenDestination(.server) }
|
||||
navButton(L10n.t("menu.sync", appLang), "arrow.triangle.2.circlepath") { onSync() }
|
||||
navButton(L10n.t("menu.logout", appLang), "rectangle.portrait.and.arrow.right", role: .destructive) {
|
||||
appState.logout()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private func navButton(_ label: String, _ systemImage: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View {
|
||||
Button(role: role, action: action) {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: systemImage).font(.system(size: 18))
|
||||
Text(label).font(.caption2).lineLimit(1)
|
||||
}
|
||||
.frame(width: 64)
|
||||
.foregroundStyle(role == .destructive ? Color.red : Color.accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ struct CalendarHostView: View {
|
||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||
@AppStorage("defaultView") private var defaultView = "month"
|
||||
// Opt-in: empty keeps the translucent `.bar` material; a hex tints the top bar.
|
||||
@AppStorage("surfaceColor") private var surfaceHex = ""
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -31,6 +33,8 @@ struct CalendarHostView: View {
|
||||
@State private var didApplyDefaultView = false
|
||||
@State private var groups: [CalGroup] = []
|
||||
@State private var showNewBirthday = false
|
||||
@State private var showDrawer = false
|
||||
@State private var drawerDestination: DrawerDestination? = nil
|
||||
|
||||
private var titleString: String {
|
||||
if store.viewType == .month {
|
||||
@@ -43,11 +47,66 @@ struct CalendarHostView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if liquidGlass {
|
||||
glassVariant
|
||||
} else {
|
||||
flatVariant
|
||||
ZStack(alignment: .leading) {
|
||||
Group {
|
||||
if liquidGlass { glassVariant } else { flatVariant }
|
||||
}
|
||||
// Dim scrim behind the open drawer.
|
||||
if showDrawer {
|
||||
Color.black.opacity(0.35)
|
||||
.ignoresSafeArea()
|
||||
.transition(.opacity)
|
||||
.onTapGesture { closeDrawer() }
|
||||
}
|
||||
// Off-canvas left drawer.
|
||||
CalendarDrawer(
|
||||
api: api, store: store, groups: groups,
|
||||
onSwitchGroup: { g in closeDrawer(); switchGroup(g) },
|
||||
onSelectView: { vt in store.viewType = vt; closeDrawer() },
|
||||
onOpenDestination: { dest in closeDrawer(); drawerDestination = dest },
|
||||
onSync: { closeDrawer(); Task { await syncFromServer(force: true) } },
|
||||
onClose: { closeDrawer() }
|
||||
)
|
||||
.frame(width: drawerWidth)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.shadow(color: .black.opacity(showDrawer ? 0.25 : 0), radius: 12, x: 4)
|
||||
.offset(x: showDrawer ? 0 : -(drawerWidth + 60))
|
||||
.animation(.easeInOut(duration: 0.25), value: showDrawer)
|
||||
}
|
||||
// A narrow leading strip opens the drawer via edge-swipe (kept off the
|
||||
// content area so month paging / week swipe stay free).
|
||||
.overlay(alignment: .leading) {
|
||||
if !showDrawer {
|
||||
Color.clear
|
||||
.frame(width: 18)
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 12, coordinateSpace: .local)
|
||||
.onEnded { v in
|
||||
if v.translation.width > 45,
|
||||
abs(v.translation.width) > abs(v.translation.height) {
|
||||
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(item: $drawerDestination) { dest in
|
||||
switch dest {
|
||||
case .profile: ProfileView(api: api)
|
||||
case .settings: SettingsView(api: api)
|
||||
case .accounts: AccountsView(api: api)
|
||||
case .groups: GroupsView(api: api)
|
||||
case .server: ServerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var drawerWidth: CGFloat { min(UIScreen.main.bounds.width - 40, 360) }
|
||||
|
||||
private func closeDrawer() {
|
||||
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = false }
|
||||
}
|
||||
|
||||
// MARK: – Loading indicator
|
||||
@@ -205,7 +264,9 @@ struct CalendarHostView: View {
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
barContents.background(.bar)
|
||||
barContents.background(
|
||||
surfaceHex.isEmpty ? AnyShapeStyle(Material.bar) : AnyShapeStyle(Color(hex: surfaceHex))
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private var groupBanner: some View {
|
||||
@@ -231,51 +292,11 @@ struct CalendarHostView: View {
|
||||
Task { await forceReload() }
|
||||
}
|
||||
|
||||
/// The single top-bar action: a compact popup holding view / filter /
|
||||
/// groups / sync, plus an "Einstellungen" entry that opens the full menu.
|
||||
/// (Replaces the separate view / filter / group icons in the bar.)
|
||||
/// Opens the side drawer (calendars + groups + navigation). Tinted when a
|
||||
/// filter/group is active so the user sees state at a glance.
|
||||
private var menuButton: some View {
|
||||
Menu {
|
||||
// View (fixed icon, not per-view)
|
||||
Menu {
|
||||
ForEach(CalViewType.allCases, id: \.self) { vt in
|
||||
Button { store.viewType = vt } label: {
|
||||
Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
|
||||
}
|
||||
// Filter
|
||||
Button { showFilter = true } label: {
|
||||
Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
// Groups
|
||||
if !groups.isEmpty {
|
||||
Menu {
|
||||
Button { switchGroup(nil) } label: {
|
||||
Label(L10n.t("groups.personal", appLang),
|
||||
systemImage: store.activeGroup == nil ? "checkmark" : "person")
|
||||
}
|
||||
ForEach(groups) { g in
|
||||
Button { switchGroup(g) } label: {
|
||||
Label(g.name,
|
||||
systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
|
||||
}
|
||||
}
|
||||
// Sync
|
||||
Button { Task { await syncFromServer(force: true) } } label: {
|
||||
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
Divider()
|
||||
// Full settings menu
|
||||
Button { showMenu = true } label: {
|
||||
Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape")
|
||||
}
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
|
||||
Reference in New Issue
Block a user