- ノートの表示処理を、通常テキスト・リンク自動検出・HTML埋め込みの三通りで動的に切り替える新ロジックを導入 - ノート内にHTMLが含まれる場合のパース&レンダリングをサポート - テキストから自動でURLを抽出し、クリック可能なリンクとして表示できるように改良 - 表示部分の変更に合わせ、既存のTextウィジェット処理をattributedStringによる表示へと切り替え
317 lines
11 KiB
Swift
317 lines
11 KiB
Swift
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
|
|
@State private var showingDetail = false
|
|
|
|
var body: some View {
|
|
Button(action: {
|
|
showingDetail = true
|
|
}) {
|
|
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)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.popover(isPresented: $showingDetail) {
|
|
EventDetailView(event: event)
|
|
}
|
|
}
|
|
|
|
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))"
|
|
}
|
|
}
|
|
|
|
struct EventDetailView: View {
|
|
let event: EKEvent
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
if let calendar = event.calendar {
|
|
Circle()
|
|
.fill(Color(calendar.color))
|
|
.frame(width: 12, height: 12)
|
|
Text(calendar.title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Text(event.title ?? "無題のイベント")
|
|
.font(.headline)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if event.isAllDay {
|
|
HStack {
|
|
Image(systemName: "clock")
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 20)
|
|
Text("終日")
|
|
}
|
|
} else {
|
|
HStack(alignment: .top) {
|
|
Image(systemName: "clock")
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 20)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(formatDateTime(event.startDate))
|
|
Text("〜")
|
|
.foregroundColor(.secondary)
|
|
.font(.caption)
|
|
Text(formatDateTime(event.endDate))
|
|
}
|
|
}
|
|
}
|
|
|
|
if let location = event.location, !location.isEmpty {
|
|
HStack(alignment: .top) {
|
|
Image(systemName: "location")
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 20)
|
|
Text(location)
|
|
}
|
|
}
|
|
|
|
if let notes = event.notes, !notes.isEmpty {
|
|
HStack(alignment: .top) {
|
|
Image(systemName: "note.text")
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 20)
|
|
Text(attributedString(from: notes))
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
if let url = event.url {
|
|
HStack(alignment: .top) {
|
|
Image(systemName: "link")
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 20)
|
|
Link(url.absoluteString, destination: url)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(minWidth: 300, maxWidth: 400)
|
|
}
|
|
|
|
private func formatDateTime(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "M月d日(E) HH:mm"
|
|
formatter.locale = Locale(identifier: "ja_JP")
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
private func isHTML(_ string: String) -> Bool {
|
|
let htmlPatterns = ["<a ", "<p>", "<p ", "<br", "<div", "<span", "<b>", "<i>", "<ul", "<ol", "<li"]
|
|
let lowercased = string.lowercased()
|
|
return htmlPatterns.contains { lowercased.contains($0) }
|
|
}
|
|
|
|
private func attributedString(from notes: String) -> AttributedString {
|
|
if isHTML(notes) {
|
|
return attributedStringFromHTML(notes)
|
|
} else {
|
|
return attributedStringWithLinks(notes)
|
|
}
|
|
}
|
|
|
|
private func attributedStringFromHTML(_ html: String) -> AttributedString {
|
|
let styledHTML = """
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
|
</style>
|
|
\(html)
|
|
"""
|
|
guard let data = styledHTML.data(using: .utf8) else {
|
|
return AttributedString(html)
|
|
}
|
|
do {
|
|
let nsAttrStr = try NSAttributedString(
|
|
data: data,
|
|
options: [
|
|
.documentType: NSAttributedString.DocumentType.html,
|
|
.characterEncoding: String.Encoding.utf8.rawValue
|
|
],
|
|
documentAttributes: nil
|
|
)
|
|
return AttributedString(nsAttrStr)
|
|
} catch {
|
|
return AttributedString(html)
|
|
}
|
|
}
|
|
|
|
private func attributedStringWithLinks(_ text: String) -> AttributedString {
|
|
var attributedString = AttributedString(text)
|
|
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
|
return attributedString
|
|
}
|
|
let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
|
|
for match in matches {
|
|
guard let range = Range(match.range, in: text),
|
|
let url = match.url,
|
|
let attrRange = Range(range, in: attributedString) else {
|
|
continue
|
|
}
|
|
attributedString[attrRange].link = url
|
|
}
|
|
return attributedString
|
|
}
|
|
}
|