全面重写 iOS 所有页面样式:渐变背景 + 玻璃卡片(.thinMaterial)+ 圆角阴影,匹配 Android 版视觉效果
这个提交包含在:
@@ -4,73 +4,82 @@ struct AgreementView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("用户协议与隐私说明")
|
||||
.font(.title).bold()
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [Color.white, Color(red: 0.84, green: 0.84, blue: 0.87)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
Text("欢迎使用「睡了吗」。\n\n本协议说明应用功能与数据使用方式,请仔细阅读。")
|
||||
.font(.body).foregroundColor(.secondary)
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("用户协议与隐私说明")
|
||||
.font(.title).bold()
|
||||
|
||||
section("一、应用功能") {
|
||||
Text("1. 睡眠监督:在您设定的就寝时段,提醒您按时入睡。\n(注:iOS 系统限制,本应用无法直接屏蔽其他应用,仅提供通知提醒。)\n2. 作息记录:记录您的入睡与起床时间,生成睡眠统计。\n3. 控制码功能(见第二条)。")
|
||||
Text("欢迎使用「睡了吗」。\n\n本协议说明应用功能与数据使用方式,请仔细阅读。")
|
||||
.font(.body).foregroundColor(.secondary)
|
||||
|
||||
agreementSection("一、应用功能") {
|
||||
Text("1. 睡眠监督:在您设定的就寝时段,提醒您按时入睡。\n(注:iOS 系统限制,本应用无法直接屏蔽其他应用,仅提供通知提醒。)\n2. 作息记录:记录您的入睡与起床时间,生成睡眠统计。\n3. 控制码功能(见第二条)。")
|
||||
}
|
||||
|
||||
agreementSection("二、控制码功能") {
|
||||
Text("本应用会为您的手机生成一个独一无二的控制码。\n\n• 控制码绑定本机,卸载重装后才会变更。\n• 您可以将控制码分享给朋友。朋友输入您的控制码后可以远程设置您的就寝/起床时间、提醒您入睡/起床。\n• 您可以随时在设置中停止分享。\n• 请仅将控制码分享给您信任的人。")
|
||||
}
|
||||
|
||||
agreementSection("三、数据与隐私") {
|
||||
Text("• 您的作息设置、睡眠记录会同步到服务器,以便朋友通过控制码查看和远程操作。\n• 服务器仅存储与功能相关的必要数据,不会出售或分享给第三方。\n• 您可以在应用设置中随时停止监督服务,断开与服务器同步。")
|
||||
}
|
||||
|
||||
agreementSection("四、所需权限") {
|
||||
Text("• 通知:发送就寝提醒与朋友远程消息\n• 网络访问:与服务器同步作息数据、接收朋友远程指令")
|
||||
}
|
||||
|
||||
Spacer().frame(height: 16)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("解释权归 JGZ_YES 所有")
|
||||
.font(.footnote).foregroundColor(.secondary)
|
||||
Text("(c) 2026 JGZ_YES")
|
||||
.font(.footnote).foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
section("二、控制码功能") {
|
||||
Text("本应用会为您的手机生成一个独一无二的控制码。\n\n• 控制码绑定本机,卸载重装后才会变更。\n• 您可以将控制码分享给朋友。朋友输入您的控制码后可以远程设置您的就寝/起床时间、提醒您入睡/起床。\n• 您可以随时在设置中停止分享。\n• 请仅将控制码分享给您信任的人。")
|
||||
}
|
||||
|
||||
section("三、数据与隐私") {
|
||||
Text("• 您的作息设置、睡眠记录会同步到服务器,以便朋友通过控制码查看和远程操作。\n• 服务器仅存储与功能相关的必要数据,不会出售或分享给第三方。\n• 您可以在应用设置中随时停止监督服务,断开与服务器同步。")
|
||||
}
|
||||
|
||||
section("四、所需权限") {
|
||||
Text("• 通知:发送就寝提醒与朋友远程消息\n• 网络访问:与服务器同步作息数据、接收朋友远程指令")
|
||||
}
|
||||
|
||||
Spacer().frame(height: 16)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("解释权归 JGZ_YES 所有")
|
||||
.font(.footnote).foregroundColor(.secondary)
|
||||
Text("(c) 2026 JGZ_YES")
|
||||
.font(.footnote).foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(24)
|
||||
}
|
||||
.padding(24)
|
||||
|
||||
// 底部按钮
|
||||
HStack(spacing: 12) {
|
||||
Button("不同意") {
|
||||
exit(0)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
.controlSize(.large)
|
||||
|
||||
Button("同意并继续") {
|
||||
store.agreed = true
|
||||
store.ensureControlCode()
|
||||
store.save()
|
||||
Task {
|
||||
_ = await ApiClient.bind(code: store.controlCode, nickname: store.nickname)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
HStack(spacing: 12) {
|
||||
Button("不同意") {
|
||||
exit(0)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
.controlSize(.large)
|
||||
|
||||
Button("同意并继续") {
|
||||
store.agreed = true
|
||||
store.ensureControlCode()
|
||||
store.save()
|
||||
Task {
|
||||
_ = await ApiClient.bind(code: store.controlCode, nickname: store.nickname)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
private func agreementSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title).font(.headline)
|
||||
content().font(.subheadline).foregroundColor(.secondary)
|
||||
|
||||
@@ -3,31 +3,41 @@ import SwiftUI
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
@State private var selectedTab = 0
|
||||
@State private var settingsRefresh = UUID()
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
DashboardView()
|
||||
.tabItem {
|
||||
Image(systemName: "house.fill")
|
||||
Text("仪表盘")
|
||||
}
|
||||
.tag(0)
|
||||
ZStack {
|
||||
// 渐变背景
|
||||
LinearGradient(
|
||||
colors: [Color.white, Color(red: 0.84, green: 0.84, blue: 0.87)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
StatsView()
|
||||
.tabItem {
|
||||
Image(systemName: "chart.bar.fill")
|
||||
Text("统计")
|
||||
}
|
||||
.tag(1)
|
||||
TabView(selection: $selectedTab) {
|
||||
DashboardView()
|
||||
.tabItem {
|
||||
Image(systemName: "house.fill")
|
||||
Text("仪表盘")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Image(systemName: "gearshape.fill")
|
||||
Text("设置")
|
||||
}
|
||||
.tag(2)
|
||||
StatsView()
|
||||
.tabItem {
|
||||
Image(systemName: "chart.bar.fill")
|
||||
Text("统计")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Image(systemName: "gearshape.fill")
|
||||
Text("设置")
|
||||
}
|
||||
.tag(2)
|
||||
}
|
||||
.tint(.blue)
|
||||
.toolbarBackground(.ultraThinMaterial, for: .tabBar)
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,79 +6,80 @@ struct DashboardView: View {
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// 时钟 + 日期
|
||||
VStack(spacing: 4) {
|
||||
Text(timeString)
|
||||
.font(.system(size: 56, weight: .thin, design: .rounded))
|
||||
Text(dateString)
|
||||
.font(.subheadline).foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
|
||||
// 时钟 + 日期
|
||||
VStack(spacing: 4) {
|
||||
Text(timeString)
|
||||
.font(.system(size: 56, weight: .thin, design: .rounded))
|
||||
Text(dateString)
|
||||
.font(.subheadline).foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
// 问候语
|
||||
Text(greeting)
|
||||
.font(.title2)
|
||||
|
||||
// 问候语
|
||||
Text(greeting)
|
||||
.font(.title2)
|
||||
|
||||
// 倒计时
|
||||
VStack(spacing: 8) {
|
||||
Text(statusTitle)
|
||||
.font(.headline)
|
||||
Text(countdownText)
|
||||
.font(.system(size: 28, weight: .medium))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
// 睡眠状态卡片
|
||||
HStack(spacing: 16) {
|
||||
statusCard(title: "就寝时间", value: TimeUtil.fmt(store.bedtimeMinutes))
|
||||
statusCard(title: "起床时间", value: TimeUtil.fmt(store.waketimeMinutes))
|
||||
}
|
||||
|
||||
// 监督状态
|
||||
HStack {
|
||||
Image(systemName: store.supervisionEnabled ? "shield.checkered" : "shield.slash")
|
||||
.foregroundColor(store.supervisionEnabled ? .green : .red)
|
||||
Text(store.supervisionEnabled ? "监督运行中" : "监督已关闭")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// 入睡/起床按钮
|
||||
if store.isSleeping {
|
||||
Button {
|
||||
store.endSleep()
|
||||
} label: {
|
||||
Label("我起床了", systemImage: "sunrise.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.tint(.orange)
|
||||
} else {
|
||||
Button {
|
||||
store.startSleep()
|
||||
} label: {
|
||||
Label("我要睡觉了", systemImage: "moon.zzz.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.tint(.indigo)
|
||||
}
|
||||
// 倒计时卡片
|
||||
VStack(spacing: 8) {
|
||||
Text(statusTitle)
|
||||
.font(.headline)
|
||||
Text(countdownText)
|
||||
.font(.system(size: 28, weight: .medium))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.glassCard()
|
||||
|
||||
// 就寝/起床时间卡片
|
||||
HStack(spacing: 12) {
|
||||
infoCard(title: "就寝时间", value: TimeUtil.fmt(store.bedtimeMinutes), icon: "moon.fill")
|
||||
infoCard(title: "起床时间", value: TimeUtil.fmt(store.waketimeMinutes), icon: "sunrise.fill")
|
||||
}
|
||||
|
||||
// 监督状态
|
||||
HStack {
|
||||
Image(systemName: store.supervisionEnabled ? "shield.checkered" : "shield.slash")
|
||||
.foregroundColor(store.supervisionEnabled ? .green : .secondary)
|
||||
Text(store.supervisionEnabled ? "监督运行中" : "监督已关闭")
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
// 入睡/起床按钮
|
||||
if store.isSleeping {
|
||||
Button {
|
||||
store.endSleep()
|
||||
} label: {
|
||||
Label("我起床了", systemImage: "sunrise.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.tint(.orange)
|
||||
} else {
|
||||
Button {
|
||||
store.startSleep()
|
||||
} label: {
|
||||
Label("我要睡觉了", systemImage: "moon.zzz.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.navigationTitle("睡了吗")
|
||||
.onReceive(timer) { t in now = t }
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("睡了吗")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { t in now = t }
|
||||
}
|
||||
|
||||
private var timeString: String {
|
||||
@@ -121,14 +122,28 @@ struct DashboardView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusCard(title: String, value: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title).font(.caption).foregroundColor(.secondary)
|
||||
private func infoCard(title: String, value: String, icon: String) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundColor(.blue)
|
||||
Text(value).font(.title3).bold()
|
||||
Text(title).font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.glassCard()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 玻璃卡片 Modifier
|
||||
extension View {
|
||||
func glassCard(radius: CGFloat = 16) -> some View {
|
||||
self
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: radius)
|
||||
.fill(.thinMaterial)
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, x: 0, y: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,60 +8,95 @@ struct FriendControlView: View {
|
||||
@State private var errorMsg: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
TextField("输入朋友的控制码", text: $friendCode)
|
||||
.disableAutocorrection(true)
|
||||
.textCase(.uppercase)
|
||||
Button("查询") { lookupFriend() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(friendCode.isEmpty || isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
if let info = friendInfo {
|
||||
Section("朋友信息") {
|
||||
Text(info).font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = errorMsg {
|
||||
Section { Text(error).foregroundColor(.red) }
|
||||
}
|
||||
|
||||
if friendInfo != nil {
|
||||
Section("操作") {
|
||||
Button { triggerFriend("sleep") } label: {
|
||||
Label("催朋友睡觉", systemImage: "moon.zzz.fill")
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// 查询卡片
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
TextField("输入朋友的控制码", text: $friendCode)
|
||||
.disableAutocorrection(true)
|
||||
.textCase(.uppercase)
|
||||
Button("查询") { lookupFriend() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(friendCode.isEmpty || isLoading)
|
||||
}
|
||||
Button { triggerFriend("wake") } label: {
|
||||
Label("叫朋友起床", systemImage: "sunrise.fill")
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
|
||||
if let info = friendInfo {
|
||||
Text(info)
|
||||
.font(.headline)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
|
||||
if let error = errorMsg {
|
||||
Text(error)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.glassCard()
|
||||
|
||||
// 远程控制卡片
|
||||
if friendInfo != nil {
|
||||
VStack(spacing: 8) {
|
||||
Button {
|
||||
sendCommand("sleep")
|
||||
} label: {
|
||||
Label("提醒入睡", systemImage: "moon.zzz.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.indigo)
|
||||
|
||||
Button {
|
||||
sendCommand("wake")
|
||||
} label: {
|
||||
Label("提醒起床", systemImage: "sunrise.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("控制朋友").font(.headline)
|
||||
}
|
||||
}
|
||||
.navigationTitle("控制朋友")
|
||||
.overlay { if isLoading { ProgressView() } }
|
||||
}
|
||||
|
||||
private func lookupFriend() {
|
||||
isLoading = true; errorMsg = nil; friendInfo = nil
|
||||
isLoading = true
|
||||
errorMsg = nil
|
||||
friendInfo = nil
|
||||
Task {
|
||||
if let res = await ApiClient.friendInfo(code: friendCode) {
|
||||
friendInfo = (res["nickname"] as? String) ?? friendCode
|
||||
} else {
|
||||
errorMsg = "未找到该用户"
|
||||
}
|
||||
let result = await ApiClient.query(code: friendCode)
|
||||
isLoading = false
|
||||
switch result {
|
||||
case .success(let info):
|
||||
friendInfo = info
|
||||
case .failure(let e):
|
||||
errorMsg = "查询失败:\(e.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerFriend(_ action: String) {
|
||||
private func sendCommand(_ cmd: String) {
|
||||
Task {
|
||||
let res = await ApiClient.friendTrigger(friendCode: friendCode, action: action)
|
||||
if !res.ok { errorMsg = res.error ?? "操作失败" }
|
||||
let result = await ApiClient.command(code: friendCode, cmd: cmd)
|
||||
await MainActor.run {
|
||||
if case .failure(let e) = result {
|
||||
errorMsg = "指令失败:\(e.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,101 +3,108 @@ import SwiftUI
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
@State private var showAgreement = false
|
||||
@State private var showStyleSettings = false
|
||||
@State private var showTimePicker = false
|
||||
@State private var isBedPicker = false
|
||||
@State private var pickerDate = Date()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// MARK: - 控制码
|
||||
Section {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("我的控制码").font(.caption).foregroundColor(.secondary)
|
||||
Text(store.controlCode.isEmpty ? "----" : store.controlCode)
|
||||
.font(.title3).bold().foregroundColor(.blue)
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
// 控制码卡片
|
||||
cardSection(header: "控制码") {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("我的控制码").font(.caption).foregroundColor(.secondary)
|
||||
Text(store.controlCode.isEmpty ? "----" : store.controlCode)
|
||||
.font(.title3).bold().foregroundColor(.blue)
|
||||
}
|
||||
Spacer()
|
||||
if !store.controlCode.isEmpty {
|
||||
ShareLink(item: shareText) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.title3)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Toggle("启用控制码功能", isOn: $store.controlCodeEnabled)
|
||||
.onChange(of: store.controlCodeEnabled) { _ in store.save() }
|
||||
}
|
||||
Spacer()
|
||||
if !store.controlCode.isEmpty {
|
||||
ShareLink(item: shareText) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
// 作息卡片
|
||||
cardSection(header: "作息") {
|
||||
VStack(spacing: 0) {
|
||||
timeRow(title: "目标就寝时间", minutes: store.bedtimeMinutes) {
|
||||
showTimePickerSheet(isBed: true)
|
||||
}
|
||||
Divider().padding(.leading, 16)
|
||||
timeRow(title: "目标起床时间", minutes: store.waketimeMinutes) {
|
||||
showTimePickerSheet(isBed: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Toggle("启用控制码功能", isOn: $store.controlCodeEnabled)
|
||||
.onChange(of: store.controlCodeEnabled) { _ in store.save() }
|
||||
} header: { Text("控制码") }
|
||||
|
||||
// MARK: - 作息
|
||||
Section {
|
||||
HStack {
|
||||
Text("目标就寝时间")
|
||||
Spacer()
|
||||
Text(TimeUtil.fmt(store.bedtimeMinutes)).foregroundColor(.blue)
|
||||
// 提醒卡片
|
||||
cardSection(header: "提醒", footer: "睡觉时间前发送通知提醒你准备休息") {
|
||||
Toggle("睡前 30 分钟提醒", isOn: $store.bedtimeReminderEnabled)
|
||||
.onChange(of: store.bedtimeReminderEnabled) { _ in
|
||||
store.save()
|
||||
BedtimeReminder.schedule(store: store)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { showTimePickerSheet(isBed: true) }
|
||||
|
||||
HStack {
|
||||
Text("目标起床时间")
|
||||
Spacer()
|
||||
Text(TimeUtil.fmt(store.waketimeMinutes)).foregroundColor(.blue)
|
||||
// 监督卡片
|
||||
cardSection(header: "监督", footer: "就寝时段提醒你放下手机") {
|
||||
Toggle("监督模式", isOn: $store.supervisionEnabled)
|
||||
.onChange(of: store.supervisionEnabled) { _ in store.save() }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { showTimePickerSheet(isBed: false) }
|
||||
} header: { Text("作息") }
|
||||
|
||||
// MARK: - 提醒
|
||||
Section {
|
||||
Toggle("睡前 30 分钟提醒", isOn: $store.bedtimeReminderEnabled)
|
||||
.onChange(of: store.bedtimeReminderEnabled) { _ in
|
||||
store.save()
|
||||
BedtimeReminder.schedule(store: store)
|
||||
// 后台运行卡片
|
||||
cardSection(header: "后台运行", footer: "关闭后应用切到后台将停止服务") {
|
||||
Toggle("允许后台运行", isOn: $store.backgroundRunning)
|
||||
.onChange(of: store.backgroundRunning) { _ in store.save() }
|
||||
}
|
||||
|
||||
// 外观
|
||||
cardSection(header: "外观") {
|
||||
NavigationLink {
|
||||
StyleSettingsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("样式")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} header: { Text("提醒") } footer: {
|
||||
Text("睡觉时间前发送通知提醒你准备休息")
|
||||
}
|
||||
|
||||
// MARK: - 监督
|
||||
Section {
|
||||
Toggle("监督模式", isOn: $store.supervisionEnabled)
|
||||
.onChange(of: store.supervisionEnabled) { _ in store.save() }
|
||||
} header: { Text("监督") } footer: {
|
||||
Text("就寝时段提醒你放下手机")
|
||||
}
|
||||
|
||||
// MARK: - 后台运行
|
||||
Section {
|
||||
Toggle("允许后台运行", isOn: $store.backgroundRunning)
|
||||
.onChange(of: store.backgroundRunning) { _ in store.save() }
|
||||
} header: { Text("后台运行") } footer: {
|
||||
Text("关闭后应用切到后台将停止服务")
|
||||
}
|
||||
|
||||
// MARK: - 样式
|
||||
Section {
|
||||
NavigationLink {
|
||||
StyleSettingsView()
|
||||
} label: {
|
||||
Text("样式")
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
} header: { Text("外观") }
|
||||
|
||||
// MARK: - 朋友
|
||||
Section {
|
||||
NavigationLink("控制朋友") {
|
||||
FriendControlView()
|
||||
// 朋友
|
||||
cardSection(header: "朋友") {
|
||||
NavigationLink("控制朋友") {
|
||||
FriendControlView()
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
} header: { Text("朋友") }
|
||||
|
||||
// MARK: - 其他
|
||||
Section {
|
||||
Button("用户协议与隐私说明") { showAgreement = true }
|
||||
// 其他
|
||||
cardSection {
|
||||
Button("用户协议与隐私说明") { showAgreement = true }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("设置").font(.headline)
|
||||
}
|
||||
}
|
||||
.navigationTitle("设置")
|
||||
.sheet(isPresented: $showAgreement) {
|
||||
AgreementView()
|
||||
}
|
||||
@@ -108,7 +115,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 时间选择器(纯 SwiftUI)
|
||||
// MARK: - 时间选择器
|
||||
private var timePickerSheet: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
@@ -135,7 +142,6 @@ struct SettingsView: View {
|
||||
.padding(.horizontal)
|
||||
|
||||
DatePicker("", selection: $pickerDate, displayedComponents: .hourAndMinute)
|
||||
.datePickerStyle(.wheel)
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding(.top, 12)
|
||||
@@ -161,4 +167,43 @@ struct SettingsView: View {
|
||||
\(webLink)
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - 组件
|
||||
|
||||
@ViewBuilder
|
||||
private func timeRow(title: String, minutes: Int, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text(TimeUtil.fmt(minutes))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 16)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cardSection(header: String? = nil, footer: String? = nil, @ViewBuilder content: () -> some View) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let h = header {
|
||||
Text(h)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
content()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.glassCard(radius: 14)
|
||||
if let f = footer {
|
||||
Text(f)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,32 +6,42 @@ struct StatsView: View {
|
||||
private var records: [SleepRecord] { store.recentRecords(7) }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// KPI 卡片
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
kpiCard(title: "平均睡眠", value: avgHours, unit: "小时", color: .blue)
|
||||
kpiCard(title: "连续达标", value: "\(streakDays)", unit: "天", color: .green)
|
||||
kpiCard(title: "准时入睡", value: "\(onTimeCount)/\(max(records.count, 1))", unit: "晚", color: .orange)
|
||||
kpiCard(title: "达标率", value: String(format: "%.0f%%", goalRate * 100), unit: "", color: .purple)
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// KPI 卡片
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
StatKpiCard(title: "平均睡眠", value: avgHours, unit: "小时", color: .blue)
|
||||
StatKpiCard(title: "连续达标", value: "\(streakDays)", unit: "天", color: .green)
|
||||
StatKpiCard(title: "准时入睡", value: "\(onTimeCount)/\(max(records.count, 1))", unit: "晚", color: .orange)
|
||||
StatKpiCard(title: "达标率", value: String(format: "%.0f%%", goalRate * 100), unit: "", color: .purple)
|
||||
}
|
||||
|
||||
// 最近记录
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("最近 7 晚").font(.headline).padding(.horizontal)
|
||||
if records.isEmpty {
|
||||
Text("暂无睡眠记录").foregroundColor(.secondary).padding()
|
||||
} else {
|
||||
ForEach(records) { rec in
|
||||
recordRow(rec)
|
||||
}
|
||||
// 最近记录
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("最近 7 晚")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
if records.isEmpty {
|
||||
Text("暂无睡眠记录")
|
||||
.foregroundColor(.secondary)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.glassCard()
|
||||
} else {
|
||||
ForEach(records) { rec in
|
||||
RecordRow(rec: rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("统计")
|
||||
.padding(16)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("统计").font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,36 +66,46 @@ struct StatsView: View {
|
||||
private var onTimeCount: Int {
|
||||
records.filter { $0.bedMinutes <= store.bedtimeMinutes + 30 }.count
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func kpiCard(title: String, value: String, unit: String, color: Color) -> some View {
|
||||
struct StatKpiCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let unit: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title).font(.caption).foregroundColor(.secondary)
|
||||
HStack(alignment: .lastTextBaseline, spacing: 2) {
|
||||
Text(value).font(.title2).bold().foregroundColor(color)
|
||||
if !unit.isEmpty { Text(unit).font(.caption).foregroundColor(.secondary) }
|
||||
if !unit.isEmpty {
|
||||
Text(unit).font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.glassCard(radius: 14)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func recordRow(_ rec: SleepRecord) -> some View {
|
||||
struct RecordRow: View {
|
||||
let rec: SleepRecord
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(rec.nightDate).font(.subheadline).bold()
|
||||
Text("\(TimeUtil.fmt(rec.bedMinutes)) → \(TimeUtil.fmt(rec.wakeMinutes))")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(String(format: "%.1f 小时", rec.sleepHours))
|
||||
.font(.headline).foregroundColor(rec.sleepHours >= 7 ? .green : .orange)
|
||||
.font(.headline)
|
||||
.foregroundColor(rec.sleepHours >= 7 ? .green : .orange)
|
||||
}
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.glassCard(radius: 14)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,60 +4,97 @@ struct StyleSettingsView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("暗色模式", isOn: $store.darkMode)
|
||||
.onChange(of: store.darkMode) { _ in store.save() }
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
// 暗色模式
|
||||
styleCard {
|
||||
Toggle("暗色模式", isOn: $store.darkMode)
|
||||
.onChange(of: store.darkMode) { _ in store.save() }
|
||||
} header: { Text("主题") }
|
||||
|
||||
Section {
|
||||
Picker("导航栏样式", selection: $store.navStyle) {
|
||||
Text("默认").tag(0)
|
||||
Text("无边框").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: store.navStyle) { _ in store.save() }
|
||||
} header: { Text("导航栏") }
|
||||
|
||||
Section {
|
||||
Picker("字体风格", selection: $store.fontStyle) {
|
||||
Text("默认").tag(0)
|
||||
Text("圆体").tag(1)
|
||||
Text("等宽").tag(2)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: store.fontStyle) { _ in store.save() }
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Text("字体大小: \(String(format: "%.0f%%", store.fontScale * 100))")
|
||||
Spacer()
|
||||
Button("重置") { store.fontScale = 1.0; store.save() }
|
||||
.buttonStyle(.borderless)
|
||||
// 导航栏
|
||||
styleCard {
|
||||
VStack(spacing: 8) {
|
||||
Picker("导航栏样式", selection: $store.navStyle) {
|
||||
Text("默认").tag(0)
|
||||
Text("无边框").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: store.navStyle) { _ in store.save() }
|
||||
}
|
||||
Slider(value: $store.fontScale, in: 0.85...1.30, step: 0.05)
|
||||
.onChange(of: store.fontScale) { _ in store.save() }
|
||||
}
|
||||
} header: { Text("字体") }
|
||||
} header: { Text("导航栏") }
|
||||
|
||||
Section {
|
||||
Picker("卡片圆角", selection: $store.cardRadius) {
|
||||
Text("小 (4)").tag(4)
|
||||
Text("中 (18)").tag(18)
|
||||
Text("大 (28)").tag(28)
|
||||
}
|
||||
.onChange(of: store.cardRadius) { _ in store.save() }
|
||||
} header: { Text("卡片") }
|
||||
// 字体
|
||||
styleCard {
|
||||
VStack(spacing: 12) {
|
||||
Picker("字体风格", selection: $store.fontStyle) {
|
||||
Text("默认").tag(0)
|
||||
Text("圆体").tag(1)
|
||||
Text("等宽").tag(2)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: store.fontStyle) { _ in store.save() }
|
||||
|
||||
Section {
|
||||
Picker("模糊强度", selection: $store.blurStrength) {
|
||||
Text("无").tag(0)
|
||||
Text("轻微").tag(1)
|
||||
Text("强烈").tag(2)
|
||||
}
|
||||
.onChange(of: store.blurStrength) { _ in store.save() }
|
||||
} header: { Text("模糊") }
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Text("字体大小: \(String(format: "%.0f%%", store.fontScale * 100))")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Button("重置") { store.fontScale = 1.0; store.save() }
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption)
|
||||
}
|
||||
Slider(value: $store.fontScale, in: 0.85...1.30, step: 0.05)
|
||||
.onChange(of: store.fontScale) { _ in store.save() }
|
||||
}
|
||||
}
|
||||
} header: { Text("字体") }
|
||||
|
||||
// 卡片
|
||||
styleCard {
|
||||
Picker("卡片圆角", selection: $store.cardRadius) {
|
||||
Text("小 (4)").tag(4)
|
||||
Text("中 (18)").tag(18)
|
||||
Text("大 (28)").tag(28)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: store.cardRadius) { _ in store.save() }
|
||||
} header: { Text("卡片") }
|
||||
|
||||
// 模糊
|
||||
styleCard {
|
||||
Picker("模糊强度", selection: $store.blurStrength) {
|
||||
Text("无").tag(0)
|
||||
Text("轻微").tag(1)
|
||||
Text("强烈").tag(2)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: store.blurStrength) { _ in store.save() }
|
||||
} header: { Text("模糊") }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("样式设置").font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func styleCard<Content: View, Header: View>(
|
||||
@ViewBuilder content: () -> Content,
|
||||
header: () -> Header
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
header()
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
content()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.glassCard(radius: 14)
|
||||
}
|
||||
.navigationTitle("样式设置")
|
||||
}
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户