diff --git a/macos/Overview/Extensions/DateFormatter.swift b/macos/Overview/Extensions/DateFormatter.swift new file mode 100644 index 0000000..fa4f39b --- /dev/null +++ b/macos/Overview/Extensions/DateFormatter.swift @@ -0,0 +1,36 @@ +// Copyright (c) 2021-2025 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +extension DateFormatter { + + static var weeklyTitleDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "'Week' w '('EEEE',' d MMMM')'" + return dateFormatter + }() + + static var monthlyTitleDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMMM" + return dateFormatter + }() +} diff --git a/macos/Overview/Extensions/String.swift b/macos/Overview/Extensions/String.swift new file mode 100644 index 0000000..7157f7f --- /dev/null +++ b/macos/Overview/Extensions/String.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2021-2025 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +extension String { + + static let selections = "Selections" + static let granularity = "Granularity" + static let year = "Year" + +} diff --git a/macos/Overview/Model/ApplicationModel.swift b/macos/Overview/Model/ApplicationModel.swift index 427edc7..89c7110 100644 --- a/macos/Overview/Model/ApplicationModel.swift +++ b/macos/Overview/Model/ApplicationModel.swift @@ -51,6 +51,7 @@ class ApplicationModel: NSObject, ObservableObject { @Published var calendars: [CalendarInstance] = [] @Published var years: [Int] = [Date.now.year] @Published var useDemoData: Bool = false + let updates: AnyPublisher @Published public var suppressUpdateCheck: Bool { @@ -177,10 +178,11 @@ class ApplicationModel: NSObject, ObservableObject { cancellables.removeAll() } - func summary(year: Int, calendars: [CalendarInstance]) throws -> [MonthlySummary] { + func summary(year: Int, calendars: [CalendarInstance], granularity: Granularity) throws -> [PeriodSummary] { return try store(type: useDemoData ? .demo : .eventKit).summary(calendar: calendar, year: year, - calendars: calendars) + calendars: calendars, + granularity: granularity) } } diff --git a/macos/Overview/Model/CalendarStore.swift b/macos/Overview/Model/CalendarStore.swift index ce63caf..aca76a9 100644 --- a/macos/Overview/Model/CalendarStore.swift +++ b/macos/Overview/Model/CalendarStore.swift @@ -59,9 +59,9 @@ extension CalendarStore { private func summaries(calendar: Calendar, dateInterval: DateInterval, - calendars: [CalendarInstance]) throws -> [MonthlySummary] { - var results: [MonthlySummary] = [] - let granularity = DateComponents(month: 1) + calendars: [CalendarInstance], + granularity: DateComponents) throws -> [PeriodSummary] { + var results: [PeriodSummary] = [] calendar.enumerate(dateInterval: dateInterval, components: granularity) { dateInterval in let summaries = try! self.summaries(calendar: calendar, dateInterval: dateInterval, @@ -72,14 +72,36 @@ extension CalendarStore { return results } - func summary(calendar: Calendar, - year: Int, - calendars: [CalendarInstance]) throws -> [MonthlySummary] { - guard let start = calendar.date(from: DateComponents(year: year, month: 1)) else { + func startDate(calendar: Calendar, year: Int, granularity: Granularity) throws -> Date { + + var components = DateComponents() + switch granularity { + case .weekly: + components.yearForWeekOfYear = year + components.weekOfYear = 1 + components.weekday = calendar.firstWeekday + case .monthly: + components.year = year + components.month = 1 + } + + guard let startDate = calendar.date(from: components) else { throw CalendarError.invalidDate } - let dateInterval = try calendar.dateInterval(start: start, duration: DateComponents(year: 1)) - let summaries = try summaries(calendar: calendar, dateInterval: dateInterval, calendars: calendars) + + return startDate + } + + func summary(calendar: Calendar, + year: Int, + calendars: [CalendarInstance], + granularity: Granularity) throws -> [PeriodSummary] { + let startDate = try startDate(calendar: calendar, year: year, granularity: granularity) + let dateInterval = try calendar.dateInterval(start: startDate, duration: DateComponents(year: 1)) + let summaries = try summaries(calendar: calendar, + dateInterval: dateInterval, + calendars: calendars, + granularity: granularity.dateComponents) return summaries } diff --git a/macos/Overview/Model/Granularity.swift b/macos/Overview/Model/Granularity.swift new file mode 100644 index 0000000..d93f817 --- /dev/null +++ b/macos/Overview/Model/Granularity.swift @@ -0,0 +1,52 @@ +// Copyright (c) 2021-2025 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import EventKit +import SwiftUI +import Interact + +enum Granularity: String, CaseIterable, Identifiable { + + var id: Self { + return self + } + + var dateComponents: DateComponents { + switch self { + case .weekly: + return DateComponents(day: 7) + case .monthly: + return DateComponents(month: 1) + } + } + + var name: LocalizedStringKey { + switch self { + case .weekly: + return "Weekly" + case .monthly: + return "Monthly" + } + } + + case weekly + case monthly + +} diff --git a/macos/Overview/Model/MonthlySummary.swift b/macos/Overview/Model/PeriodSummary.swift similarity index 93% rename from macos/Overview/Model/MonthlySummary.swift rename to macos/Overview/Model/PeriodSummary.swift index 785346c..f0f103a 100644 --- a/macos/Overview/Model/MonthlySummary.swift +++ b/macos/Overview/Model/PeriodSummary.swift @@ -20,9 +20,9 @@ import Foundation -typealias MonthlySummary = Summary<[CalendarInstance], SimilarEvents> +typealias PeriodSummary = Summary<[CalendarInstance], SimilarEvents> -extension MonthlySummary { +extension PeriodSummary { func duration(calendar: Calendar) -> DateComponents { calendar.date(byAdding: items.map { $0.duration(calendar: calendar) }, to: dateInterval.start) diff --git a/macos/Overview/Model/SimilarEvents.swift b/macos/Overview/Model/SimilarEvents.swift index cb0c560..43ec9db 100644 --- a/macos/Overview/Model/SimilarEvents.swift +++ b/macos/Overview/Model/SimilarEvents.swift @@ -31,5 +31,4 @@ extension SimilarEvents { to: dateInterval.start) } - } diff --git a/macos/Overview/Model/WindowModel.swift b/macos/Overview/Model/SummaryViewModel.swift similarity index 51% rename from macos/Overview/Model/WindowModel.swift rename to macos/Overview/Model/SummaryViewModel.swift index ee46347..1f2b586 100644 --- a/macos/Overview/Model/WindowModel.swift +++ b/macos/Overview/Model/SummaryViewModel.swift @@ -23,82 +23,56 @@ import EventKit import Foundation import SwiftUI -class WindowModel: ObservableObject { +import Interact - var applicationModel: ApplicationModel +@Observable +class SummaryViewModel: Runnable { - @AppStorage("Selections") var selectionsStorage: Set = [] + private let applicationModel: ApplicationModel + private let calendars: [CalendarInstance] + private let granularity: Granularity + private let year: Int - @Published var selections: Set = Set() - @Published var year: Int = Date.now.year - - @Published var title: String = "" - @Published var loading = false - @Published var summaries: [MonthlySummary] = [] + var loading = true private let updateQueue = DispatchQueue(label: "WindowModel.updateQueue") private var cancellables: Set = [] - init(applicationModel: ApplicationModel) { + init(applicationModel: ApplicationModel, calendars: [CalendarInstance], granularity: Granularity, year: Int) { self.applicationModel = applicationModel - self.selections = selectionsStorage - self.loading = true + self.calendars = calendars + self.granularity = granularity + self.year = year + } + + var title: String { + return Array(calendars).map({ $0.title }).joined(separator: ", ") } + var summaries: [PeriodSummary] = [] + func start() { dispatchPrecondition(condition: .onQueue(.main)) - // Update the summaries. - $year - .combineLatest(applicationModel.$calendars, $selections, applicationModel.updates) + applicationModel.updates .receive(on: DispatchQueue.main) .map { contents in self.loading = true return contents } .receive(on: updateQueue) - .map { (year, calendars, selections, _) in - return (year, calendars.filter { selections.contains($0.calendarIdentifier) }) - } - .map { (year, calendars) in - guard !calendars.isEmpty else { - return [] - } - return (try? self.applicationModel.summary(year: year, calendars: calendars)) ?? [] + .map { (_) in + return (try? self.applicationModel.summary(year: self.year, + calendars: self.calendars, + granularity: self.granularity)) ?? [] } .receive(on: DispatchQueue.main) - .sink { [weak self] (summaries: [MonthlySummary]) in - guard let self = self else { - return - } + .sink { (summaries: [PeriodSummary]) in self.summaries = summaries self.loading = false } .store(in: &cancellables) - // Update the title. - $selections - .combineLatest(applicationModel.$calendars) - .map { (selections, calendars) in - return calendars.filter { selections.contains($0.calendarIdentifier) } - } - .map { calendars in - return Array(calendars).map({ $0.title }).joined(separator: ", ") - } - .receive(on: DispatchQueue.main) - .sink { title in - self.title = title - } - .store(in: &cancellables) - - // Store the selections. - $selections - .receive(on: DispatchQueue.main) - .sink { selections in - self.selectionsStorage = selections - } - .store(in: &cancellables) - } func stop() { @@ -106,5 +80,4 @@ class WindowModel: ObservableObject { cancellables.removeAll() } - } diff --git a/macos/Overview/Toolbars/ApplicationToolbar.swift b/macos/Overview/Toolbars/ApplicationToolbar.swift new file mode 100644 index 0000000..5c80cca --- /dev/null +++ b/macos/Overview/Toolbars/ApplicationToolbar.swift @@ -0,0 +1,57 @@ +// Copyright (c) 2021-2025 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +struct ApplicationToolbar: ToolbarContent { + + @ObservedObject var applicationModel: ApplicationModel + + @AppStorage(.granularity) var granularity: Granularity = .monthly + @AppStorage(.year) var year: Int = Date.now.year + + init(applicationModel: ApplicationModel) { + self.applicationModel = applicationModel + } + + var body: some ToolbarContent { + + ToolbarItem { + Picker("Granularity", selection: $granularity) { + ForEach(Granularity.allCases) { granularity in + Text(granularity.name) + } + } + .pickerStyle(.segmented) + } + + ToolbarItem { + Picker(selection: $year) { + ForEach(applicationModel.years) { year in + Text(String(year)).tag(year) + } + } label: { + Text("Year", comment: "Toolbar year picker label.") + } + } + + } + +} diff --git a/macos/Overview/Views/CalendarList.swift b/macos/Overview/Views/CalendarList.swift index 7596bec..596abd3 100644 --- a/macos/Overview/Views/CalendarList.swift +++ b/macos/Overview/Views/CalendarList.swift @@ -25,6 +25,10 @@ import Interact struct CalendarList: View { + struct LayoutMetrics { + static let minWidth = 200.0 + } + @ObservedObject var applicationModel: ApplicationModel @Binding var selections: Set @@ -46,5 +50,6 @@ struct CalendarList: View { .toggleStyle(CheckboxStyle(color: Color(calendar.color))) } } + .frame(minWidth: LayoutMetrics.minWidth) } } diff --git a/macos/Overview/Views/ContentView.swift b/macos/Overview/Views/ContentView.swift index 814812e..938a9fb 100644 --- a/macos/Overview/Views/ContentView.swift +++ b/macos/Overview/Views/ContentView.swift @@ -21,80 +21,33 @@ import EventKit import SwiftUI -import Interact - struct ContentView: View { @ObservedObject var applicationModel: ApplicationModel - @StateObject var windowModel: WindowModel - @Environment(\.openURL) var openURL + @AppStorage(.selections) var selections: Set = [] + @AppStorage(.granularity) var granularity: Granularity = .monthly + @AppStorage(.year) var year: Int = Date.now.year init(applicationModel: ApplicationModel) { self.applicationModel = applicationModel - _windowModel = StateObject(wrappedValue: WindowModel(applicationModel: applicationModel)) + } + + var calendars: [CalendarInstance] { + return applicationModel.calendars.filter { selections.contains($0.calendarIdentifier) } } var body: some View { NavigationSplitView { - CalendarList(applicationModel: applicationModel, selections: $windowModel.selections) - .frame(minWidth: 200) + CalendarList(applicationModel: applicationModel, selections: $selections) } detail: { - HStack { - if windowModel.loading { - PlaceholderView { - ProgressView() - .progressViewStyle(.circular) - } - } else if !windowModel.summaries.isEmpty { - YearView(summaries: windowModel.summaries) - } else { - switch applicationModel.state { - case .unknown: - ProgressView() - .progressViewStyle(.circular) - case .authorized: - ContentUnavailableView { - Label("No Calendars Selected", systemImage: "calendar") - } description: { - Text("Select one or more calendars from the sidebar.") - } - case .unauthorized: - ContentUnavailableView { - Label("Limited Calendar Access", systemImage: "calendar") - } description: { - Text("Overview needs full access to your calendar to be able to display and summarize your events.", - comment: "Calendar privacy usage description shown when the user has denied acccess.") - Button { - openURL(.settingsPrivacyCalendars) - } label: { - Text("Open Privacy Settings", comment: "Title of the button that opens System Settings.") - } - } - } - } - } - .frame(minWidth: 500, minHeight: 400) - .navigationTitle(Text("Overview", comment: "Main window title.")) - .navigationSubtitle(windowModel.title) - .toolbar { - ToolbarItem { - Picker(selection: $windowModel.year) { - ForEach(applicationModel.years) { year in - Text(String(year)).tag(year) - } - } label: { - Text("Year", comment: "Toolbar year picker label.") - } - } - } + SummaryView(applicationModel: applicationModel, calendars: calendars, granularity: granularity, year: year) + .id(String(describing: selections) + String(describing: granularity) + String(describing: year)) } - .presents($applicationModel.error) - .onAppear { - windowModel.start() - } - .onDisappear { - windowModel.stop() + .toolbar { + ApplicationToolbar(applicationModel: applicationModel) } + .presents($applicationModel.error) } + } diff --git a/macos/Overview/Views/MonthView.swift b/macos/Overview/Views/PeriodSummaryView.swift similarity index 86% rename from macos/Overview/Views/MonthView.swift rename to macos/Overview/Views/PeriodSummaryView.swift index 6beb128..79e67b7 100644 --- a/macos/Overview/Views/MonthView.swift +++ b/macos/Overview/Views/PeriodSummaryView.swift @@ -21,19 +21,14 @@ import EventKit import SwiftUI -struct MonthView: View { +struct PeriodSummaryView: View { let calendar = Calendar.current - @State var summary: MonthlySummary + let title: String + let summary: PeriodSummary - var dateFormatter: DateFormatter = { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMMM" - return dateFormatter - }() - - var dateComponentsFormatter: DateComponentsFormatter = { + static var dateComponentsFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.calendar = Calendar.current formatter.unitsStyle = .full @@ -41,10 +36,14 @@ struct MonthView: View { return formatter }() - var title: String { dateFormatter.string(from: summary.dateInterval.start) } + + init(_ title: String, summary: PeriodSummary) { + self.title = title + self.summary = summary + } func format(dateComponents: DateComponents, startDate: Date) -> String { - guard let result = dateComponentsFormatter.string(from: dateComponents, startDate: startDate) else { + guard let result = Self.dateComponentsFormatter.string(from: dateComponents, startDate: startDate) else { return "Unknown" } return result diff --git a/macos/Overview/Views/SummaryView.swift b/macos/Overview/Views/SummaryView.swift new file mode 100644 index 0000000..b2721ca --- /dev/null +++ b/macos/Overview/Views/SummaryView.swift @@ -0,0 +1,103 @@ +// Copyright (c) 2021-2025 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import EventKit +import SwiftUI + +import Interact + +struct SummaryView: View { + + struct LayoutMetrics { + static let minWidth = 500.0 + static let minHeight = 400.0 + } + + @Environment(\.openURL) var openURL + + @ObservedObject var applicationModel: ApplicationModel + + private let granularity: Granularity + + @State private var windowModel: SummaryViewModel + + init(applicationModel: ApplicationModel, calendars: [CalendarInstance], granularity: Granularity, year: Int) { + self.applicationModel = applicationModel + self.granularity = granularity + _windowModel = State(wrappedValue: SummaryViewModel(applicationModel: applicationModel, + calendars: calendars, + granularity: granularity, + year: year)) + } + + func title(for summary: PeriodSummary) -> String { + switch granularity { + case .weekly: + return DateFormatter.weeklyTitleDateFormatter.string(from: summary.dateInterval.start) + case .monthly: + return DateFormatter.monthlyTitleDateFormatter.string(from: summary.dateInterval.start) + } + } + + var body: some View { + HStack { + if windowModel.loading { + PlaceholderView { + ProgressView() + .progressViewStyle(.circular) + } + } else if !windowModel.summaries.isEmpty { + YearSummaryView(summaries: windowModel.summaries) { summary in + title(for: summary) + } + } else { + switch applicationModel.state { + case .unknown: + ProgressView() + .progressViewStyle(.circular) + case .authorized: + ContentUnavailableView { + Label("No Calendars Selected", systemImage: "calendar") + } description: { + Text("Select one or more calendars from the sidebar.") + } + case .unauthorized: + ContentUnavailableView { + Label("Limited Calendar Access", systemImage: "calendar") + } description: { + Text("Overview needs full access to your calendar to be able to display and summarize your events.", + comment: "Calendar privacy usage description shown when the user has denied acccess.") + Button { + openURL(.settingsPrivacyCalendars) + } label: { + Text("Open Privacy Settings", comment: "Title of the button that opens System Settings.") + } + } + } + } + } + .frame(minWidth: LayoutMetrics.minWidth, minHeight: LayoutMetrics.minHeight) + .navigationTitle(Text("Overview", comment: "Main window title.")) + .navigationSubtitle(windowModel.title) + .runs(windowModel) + .environment(windowModel) + } + +} diff --git a/macos/Overview/Views/YearView.swift b/macos/Overview/Views/YearSummaryView.swift similarity index 81% rename from macos/Overview/Views/YearView.swift rename to macos/Overview/Views/YearSummaryView.swift index e8b88f9..d89993e 100644 --- a/macos/Overview/Views/YearView.swift +++ b/macos/Overview/Views/YearSummaryView.swift @@ -21,15 +21,22 @@ import EventKit import SwiftUI -struct YearView: View { +struct YearSummaryView: View { - var summaries: [MonthlySummary] = [] + var summaries: [PeriodSummary] = [] + + let title: (PeriodSummary) -> String + + init(summaries: [PeriodSummary], title: @escaping (PeriodSummary) -> String) { + self.summaries = summaries + self.title = title + } var body: some View { ScrollView { VStack { ForEach(summaries) { summary in - MonthView(summary: summary) + PeriodSummaryView(title(summary), summary: summary) .padding(.bottom) } }