iOS版本初始化:睡了吗 SwiftUI 项目
这个提交包含在:
+24
@@ -0,0 +1,24 @@
|
||||
# Xcode
|
||||
*.xcuserstate
|
||||
*.xcworkspace
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
build/
|
||||
*.xcuserdata
|
||||
|
||||
# CocoaPods
|
||||
Pods/
|
||||
|
||||
# Carthage
|
||||
Carthage/Build/
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
*.xcodeproj/xcuserdata/
|
||||
|
||||
# Simulator
|
||||
*.simulator
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,218 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
111111111111111111 /* AreYouSleepingApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222222; };
|
||||
111111111111111112 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222223; };
|
||||
111111111111111113 /* SleepRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222224; };
|
||||
111111111111111114 /* TimeUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222225; };
|
||||
111111111111111115 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222226; };
|
||||
111111111111111116 /* BedtimeReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222227; };
|
||||
111111111111111117 /* AgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222228; };
|
||||
111111111111111118 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222229; };
|
||||
111111111111111119 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222230; };
|
||||
111111111111111120 /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222231; };
|
||||
111111111111111121 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222232; };
|
||||
111111111111111122 /* StyleSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222233; };
|
||||
111111111111111123 /* FriendControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222222222222222234; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
333333333333333301 /* AreYouSleeping.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AreYouSleeping.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
222222222222222222 /* AreYouSleepingApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AreYouSleepingApp.swift; sourceTree = "<group>"; };
|
||||
222222222222222223 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
|
||||
222222222222222224 /* SleepRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepRecord.swift; sourceTree = "<group>"; };
|
||||
222222222222222225 /* TimeUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeUtil.swift; sourceTree = "<group>"; };
|
||||
222222222222222226 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = "<group>"; };
|
||||
222222222222222227 /* BedtimeReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BedtimeReminder.swift; sourceTree = "<group>"; };
|
||||
222222222222222228 /* AgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementView.swift; sourceTree = "<group>"; };
|
||||
222222222222222229 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
222222222222222230 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||
222222222222222231 /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = "<group>"; };
|
||||
222222222222222232 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
222222222222222233 /* StyleSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleSettingsView.swift; sourceTree = "<group>"; };
|
||||
222222222222222234 /* FriendControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendControlView.swift; sourceTree = "<group>"; };
|
||||
222222222222222235 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
444444444444444401 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647;
|
||||
files = ();
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
555555555555555501 = {
|
||||
isa = PBXGroup; children = (
|
||||
555555555555555502 /* AreYouSleeping */,
|
||||
555555555555555503 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
555555555555555502 /* AreYouSleeping */ = {
|
||||
isa = PBXGroup; children = (
|
||||
222222222222222222 /* AreYouSleepingApp.swift */,
|
||||
222222222222222223 /* Store.swift */,
|
||||
222222222222222235 /* Info.plist */,
|
||||
555555555555555504 /* Models */,
|
||||
555555555555555505 /* Utils */,
|
||||
555555555555555506 /* Services */,
|
||||
555555555555555507 /* Views */,
|
||||
);
|
||||
path = AreYouSleeping;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
555555555555555503 /* Products */ = {
|
||||
isa = PBXGroup; children = (333333333333333301 /* AreYouSleeping.app */);
|
||||
name = Products; sourceTree = "<group>";
|
||||
};
|
||||
555555555555555504 /* Models */ = {
|
||||
isa = PBXGroup; children = (222222222222222224 /* SleepRecord.swift */);
|
||||
path = Models; sourceTree = "<group>";
|
||||
};
|
||||
555555555555555505 /* Utils */ = {
|
||||
isa = PBXGroup; children = (222222222222222225 /* TimeUtil.swift */);
|
||||
path = Utils; sourceTree = "<group>";
|
||||
};
|
||||
555555555555555506 /* Services */ = {
|
||||
isa = PBXGroup; children = (
|
||||
222222222222222226 /* ApiClient.swift */,
|
||||
222222222222222227 /* BedtimeReminder.swift */,
|
||||
);
|
||||
path = Services; sourceTree = "<group>";
|
||||
};
|
||||
555555555555555507 /* Views */ = {
|
||||
isa = PBXGroup; children = (
|
||||
222222222222222228 /* AgreementView.swift */,
|
||||
222222222222222229 /* ContentView.swift */,
|
||||
222222222222222230 /* DashboardView.swift */,
|
||||
222222222222222231 /* StatsView.swift */,
|
||||
222222222222222232 /* SettingsView.swift */,
|
||||
222222222222222233 /* StyleSettingsView.swift */,
|
||||
222222222222222234 /* FriendControlView.swift */,
|
||||
);
|
||||
path = Views; sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
666666666666666601 /* AreYouSleeping */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 777777777777777701;
|
||||
buildPhases = (
|
||||
888888888888888801 /* Sources */,
|
||||
444444444444444401 /* Frameworks */,
|
||||
);
|
||||
buildRules = ();
|
||||
dependencies = ();
|
||||
name = AreYouSleeping;
|
||||
productName = AreYouSleeping;
|
||||
productReference = 333333333333333301;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
999999999999999901 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
};
|
||||
buildConfigurationList = 777777777777777702;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = "zh-Hans";
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = ("zh-Hans", en, Base);
|
||||
mainGroup = 555555555555555501;
|
||||
productRefGroup = 555555555555555503;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (666666666666666601);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
888888888888888801 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase; buildActionMask = 2147483647;
|
||||
files = (
|
||||
111111111111111111 /* AreYouSleepingApp.swift */,
|
||||
111111111111111112 /* Store.swift */,
|
||||
111111111111111113 /* SleepRecord.swift */,
|
||||
111111111111111114 /* TimeUtil.swift */,
|
||||
111111111111111115 /* ApiClient.swift */,
|
||||
111111111111111116 /* BedtimeReminder.swift */,
|
||||
111111111111111117 /* AgreementView.swift */,
|
||||
111111111111111118 /* ContentView.swift */,
|
||||
111111111111111119 /* DashboardView.swift */,
|
||||
111111111111111120 /* StatsView.swift */,
|
||||
111111111111111121 /* SettingsView.swift */,
|
||||
111111111111111122 /* StyleSettingsView.swift */,
|
||||
111111111111111123 /* FriendControlView.swift */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
7A7A7A7A7A7A7A7A01 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = AreYouSleeping/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ausleep.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
7A7A7A7A7A7A7A7A02 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = AreYouSleeping/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ausleep.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
777777777777777701 = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (7A7A7A7A7A7A7A7A01, 7A7A7A7A7A7A7A7A02);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
777777777777777702 = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (7A7A7A7A7A7A7A7A01, 7A7A7A7A7A7A7A7A02);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
};
|
||||
rootObject = 999999999999999901;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct AreYouSleepingApp: App {
|
||||
@StateObject private var store = Store.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if store.agreed {
|
||||
ContentView()
|
||||
.environmentObject(store)
|
||||
.preferredColorScheme(store.darkMode ? .dark : .light)
|
||||
.onAppear {
|
||||
BedtimeReminder.requestPermission()
|
||||
BedtimeReminder.schedule(store: store)
|
||||
}
|
||||
} else {
|
||||
AgreementView()
|
||||
.environmentObject(store)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>睡了吗</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.ausleep.ios</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>AreYouSleeping</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>需要网络权限与服务器同步作息数据</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>AccentColor</string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.ausleep.ios.refresh</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
/// 睡眠记录
|
||||
struct SleepRecord: Identifiable, Codable {
|
||||
var id: String { nightDate }
|
||||
let nightDate: String // "2026-08-10"
|
||||
let bedMinutes: Int // 入睡分钟数
|
||||
let wakeMinutes: Int // 起床分钟数
|
||||
let sleepHours: Double // 睡眠时长
|
||||
|
||||
/// 序列化:nightDate|bedMinutes|wakeMinutes|sleepHours
|
||||
func serialize() -> String {
|
||||
String(format: "%@|%d|%d|%.2f", nightDate, bedMinutes, wakeMinutes, sleepHours)
|
||||
}
|
||||
|
||||
static func parse(_ line: String) -> SleepRecord? {
|
||||
let parts = line.components(separatedBy: "|")
|
||||
guard parts.count == 4,
|
||||
let bed = Int(parts[1]),
|
||||
let wake = Int(parts[2]),
|
||||
let hours = Double(parts[3]) else { return nil }
|
||||
return SleepRecord(nightDate: parts[0], bedMinutes: bed, wakeMinutes: wake, sleepHours: hours)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
|
||||
/// HTTP 客户端:与 api.ausleep.parlz.com 通信
|
||||
enum ApiClient {
|
||||
private static let base = "http://api.ausleep.parlz.com"
|
||||
private static let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder(); d.keyDecodingStrategy = .convertFromSnakeCase; return d
|
||||
}()
|
||||
|
||||
struct ApiResult: Codable {
|
||||
let ok: Bool
|
||||
let error: String?
|
||||
}
|
||||
|
||||
/// 注册控制码
|
||||
static func bind(code: String, nickname: String) async -> ApiResult {
|
||||
post("/register.php", body: ["code": code, "nickname": nickname])
|
||||
}
|
||||
|
||||
/// 同步状态 + 拉取指令
|
||||
static func sync(code: String, body: [String: Any]) async -> ApiResult {
|
||||
var b = body; b["code"] = code
|
||||
return post("/sync.php", body: b)
|
||||
}
|
||||
|
||||
/// 查询朋友信息
|
||||
static func friendInfo(code: String) async -> [String: Any]? {
|
||||
do {
|
||||
let res = try await postDict("/friend_info.php", body: ["friend_code": code])
|
||||
return res
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
/// 朋友设置就寝时间
|
||||
static func friendSetBedtime(friendCode: String, bedtime: Int, waketime: Int) async -> ApiResult {
|
||||
post("/friend_set_bedtime.php", body: ["friend_code": friendCode, "bedtime": bedtime, "waketime": waketime])
|
||||
}
|
||||
|
||||
/// 朋友触发指令 (sleep/wake/message)
|
||||
static func friendTrigger(friendCode: String, action: String, message: String = "") async -> ApiResult {
|
||||
post("/friend_trigger.php", body: ["friend_code": friendCode, "action": action, "message": message])
|
||||
}
|
||||
|
||||
/// 检查更新
|
||||
static func checkUpdate(versionCode: Int) async -> [String: Any]? {
|
||||
do {
|
||||
return try await postDict("/check_update.php", body: ["version_code": versionCode])
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
// MARK: - 底层 HTTP
|
||||
|
||||
private static func post(_ path: String, body: [String: Any]) -> ApiResult {
|
||||
do {
|
||||
let data = try postData(path, body: body)
|
||||
return try decoder.decode(ApiResult.self, from: data)
|
||||
} catch {
|
||||
return ApiResult(ok: false, error: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func postDict(_ path: String, body: [String: Any]) async throws -> [String: Any] {
|
||||
let data = try await postDataAsync(path, body: body)
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw URLError(.cannotParseResponse)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private static func postData(_ path: String, body: [String: Any]) throws -> Data {
|
||||
var semaphoreResult: Result<Data, Error> = .failure(URLError(.unknown))
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
do { semaphoreResult = .success(try await postDataAsync(path, body: body)) }
|
||||
catch { semaphoreResult = .failure(error) }
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return try semaphoreResult.get()
|
||||
}
|
||||
|
||||
private static func postDataAsync(_ path: String, body: [String: Any]) async throws -> Data {
|
||||
guard let url = URL(string: base + path) else { throw URLError(.badURL) }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.timeoutInterval = 10
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import UserNotifications
|
||||
|
||||
/// 睡前 30 分钟提醒调度器(iOS 使用 UNUserNotificationCenter)
|
||||
enum BedtimeReminder {
|
||||
|
||||
static func schedule(store: Store) {
|
||||
guard store.agreed, store.bedtimeReminderEnabled else {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getNotificationSettings { settings in
|
||||
guard settings.authorizationStatus == .authorized else { return }
|
||||
|
||||
let triggerDate = calcNextTrigger(bedtimeMinutes: store.bedtimeMinutes)
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "睡了吗 · 就寝提醒"
|
||||
content.body = "还有 30 分钟到您的睡觉时间 \(TimeUtil.fmt(store.bedtimeMinutes)),准备休息啦~"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = "BEDTIME_REMINDER"
|
||||
|
||||
let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: triggerDate)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "bedtime_reminder",
|
||||
content: content,
|
||||
trigger: trigger)
|
||||
center.add(request) { error in
|
||||
if let e = error { print("BedtimeReminder schedule error: \(e)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func cancel() {
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["bedtime_reminder"])
|
||||
}
|
||||
|
||||
/// 计算下一次提醒时间(bedtime 前 30 分钟)
|
||||
private static func calcNextTrigger(bedtimeMinutes: Int) -> Date {
|
||||
let minutesBefore = 30
|
||||
var reminderMin = bedtimeMinutes - minutesBefore
|
||||
if reminderMin < 0 { reminderMin += 24 * 60 }
|
||||
|
||||
var cal = Calendar.current
|
||||
var comps = cal.dateComponents([.year, .month, .day], from: Date())
|
||||
comps.hour = reminderMin / 60
|
||||
comps.minute = reminderMin % 60
|
||||
comps.second = 0
|
||||
|
||||
var candidate = cal.date(from: comps) ?? Date()
|
||||
if candidate <= Date() {
|
||||
candidate = cal.date(byAdding: .day, value: 1, to: candidate) ?? candidate
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// 请求通知权限
|
||||
static func requestPermission() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import Foundation
|
||||
|
||||
/// 全局数据中心(基于 UserDefaults),等价于 Android SharedPreferences
|
||||
@MainActor
|
||||
final class Store: ObservableObject {
|
||||
static let shared = Store()
|
||||
|
||||
// MARK: - 设置项
|
||||
@Published var bedtimeMinutes: Int = 23 * 60
|
||||
@Published var waketimeMinutes: Int = 7 * 60
|
||||
@Published var supervisionEnabled: Bool = true
|
||||
@Published var darkMode: Bool = false
|
||||
@Published var leadMinutes: Int = 30
|
||||
@Published var nagMinutes: Int = 10
|
||||
@Published var navStyle: Int = 0 // 0=默认, 1=无边框
|
||||
@Published var fontScale: Double = 1.0 // 0.85~1.3
|
||||
@Published var fontStyle: Int = 0 // 0=默认, 1=圆体, 2=等宽
|
||||
@Published var cardRadius: Int = 18 // 4/18/28
|
||||
@Published var blurStrength: Int = 1 // 0=无, 1=轻, 2=强
|
||||
@Published var bedtimeReminderEnabled: Bool = true
|
||||
@Published var backgroundRunning: Bool = true
|
||||
@Published var controlCodeEnabled: Bool = true
|
||||
|
||||
// MARK: - 控制码 & 账户
|
||||
@Published var controlCode: String = ""
|
||||
@Published var nickname: String = "未命名"
|
||||
@Published var agreed: Bool = false
|
||||
|
||||
// MARK: - 运行状态
|
||||
@Published var isSleeping: Bool = false
|
||||
@Published var sleepStartMillis: Double = 0
|
||||
@Published var lastNagMillis: Double = 0
|
||||
|
||||
// MARK: - 屏蔽应用包名(iOS 不支持实际屏蔽,仅记录)
|
||||
@Published var blockedPackages: Set<String> = []
|
||||
|
||||
// MARK: - 睡眠记录
|
||||
@Published var recordLines: Set<String> = []
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
private init() { load() }
|
||||
|
||||
func load() {
|
||||
let d = defaults
|
||||
bedtimeMinutes = d.integer(forKey: "bed")
|
||||
if bedtimeMinutes == 0 { bedtimeMinutes = 23 * 60 }
|
||||
waketimeMinutes = d.integer(forKey: "wake")
|
||||
if waketimeMinutes == 0 { waketimeMinutes = 7 * 60 }
|
||||
supervisionEnabled = d.object(forKey: "sup") as? Bool ?? true
|
||||
darkMode = d.bool(forKey: "dark")
|
||||
leadMinutes = d.integer(forKey: "lead"); if leadMinutes == 0 { leadMinutes = 30 }
|
||||
nagMinutes = d.integer(forKey: "nag"); if nagMinutes == 0 { nagMinutes = 10 }
|
||||
navStyle = d.integer(forKey: "nav_style")
|
||||
fontScale = d.double(forKey: "font_scale"); if fontScale == 0 { fontScale = 1.0 }
|
||||
fontStyle = d.integer(forKey: "font_style")
|
||||
cardRadius = d.integer(forKey: "card_radius"); if cardRadius == 0 { cardRadius = 18 }
|
||||
blurStrength = d.integer(forKey: "blur_strength"); if blurStrength == 0 { blurStrength = 1 }
|
||||
bedtimeReminderEnabled = d.object(forKey: "bed_reminder") as? Bool ?? true
|
||||
backgroundRunning = d.object(forKey: "bg_run") as? Bool ?? true
|
||||
controlCodeEnabled = d.object(forKey: "control_code_enabled") as? Bool ?? true
|
||||
controlCode = d.string(forKey: "control_code") ?? ""
|
||||
nickname = d.string(forKey: "nickname") ?? "未命名"
|
||||
agreed = d.bool(forKey: "agreed")
|
||||
isSleeping = d.bool(forKey: "sleeping")
|
||||
sleepStartMillis = d.double(forKey: "start")
|
||||
lastNagMillis = d.double(forKey: "lastNag")
|
||||
blockedPackages = Set(d.stringArray(forKey: "blocked") ?? [])
|
||||
recordLines = Set(d.stringArray(forKey: "records") ?? [])
|
||||
}
|
||||
|
||||
func save() {
|
||||
let d = defaults
|
||||
d.set(bedtimeMinutes, forKey: "bed")
|
||||
d.set(waketimeMinutes, forKey: "wake")
|
||||
d.set(supervisionEnabled, forKey: "sup")
|
||||
d.set(darkMode, forKey: "dark")
|
||||
d.set(leadMinutes, forKey: "lead")
|
||||
d.set(nagMinutes, forKey: "nag")
|
||||
d.set(navStyle, forKey: "nav_style")
|
||||
d.set(fontScale, forKey: "font_scale")
|
||||
d.set(fontStyle, forKey: "font_style")
|
||||
d.set(cardRadius, forKey: "card_radius")
|
||||
d.set(blurStrength, forKey: "blur_strength")
|
||||
d.set(bedtimeReminderEnabled, forKey: "bed_reminder")
|
||||
d.set(backgroundRunning, forKey: "bg_run")
|
||||
d.set(controlCodeEnabled, forKey: "control_code_enabled")
|
||||
d.set(controlCode, forKey: "control_code")
|
||||
d.set(nickname, forKey: "nickname")
|
||||
d.set(agreed, forKey: "agreed")
|
||||
d.set(isSleeping, forKey: "sleeping")
|
||||
d.set(sleepStartMillis, forKey: "start")
|
||||
d.set(lastNagMillis, forKey: "lastNag")
|
||||
d.set(Array(blockedPackages), forKey: "blocked")
|
||||
d.set(Array(recordLines), forKey: "records")
|
||||
}
|
||||
|
||||
// MARK: - 控制码生成
|
||||
private static let codeChars = Array("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
|
||||
|
||||
func ensureControlCode() {
|
||||
guard controlCode.isEmpty else { return }
|
||||
let raw = (0..<10).map { _ in
|
||||
String(Store.codeChars[Int.random(in: 0..<Store.codeChars.count)])
|
||||
}.joined()
|
||||
controlCode = raw.prefix(4) + "-" + raw.dropFirst(4).prefix(4) + "-" + raw.dropFirst(8)
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - 睡眠状态
|
||||
func startSleep() {
|
||||
isSleeping = true
|
||||
sleepStartMillis = Date().timeIntervalSince1970 * 1000
|
||||
save()
|
||||
}
|
||||
|
||||
func endSleep() {
|
||||
guard isSleeping, sleepStartMillis > 0 else { return }
|
||||
let endMs = Date().timeIntervalSince1970 * 1000
|
||||
let hours = max(0, (endMs - sleepStartMillis) / 3_600_000)
|
||||
let bedMin = TimeUtil.minuteOfDay(Date(timeIntervalSince1970: sleepStartMillis / 1000))
|
||||
let wakeMin = TimeUtil.minuteOfDay(Date(timeIntervalSince1970: endMs / 1000))
|
||||
let offset = bedMin < waketimeMinutes ? -1 : 0
|
||||
let night = TimeUtil.dateStr(Date(timeIntervalSince1970: sleepStartMillis / 1000), offsetDays: offset)
|
||||
let rec = SleepRecord(nightDate: night, bedMinutes: bedMin, wakeMinutes: wakeMin, sleepHours: hours)
|
||||
recordLines.removeAll { $0.hasPrefix(night + "|") }
|
||||
recordLines.insert(rec.serialize())
|
||||
isSleeping = false
|
||||
sleepStartMillis = 0
|
||||
save()
|
||||
}
|
||||
|
||||
func recentRecords(_ n: Int) -> [SleepRecord] {
|
||||
recordLines
|
||||
.compactMap(SleepRecord.parse)
|
||||
.sorted { $0.nightDate > $1.nightDate }
|
||||
.prefix(n)
|
||||
.map { $0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// 时间工具:统一用"自 00:00 起的分钟数"
|
||||
enum TimeUtil {
|
||||
/// 当前分钟数
|
||||
static func nowMinutes() -> Int {
|
||||
let cal = Calendar.current
|
||||
let h = cal.component(.hour, from: Date())
|
||||
let m = cal.component(.minute, from: Date())
|
||||
return h * 60 + m
|
||||
}
|
||||
|
||||
/// 格式化为 "HH:mm"
|
||||
static func fmt(_ minutes: Int) -> String {
|
||||
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
||||
}
|
||||
|
||||
/// 是否在睡眠窗口内(处理跨午夜)
|
||||
static func inNightWindow(_ now: Int, bedtime: Int, waketime: Int) -> Bool {
|
||||
if bedtime < waketime {
|
||||
return now >= bedtime && now < waketime
|
||||
} else {
|
||||
return now >= bedtime || now < waketime
|
||||
}
|
||||
}
|
||||
|
||||
/// 超出就寝时间的分钟数
|
||||
static func overdueMinutes(_ now: Int, bedtime: Int) -> Int {
|
||||
inNightWindow(now, bedtime: bedtime, waketime: bedtime + 1) ? now - bedtime : 0
|
||||
}
|
||||
|
||||
/// Date → 分钟数
|
||||
static func minuteOfDay(_ date: Date) -> Int {
|
||||
let cal = Calendar.current
|
||||
return cal.component(.hour, from: date) * 60 + cal.component(.minute, from: date)
|
||||
}
|
||||
|
||||
/// 日期字符串 "yyyy-MM-dd"
|
||||
static func dateStr(_ date: Date, offsetDays: Int = 0) -> String {
|
||||
let cal = Calendar.current
|
||||
let d = cal.date(byAdding: .day, value: offsetDays, to: date) ?? date
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f.string(from: d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AgreementView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("用户协议与隐私说明")
|
||||
.font(.title).bold()
|
||||
|
||||
Text("欢迎使用「睡了吗」。\n\n本协议说明应用功能与数据使用方式,请仔细阅读。")
|
||||
.font(.body).foregroundColor(.secondary)
|
||||
|
||||
section("一、应用功能") {
|
||||
Text("1. 睡眠监督:在您设定的就寝时段,提醒您按时入睡。\n(注:iOS 系统限制,本应用无法直接屏蔽其他应用,仅提供通知提醒。)\n2. 作息记录:记录您的入睡与起床时间,生成睡眠统计。\n3. 控制码功能(见第二条)。")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title).font(.headline)
|
||||
content().font(.subheadline).foregroundColor(.secondary)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
|
||||
StatsView()
|
||||
.tabItem {
|
||||
Image(systemName: "chart.bar.fill")
|
||||
Text("统计")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Image(systemName: "gearshape.fill")
|
||||
Text("设置")
|
||||
}
|
||||
.tag(2)
|
||||
}
|
||||
.tint(.blue)
|
||||
.onAppear {
|
||||
let appearance = UITabBarAppearance()
|
||||
appearance.configureWithDefaultBackground()
|
||||
UITabBar.appearance().scrollEdgeAppearance = appearance
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
@State private var now = Date()
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 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)
|
||||
|
||||
// 倒计时
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("睡了吗")
|
||||
.onReceive(timer) { t in now = t }
|
||||
}
|
||||
}
|
||||
|
||||
private var timeString: String {
|
||||
let f = DateFormatter(); f.dateFormat = "HH:mm"; return f.string(from: now)
|
||||
}
|
||||
|
||||
private var dateString: String {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy年M月d日 EEEE"; f.locale = Locale(identifier: "zh_CN")
|
||||
return f.string(from: now)
|
||||
}
|
||||
|
||||
private var greeting: String {
|
||||
let h = Calendar.current.component(.hour, from: now)
|
||||
switch h {
|
||||
case 0..<6: return "夜深了,好好休息"
|
||||
case 6..<12: return "早上好!新的一天开始了"
|
||||
case 12..<18: return "下午好,注意休息"
|
||||
default: return "晚上好,准备放松吧"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
if store.isSleeping { return "正在睡眠中" }
|
||||
let nowMin = TimeUtil.nowMinutes()
|
||||
if TimeUtil.inNightWindow(nowMin, bedtime: store.bedtimeMinutes, waketime: store.waketimeMinutes) {
|
||||
return "已到睡觉时间!"
|
||||
}
|
||||
return "距离睡觉还有"
|
||||
}
|
||||
|
||||
private var countdownText: String {
|
||||
if store.isSleeping {
|
||||
let elapsed = Int((Date().timeIntervalSince1970 * 1000 - store.sleepStartMillis) / 3_600_000)
|
||||
return "已睡 \(elapsed) 小时"
|
||||
}
|
||||
let nowMin = TimeUtil.nowMinutes()
|
||||
var remain = store.bedtimeMinutes - nowMin
|
||||
if remain < 0 { remain += 24 * 60 }
|
||||
return "\(remain / 60) 小时 \(remain % 60) 分钟"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusCard(title: String, value: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title).font(.caption).foregroundColor(.secondary)
|
||||
Text(value).font(.title3).bold()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FriendControlView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
@State private var friendCode = ""
|
||||
@State private var friendInfo: String?
|
||||
@State private var isLoading = false
|
||||
@State private var errorMsg: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
TextField("输入朋友的控制码", text: $friendCode)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.disableAutocorrection(true)
|
||||
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")
|
||||
}
|
||||
Button { triggerFriend("wake") } label: {
|
||||
Label("叫朋友起床", systemImage: "sunrise.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("控制朋友")
|
||||
.overlay { if isLoading { ProgressView() } }
|
||||
}
|
||||
|
||||
private func lookupFriend() {
|
||||
isLoading = true; errorMsg = nil; friendInfo = nil
|
||||
Task {
|
||||
if let res = await ApiClient.friendInfo(code: friendCode) {
|
||||
friendInfo = (res["nickname"] as? String) ?? friendCode
|
||||
} else {
|
||||
errorMsg = "未找到该用户"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerFriend(_ action: String) {
|
||||
Task {
|
||||
let res = await ApiClient.friendTrigger(friendCode: friendCode, action: action)
|
||||
if !res.ok { errorMsg = res.error ?? "操作失败" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
@State private var showAgreement = false
|
||||
@State private var showStyleSettings = false
|
||||
|
||||
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)
|
||||
}
|
||||
Spacer()
|
||||
if !store.controlCode.isEmpty {
|
||||
ShareLink(item: shareText) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { pickTime(isBed: true) }
|
||||
|
||||
HStack {
|
||||
Text("目标起床时间")
|
||||
Spacer()
|
||||
Text(TimeUtil.fmt(store.waketimeMinutes)).foregroundColor(.blue)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { pickTime(isBed: false) }
|
||||
} header: { Text("作息") }
|
||||
|
||||
// MARK: - 提醒
|
||||
Section {
|
||||
Toggle("睡前 30 分钟提醒", isOn: $store.bedtimeReminderEnabled)
|
||||
.onChange(of: store.bedtimeReminderEnabled) { _ in
|
||||
store.save()
|
||||
BedtimeReminder.schedule(store: store)
|
||||
}
|
||||
} 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("样式")
|
||||
}
|
||||
} header: { Text("外观") }
|
||||
|
||||
// MARK: - 朋友
|
||||
Section {
|
||||
NavigationLink("控制朋友") {
|
||||
FriendControlView()
|
||||
}
|
||||
} header: { Text("朋友") }
|
||||
|
||||
// MARK: - 其他
|
||||
Section {
|
||||
Button("用户协议与隐私说明") { showAgreement = true }
|
||||
}
|
||||
}
|
||||
.navigationTitle("设置")
|
||||
.sheet(isPresented: $showAgreement) {
|
||||
AgreementView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareText: String {
|
||||
let code = store.controlCode
|
||||
let webLink = "http://api.ausleep.parlz.com/control?code=\(code)"
|
||||
return """
|
||||
我在用「睡了吗」监督睡眠,这是我的控制码:\(code)
|
||||
点击链接直接连接(需安装「睡了吗」):
|
||||
\(webLink)
|
||||
"""
|
||||
}
|
||||
|
||||
private func pickTime(isBed: Bool) {
|
||||
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
|
||||
guard let root = scene?.windows.first?.rootViewController else { return }
|
||||
|
||||
let vc = UIViewController()
|
||||
let picker = UIDatePicker()
|
||||
picker.datePickerMode = .time
|
||||
picker.preferredDatePickerStyle = .wheels
|
||||
picker.frame = CGRect(x: 0, y: 50, width: 320, height: 216)
|
||||
|
||||
let alert = UIAlertController(title: isBed ? "就寝时间" : "起床时间", message: nil, preferredStyle: .alert)
|
||||
alert.view.addSubview(picker)
|
||||
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
|
||||
let cal = Calendar.current
|
||||
let h = cal.component(.hour, from: picker.date)
|
||||
let m = cal.component(.minute, from: picker.date)
|
||||
let v = h * 60 + m
|
||||
if isBed {
|
||||
store.bedtimeMinutes = v
|
||||
BedtimeReminder.schedule(store: store)
|
||||
} else {
|
||||
store.waketimeMinutes = v
|
||||
}
|
||||
store.save()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
alert.view.frame = CGRect(x: 0, y: 0, width: 320, height: 350)
|
||||
root.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatsView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 最近记录
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("统计")
|
||||
}
|
||||
}
|
||||
|
||||
private var avgHours: String {
|
||||
guard !records.isEmpty else { return "-" }
|
||||
return String(format: "%.1f", records.map(\.sleepHours).reduce(0, +) / Double(records.count))
|
||||
}
|
||||
|
||||
private var goalRate: Double {
|
||||
guard !records.isEmpty else { return 0 }
|
||||
return Double(records.filter { $0.sleepHours >= 7 }.count) / Double(records.count)
|
||||
}
|
||||
|
||||
private var streakDays: Int {
|
||||
var count = 0
|
||||
for r in records {
|
||||
if r.sleepHours >= 7 { count += 1 } else { break }
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
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 {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func recordRow(_ rec: SleepRecord) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(rec.nightDate).font(.subheadline).bold()
|
||||
Text("\(TimeUtil.fmt(rec.bedMinutes)) → \(TimeUtil.fmt(rec.wakeMinutes))")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(String(format: "%.1f 小时", rec.sleepHours))
|
||||
.font(.headline).foregroundColor(rec.sleepHours >= 7 ? .green : .orange)
|
||||
}
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StyleSettingsView: View {
|
||||
@EnvironmentObject var store: Store
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("暗色模式", isOn: $store.darkMode)
|
||||
.onChange(of: store.darkMode) { _ in store.save() }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Slider(value: $store.fontScale, in: 0.85...1.30, step: 0.05)
|
||||
.onChange(of: store.fontScale) { _ in store.save() }
|
||||
}
|
||||
} 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("卡片") }
|
||||
|
||||
Section {
|
||||
Picker("模糊强度", selection: $store.blurStrength) {
|
||||
Text("无").tag(0)
|
||||
Text("轻微").tag(1)
|
||||
Text("强烈").tag(2)
|
||||
}
|
||||
.onChange(of: store.blurStrength) { _ in store.save() }
|
||||
} header: { Text("模糊") }
|
||||
}
|
||||
.navigationTitle("样式设置")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"sourceFiles": [
|
||||
"AreYouSleepingApp.swift",
|
||||
"Store.swift",
|
||||
"Models/SleepRecord.swift",
|
||||
"Utils/TimeUtil.swift",
|
||||
"Services/ApiClient.swift",
|
||||
"Services/BedtimeReminder.swift",
|
||||
"Views/AgreementView.swift",
|
||||
"Views/ContentView.swift",
|
||||
"Views/DashboardView.swift",
|
||||
"Views/StatsView.swift",
|
||||
"Views/SettingsView.swift",
|
||||
"Views/StyleSettingsView.swift",
|
||||
"Views/FriendControlView.swift"
|
||||
],
|
||||
"info": {
|
||||
"deploymentTarget": "iOS 16.0",
|
||||
"bundleIdentifier": "com.ausleep.ios",
|
||||
"displayName": "睡了吗",
|
||||
"team": ""
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "AreYouSleeping",
|
||||
platforms: [.iOS(.v16)],
|
||||
products: [
|
||||
.library(name: "AreYouSleeping", targets: ["AreYouSleeping"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "AreYouSleeping",
|
||||
path: "AreYouSleeping",
|
||||
resources: [.process("Info.plist")]
|
||||
),
|
||||
]
|
||||
)
|
||||
在新工单中引用
屏蔽一个用户