commit 552a55ead6da9b13e0c689a95b8704b255f30097 Author: ssig33 Date: Fri Oct 24 23:25:57 2025 +0900 カレンダーイベント管理アプリの最初のプロジェクト構成と基本実装 - .gitignore や project.yml、Makefile などによる基本的なプロジェクト構成ファイルを追加 - macOSカレンダーイベントを表示するSwiftUIアプリの主要コンポーネント(イベント取得・リスト・カレンダー設定画面など)を実装 - カレンダーの表示期間や「今日へスクロール」「設定画面」遷移などの基本動作に対応 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18ce5ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.xcodeproj +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cabd09d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +- xcodegen で管理されます + - `make` でビルド可能ですが、基本的にはユーザーにビルドをさせて、あなたはしない想定です。ユーザーから指示があればビルドをするとよいでしょう。 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..039e2d5 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,8 @@ +- Single ColumnのSwiftUIアプリ +- EventKitを使用し、macOSのカレンダーイベントを表示 +- 今日から前後2週間のイベントを表示 + - 起動時今日の位置にスクロール +- メニューに「設定」への遷移ボタン + - 表示するカレンダーの選択 + - 設定にふさわしいUIでフワッとイイ感じに +- メニューに「今日」への遷移のボタン diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ce20f0c --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.PHONY: generate build all + +all: build + +generate: + xcodegen generate +build: generate + xcodebuild -scheme OreCalendarMac -destination 'platform=macOS' build diff --git a/Sources/CalendarService.swift b/Sources/CalendarService.swift new file mode 100644 index 0000000..bddef8a --- /dev/null +++ b/Sources/CalendarService.swift @@ -0,0 +1,70 @@ +import EventKit +import Foundation + +class CalendarService: ObservableObject { + private let eventStore = EKEventStore() + @Published var events: [EKEvent] = [] + @Published var selectedCalendars: Set = [] + @Published var availableCalendars: [EKCalendar] = [] + @Published var hasAccess = false + + init() { + Task { + await requestAccess() + } + } + + @MainActor + func requestAccess() async { + do { + let granted = try await eventStore.requestFullAccessToEvents() + hasAccess = granted + if granted { + loadCalendars() + loadDefaultSelectedCalendars() + loadEvents() + } + } catch { + print("Calendar access error: \(error)") + hasAccess = false + } + } + + @MainActor + func loadCalendars() { + availableCalendars = eventStore.calendars(for: .event) + } + + @MainActor + func loadDefaultSelectedCalendars() { + if let savedCalendars = UserDefaults.standard.array(forKey: "selectedCalendars") as? [String] { + selectedCalendars = Set(savedCalendars) + } else { + selectedCalendars = Set(availableCalendars.map { $0.calendarIdentifier }) + } + } + + @MainActor + func saveSelectedCalendars() { + UserDefaults.standard.set(Array(selectedCalendars), forKey: "selectedCalendars") + loadEvents() + } + + @MainActor + func loadEvents() { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + + guard let startDate = calendar.date(byAdding: .day, value: -14, to: today), + let endDate = calendar.date(byAdding: .day, value: 14, to: today) else { + return + } + + let calendars = availableCalendars.filter { selectedCalendars.contains($0.calendarIdentifier) } + + let predicate = eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: calendars) + let fetchedEvents = eventStore.events(matching: predicate) + + events = fetchedEvents.sorted { $0.startDate < $1.startDate } + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift new file mode 100644 index 0000000..b1d3a3c --- /dev/null +++ b/Sources/ContentView.swift @@ -0,0 +1,164 @@ +import SwiftUI +import EventKit + +struct ContentView: View { + @StateObject private var calendarService = CalendarService() + @State private var scrollToToday = false + @State private var showingSettings = false + + var body: some View { + VStack(spacing: 0) { + if calendarService.hasAccess { + ScrollViewReader { proxy in + List { + ForEach(groupedEventsByDay(), id: \.date) { dayGroup in + Section(header: dayHeader(for: dayGroup.date)) { + ForEach(dayGroup.events, id: \.eventIdentifier) { event in + EventRow(event: event) + } + } + .id(dayGroup.date) + } + } + .listStyle(.plain) + .onAppear { + if !scrollToToday { + let today = Calendar.current.startOfDay(for: Date()) + proxy.scrollTo(today, anchor: .top) + scrollToToday = true + } + } + .onChange(of: scrollToToday) { _, newValue in + if newValue { + let today = Calendar.current.startOfDay(for: Date()) + withAnimation { + proxy.scrollTo(today, anchor: .top) + } + scrollToToday = false + } + } + } + } else { + VStack { + Text("カレンダーへのアクセス権限が必要です") + .font(.headline) + Text("システム設定からアクセスを許可してください") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + } + } + .sheet(isPresented: $showingSettings) { + SettingsView(calendarService: calendarService) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { + showingSettings = true + }) { + Label("設定", systemImage: "gearshape") + } + } + ToolbarItem(placement: .primaryAction) { + Button(action: { + scrollToToday = true + }) { + Label("今日", systemImage: "calendar") + } + } + } + } + + private func groupedEventsByDay() -> [DayGroup] { + let calendar = Calendar.current + let grouped = Dictionary(grouping: calendarService.events) { event in + calendar.startOfDay(for: event.startDate) + } + + let today = calendar.startOfDay(for: Date()) + guard let startDate = calendar.date(byAdding: .day, value: -14, to: today), + let endDate = calendar.date(byAdding: .day, value: 14, to: today) else { + return [] + } + + var allDates: [Date] = [] + var currentDate = startDate + while currentDate <= endDate { + allDates.append(currentDate) + guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break } + currentDate = nextDate + } + + return allDates.map { date in + DayGroup(date: date, events: grouped[date] ?? []) + }.sorted { $0.date < $1.date } + } + + private func dayHeader(for date: Date) -> some View { + let calendar = Calendar.current + let isToday = calendar.isDateInToday(date) + let formatter = DateFormatter() + formatter.dateFormat = "M月d日(E)" + formatter.locale = Locale(identifier: "ja_JP") + + return HStack { + Text(formatter.string(from: date)) + .font(.headline) + .foregroundColor(isToday ? .blue : .primary) + if isToday { + Text("今日") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(4) + } + } + } +} + +struct DayGroup { + let date: Date + let events: [EKEvent] +} + +struct EventRow: View { + let event: EKEvent + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + if event.isAllDay { + Text("終日") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text(timeString(from: event.startDate, to: event.endDate)) + .font(.caption) + .foregroundColor(.secondary) + } + if let calendar = event.calendar { + Circle() + .fill(Color(calendar.color)) + .frame(width: 8, height: 8) + } + } + Text(event.title ?? "無題のイベント") + .font(.body) + if let location = event.location, !location.isEmpty { + Text(location) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } + + private func timeString(from start: Date, to end: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return "\(formatter.string(from: start)) - \(formatter.string(from: end))" + } +} diff --git a/Sources/OreCalendarApp.swift b/Sources/OreCalendarApp.swift new file mode 100644 index 0000000..82ab1ff --- /dev/null +++ b/Sources/OreCalendarApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct OreCalendarApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 600, minHeight: 400) + } + } +} diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift new file mode 100644 index 0000000..0f7a29e --- /dev/null +++ b/Sources/SettingsView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import EventKit + +struct SettingsView: View { + @ObservedObject var calendarService: CalendarService + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("設定") + .font(.headline) + .padding() + Spacer() + Button("キャンセル") { + calendarService.loadDefaultSelectedCalendars() + dismiss() + } + .padding(.trailing, 8) + Button("完了") { + calendarService.saveSelectedCalendars() + dismiss() + } + .keyboardShortcut(.defaultAction) + .padding(.trailing) + } + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + VStack(alignment: .leading, spacing: 0) { + Text("表示するカレンダー") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal) + .padding(.top) + .padding(.bottom, 8) + + if calendarService.availableCalendars.isEmpty { + Text("カレンダーがありません") + .foregroundColor(.secondary) + .padding() + } else { + List { + ForEach(calendarService.availableCalendars, id: \.calendarIdentifier) { calendar in + CalendarRow( + calendar: calendar, + isSelected: calendarService.selectedCalendars.contains(calendar.calendarIdentifier) + ) { isSelected in + if isSelected { + calendarService.selectedCalendars.insert(calendar.calendarIdentifier) + } else { + calendarService.selectedCalendars.remove(calendar.calendarIdentifier) + } + } + } + } + .listStyle(.plain) + } + } + } + .frame(minWidth: 400, minHeight: 300) + } +} + +struct CalendarRow: View { + let calendar: EKCalendar + let isSelected: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack { + Circle() + .fill(Color(calendar.color)) + .frame(width: 12, height: 12) + Text(calendar.title) + .font(.body) + Spacer() + Toggle("", isOn: .init( + get: { isSelected }, + set: { onToggle($0) } + )) + .labelsHidden() + } + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..309db22 --- /dev/null +++ b/project.yml @@ -0,0 +1,32 @@ +name: OreCalendar +options: + deploymentTarget: + macOS: "26.0" +schemes: + OreCalendarMac: + build: + targets: + OreCalendarMac: all + run: + config: Release + archive: + config: Release +targets: + OreCalendarMac: + platform: macOS + type: application + sources: + - Sources + - Resources + settings: + DEVELOPMENT_TEAM: QXF7ZWFA28 + GENERATE_INFOPLIST_FILE: true + CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: "0.0.1α" + PRODUCT_BUNDLE_IDENTIFIER: com.ssig33.OreCalendarMac + INFOPLIST_KEY_UILaunchScreen_Generation: true + INFOPLIST_KEY_CFBundleDisplayName: OreCalendar + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + INFOPLIST_KEY_NSCalendarsUsageDescription: "カレンダーのイベントを表示するためにアクセスが必要です" + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "カレンダーのイベントを表示するためにフルアクセスが必要です" +