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