カレンダーイベント管理アプリの最初のプロジェクト構成と基本実装

- .gitignore や project.yml、Makefile などによる基本的なプロジェクト構成ファイルを追加
- macOSカレンダーイベントを表示するSwiftUIアプリの主要コンポーネント(イベント取得・リスト・カレンダー設定画面など)を実装
- カレンダーの表示期間や「今日へスクロール」「設定画面」遷移などの基本動作に対応
This commit is contained in:
2025-10-24 23:25:57 +09:00
commit 552a55ead6
9 changed files with 383 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.xcodeproj
.DS_Store

2
CLAUDE.md Normal file
View File

@@ -0,0 +1,2 @@
- xcodegen で管理されます
- `make` でビルド可能ですが、基本的にはユーザーにビルドをさせて、あなたはしない想定です。ユーザーから指示があればビルドをするとよいでしょう。

8
DESIGN.md Normal file
View File

@@ -0,0 +1,8 @@
- Single ColumnのSwiftUIアプリ
- EventKitを使用し、macOSのカレンダーイベントを表示
- 今日から前後2週間のイベントを表示
- 起動時今日の位置にスクロール
- メニューに「設定」への遷移ボタン
- 表示するカレンダーの選択
- 設定にふさわしいUIでフワッとイイ感じに
- メニューに「今日」への遷移のボタン

8
Makefile Normal file
View File

@@ -0,0 +1,8 @@
.PHONY: generate build all
all: build
generate:
xcodegen generate
build: generate
xcodebuild -scheme OreCalendarMac -destination 'platform=macOS' build

View File

@@ -0,0 +1,70 @@
import EventKit
import Foundation
class CalendarService: ObservableObject {
private let eventStore = EKEventStore()
@Published var events: [EKEvent] = []
@Published var selectedCalendars: Set<String> = []
@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 }
}
}

164
Sources/ContentView.swift Normal file
View File

@@ -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))"
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
@main
struct OreCalendarApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 600, minHeight: 400)
}
}
}

View File

@@ -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()
}
}
}

32
project.yml Normal file
View File

@@ -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: "カレンダーのイベントを表示するためにフルアクセスが必要です"