Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Alarm/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct ContentView: View {
Label("计时器", systemImage: "timer")
}
}
.tint(.orange)
.preferredColorScheme(.light)
}
}
Expand Down
6 changes: 5 additions & 1 deletion Alarm/Services/AlarmStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions Alarm/Views/AlarmEditView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ struct AlarmEditView: View {
}
.pickerStyle(.wheel)
.frame(height: 160)
.transition(.opacity)
.animation(.easeInOut(duration: 0.2), value: showSnoozePicker)
}

divider
Expand Down
180 changes: 163 additions & 17 deletions Alarm/Views/AlarmListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
}
}
}
Expand All @@ -132,22 +150,37 @@ 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))
.foregroundStyle(.red)
}
}

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()
Expand All @@ -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 }
Expand All @@ -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)
Comment on lines +216 to +217

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stabilize sort comparisons with a single reference time

displayAlarms calls store.nextTriggerDate(for:) inside the sort predicate for both sides, and that helper uses Date() internally each time. Around trigger boundaries (for example, when the current minute rolls over during sorting), the same alarm can compare differently across predicate invocations, which violates sorted’s strict weak ordering requirement and can yield unstable ordering/flicker. Compute now once (or precompute each alarm’s next trigger once) before sorting to keep comparisons deterministic.

Useful? React with 👍 / 👎.


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 {
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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` 可用性依赖签名能力与设备环境
- 节假日相关结果依赖本地日历和配置数据

## 后续可补强方向

- 为闹钟触发时间计算补单元测试,重点覆盖工作日、节假日和调休交叉场景
- 为列表和编辑页补首次授权与失败提示说明
- 继续完善秒表和计时器在后台恢复、完成提醒等细节体验