カレンダーイベント管理アプリの最初のプロジェクト構成と基本実装
- .gitignore や project.yml、Makefile などによる基本的なプロジェクト構成ファイルを追加 - macOSカレンダーイベントを表示するSwiftUIアプリの主要コンポーネント(イベント取得・リスト・カレンダー設定画面など)を実装 - カレンダーの表示期間や「今日へスクロール」「設定画面」遷移などの基本動作に対応
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.xcodeproj
|
||||
.DS_Store
|
||||
2
CLAUDE.md
Normal file
2
CLAUDE.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- xcodegen で管理されます
|
||||
- `make` でビルド可能ですが、基本的にはユーザーにビルドをさせて、あなたはしない想定です。ユーザーから指示があればビルドをするとよいでしょう。
|
||||
8
DESIGN.md
Normal file
8
DESIGN.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- Single ColumnのSwiftUIアプリ
|
||||
- EventKitを使用し、macOSのカレンダーイベントを表示
|
||||
- 今日から前後2週間のイベントを表示
|
||||
- 起動時今日の位置にスクロール
|
||||
- メニューに「設定」への遷移ボタン
|
||||
- 表示するカレンダーの選択
|
||||
- 設定にふさわしいUIでフワッとイイ感じに
|
||||
- メニューに「今日」への遷移のボタン
|
||||
8
Makefile
Normal file
8
Makefile
Normal file
@@ -0,0 +1,8 @@
|
||||
.PHONY: generate build all
|
||||
|
||||
all: build
|
||||
|
||||
generate:
|
||||
xcodegen generate
|
||||
build: generate
|
||||
xcodebuild -scheme OreCalendarMac -destination 'platform=macOS' build
|
||||
70
Sources/CalendarService.swift
Normal file
70
Sources/CalendarService.swift
Normal 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
164
Sources/ContentView.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
11
Sources/OreCalendarApp.swift
Normal file
11
Sources/OreCalendarApp.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OreCalendarApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Sources/SettingsView.swift
Normal file
86
Sources/SettingsView.swift
Normal 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
32
project.yml
Normal 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: "カレンダーのイベントを表示するためにフルアクセスが必要です"
|
||||
|
||||
Reference in New Issue
Block a user