diff --git a/Alarm/ContentView.swift b/Alarm/ContentView.swift index 910f845..f24d33c 100644 --- a/Alarm/ContentView.swift +++ b/Alarm/ContentView.swift @@ -25,6 +25,7 @@ struct ContentView: View { Label("计时器", systemImage: "timer") } } + .tint(.orange) .preferredColorScheme(.light) } } diff --git a/Alarm/Services/AlarmStore.swift b/Alarm/Services/AlarmStore.swift index f2c8ec0..036d5af 100644 --- a/Alarm/Services/AlarmStore.swift +++ b/Alarm/Services/AlarmStore.swift @@ -112,13 +112,17 @@ final class AlarmStore: ObservableObject { func nextTriggerText(for item: AlarmItem) -> String { guard item.isEnabled else { return "已关闭" } - guard let nextDate = nextTriggerDate(for: item, from: Date()) else { + guard let nextDate = nextTriggerDate(for: item) else { return "暂无下次提醒" } return "下次:\(nextDate.alarmDisplayText()) · 节假日跳过 \(item.skipHolidayEnabled ? "🟢" : "🔴")" } + func nextTriggerDate(for item: AlarmItem) -> Date? { + nextTriggerDate(for: item, from: Date()) + } + private func rescheduleEnabledAlarms() async { for item in alarms where item.isEnabled { await scheduleIfNeeded(item) diff --git a/Alarm/Views/AlarmEditView.swift b/Alarm/Views/AlarmEditView.swift index e11f183..5bb0cc4 100644 --- a/Alarm/Views/AlarmEditView.swift +++ b/Alarm/Views/AlarmEditView.swift @@ -179,6 +179,8 @@ struct AlarmEditView: View { } .pickerStyle(.wheel) .frame(height: 160) + .transition(.opacity) + .animation(.easeInOut(duration: 0.2), value: showSnoozePicker) } divider diff --git a/Alarm/Views/AlarmListView.swift b/Alarm/Views/AlarmListView.swift index f6b8bbc..ae14b95 100644 --- a/Alarm/Views/AlarmListView.swift +++ b/Alarm/Views/AlarmListView.swift @@ -6,6 +6,7 @@ struct AlarmListView: View { @State private var isPresentingAdd = false @State private var editingItem: AlarmItem? @State private var isEditing = false + @State private var itemToDelete: AlarmItem? var body: some View { NavigationStack { @@ -54,6 +55,22 @@ struct AlarmListView: View { } ) } + .alert("确认删除", isPresented: Binding( + get: { itemToDelete != nil }, + set: { if !$0 { itemToDelete = nil } } + )) { + Button("删除", role: .destructive) { + if let item = itemToDelete { + store.removeAlarm(id: item.id) + itemToDelete = nil + } + } + Button("取消", role: .cancel) { + itemToDelete = nil + } + } message: { + Text("确定要删除这个闹钟吗?") + } } .preferredColorScheme(.light) } @@ -113,15 +130,16 @@ struct AlarmListView: View { .overlay(Color(white: 0.84)) if store.alarms.isEmpty { - Text("暂无闹钟") - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(Color(white: 0.5)) - .padding(.vertical, 10) + emptyStateCard } else { + if let upcomingAlarm = upcomingAlarm { + upcomingAlarmCard(for: upcomingAlarm) + .padding(.top, 6) + .padding(.bottom, 4) + } + ForEach(displayAlarms) { item in alarmRow(item) - Divider() - .overlay(Color(white: 0.84)) } } } @@ -132,7 +150,7 @@ struct AlarmListView: View { HStack(spacing: 10) { if isEditing { Button { - store.removeAlarm(id: item.id) + itemToDelete = item } label: { Image(systemName: "minus.circle.fill") .font(.system(size: 22)) @@ -140,14 +158,29 @@ struct AlarmListView: View { } } - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { Text(item.time.alarmTimeText()) - .font(.system(size: 68, weight: .light)) + .font(.system(size: 60, weight: .light)) .foregroundStyle(item.isEnabled ? Color(white: 0.1) : Color(white: 0.6)) - Text(item.label) - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(Color(white: 0.5)) + HStack(spacing: 4) { + Text(item.label) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(Color(white: 0.5)) + + Text("·") + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(Color(white: 0.6)) + + Text(repeatSummary(for: item)) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(Color(white: 0.6)) + } + + Text(store.nextTriggerText(for: item)) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(Color(white: 0.42)) + .lineLimit(2) } Spacer() @@ -160,6 +193,13 @@ struct AlarmListView: View { .toggleStyle(.switch) .onTapGesture {} } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + .shadow(color: Color.black.opacity(0.05), radius: 8, x: 0, y: 2) + ) + .opacity(item.isEnabled ? 1.0 : 0.6) .contentShape(Rectangle()) .onTapGesture { guard !isEditing else { return } @@ -169,17 +209,123 @@ struct AlarmListView: View { private var displayAlarms: [AlarmItem] { store.alarms.sorted { - let lhs = Calendar.current.dateComponents([.hour, .minute], from: $0.time) - let rhs = Calendar.current.dateComponents([.hour, .minute], from: $1.time) - let lhsValue = (lhs.hour ?? 0) * 60 + (lhs.minute ?? 0) - let rhsValue = (rhs.hour ?? 0) * 60 + (rhs.minute ?? 0) - return lhsValue < rhsValue + if $0.isEnabled != $1.isEnabled { + return $0.isEnabled && !$1.isEnabled + } + + let lhsNext = store.nextTriggerDate(for: $0) + let rhsNext = store.nextTriggerDate(for: $1) + + switch (lhsNext, rhsNext) { + case let (lhsDate?, rhsDate?): + if lhsDate != rhsDate { + return lhsDate < rhsDate + } + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + break + } + + return alarmTimeValue(for: $0) < alarmTimeValue(for: $1) } } private var enabledAlarmCount: Int { store.alarms.filter(\.isEnabled).count } + + private var upcomingAlarm: AlarmItem? { + store.alarms + .filter(\.isEnabled) + .compactMap { item in + guard let nextDate = store.nextTriggerDate(for: item) else { return nil } + return (item, nextDate) + } + .min { $0.1 < $1.1 }? + .0 + } + + private var emptyStateCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("暂无闹钟") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color(white: 0.18)) + + Text("点击右上角 + 创建第一个闹钟,列表会在这里显示重复规则和下一次提醒。") + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(Color(white: 0.48)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 22) + .fill(Color.white) + ) + .padding(.top, 6) + } + + private func upcomingAlarmCard(for item: AlarmItem) -> some View { + let nextDate = store.nextTriggerDate(for: item) + let title = item.label == "闹钟" ? "最近提醒" : "最近提醒 · \(item.label)" + + return VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Color.orange) + + Text(item.time.alarmTimeText()) + .font(.system(size: 40, weight: .light)) + .foregroundStyle(Color(white: 0.12)) + + if let nextDate { + Text("预计在 \(nextDate.alarmDisplayText()) 响铃") + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(Color(white: 0.42)) + + Text(relativeTimeText(to: nextDate)) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(Color(white: 0.58)) + } else { + Text("当前规则下暂无下次提醒") + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(Color(white: 0.48)) + } + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 22) + .fill(Color.white) + ) + } + + private func repeatSummary(for item: AlarmItem) -> String { + let holidayText = item.skipHolidayEnabled ? "节假日跳过" : "节假日照常" + return "\(item.repeatRule.displayText) · \(holidayText)" + } + + private func alarmTimeValue(for item: AlarmItem) -> Int { + let components = Calendar.current.dateComponents([.hour, .minute], from: item.time) + return (components.hour ?? 0) * 60 + (components.minute ?? 0) + } + + private func relativeTimeText(to date: Date) -> String { + let interval = max(Int(date.timeIntervalSinceNow), 0) + let hours = interval / 3600 + let minutes = (interval % 3600) / 60 + + if hours == 0 { + return "\(max(minutes, 1)) 分钟后" + } + if minutes == 0 { + return "\(hours) 小时后" + } + return "\(hours) 小时 \(minutes) 分钟后" + } } #Preview { diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed5d06d --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Alarm + +一个基于 SwiftUI 的本地闹钟原型,当前重点覆盖工作日重复、节假日跳过、调休补班和基础计时能力。 + +## 当前分支说明 + +当前分支 `Codex/optimize-alarm-display` 侧重闹钟列表的显示优化: + +- 强化空状态提示,首次打开时更清楚地引导创建闹钟 +- 在列表中同时展示重复规则、节假日处理方式和下一次提醒时间 +- 保持现有交互结构不变,只做轻量视觉与信息层级调整 + +## 近期协作更新 + +最近几轮由不同提交者补充的内容,当前分支已经包含: + +- 闹钟列表排版优化:补充启用数量、重复规则摘要和更清晰的信息层级 +- 闹钟编辑页样式统一:列表页与编辑页的视觉风格已基本对齐 +- 重复规则交互增强:支持每天、工作日、自定义日期组合,并优化选择流程 +- 节假日配置能力:可配置法定节假日、调休工作日、自定义跳过日期和自定义工作日 +- 秒表与计时器基础体验:已提供基础页面,秒表含空状态和运行状态文案 + +## 主要能力 + +- 新增、编辑、删除闹钟 +- 每天、工作日、自定义重复 +- 节假日跳过与调休工作日识别 +- 稍后提醒开关与时长设置 +- 秒表与计时器基础页面 + +## 页面说明 + +- `闹钟列表`: 查看全部闹钟、启用状态、重复规则、节假日策略和下一次提醒 +- `闹钟编辑`: 调整时间、标签、铃声、重复规则、稍后提醒和节假日跳过开关 +- `节假日配置`: 维护法定节假日、调休工作日和自定义日期 +- `秒表 / 计时器`: 提供基础计时功能,用于后续继续补强 + +## 项目结构 + +- `Alarm/Views`: 闹钟列表、编辑页、节假日设置、秒表、计时器 +- `Alarm/Services`: 存储、调度、节假日计算 +- `Alarm/Models`: 闹钟、应用设置、节假日数据模型 +- `Alarm/Utilities`: 日期格式化辅助方法 + +## 运行说明 + +1. 使用 Xcode 打开 `Alarm.xcodeproj` +2. 选择 `Alarm` scheme +3. 在模拟器或真机运行 + +## 已知限制 + +- 当前无单元测试 target +- `AlarmKit` 可用性依赖签名能力与设备环境 +- 节假日相关结果依赖本地日历和配置数据 + +## 后续可补强方向 + +- 为闹钟触发时间计算补单元测试,重点覆盖工作日、节假日和调休交叉场景 +- 为列表和编辑页补首次授权与失败提示说明 +- 继续完善秒表和计时器在后台恢复、完成提醒等细节体验