iOS版本初始化:睡了吗 SwiftUI 项目

这个提交包含在:
JGZ_YES
2026-08-11 21:11:24 +08:00
当前提交 3fa73e052e
修改 18 个文件,包含 1337 行新增0 行删除
+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
+218
查看文件
@@ -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;
}
+23
查看文件
@@ -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)
}
}
}
}
+51
查看文件
@@ -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>
+24
查看文件
@@ -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)
}
}
+92
查看文件
@@ -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 }
}
}
+140
查看文件
@@ -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 }
}
}
+46
查看文件
@@ -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)
}
}
+80
查看文件
@@ -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)
}
}
}
+38
查看文件
@@ -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
}
}
}
+134
查看文件
@@ -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 ?? "操作失败" }
}
}
}
+145
查看文件
@@ -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)
}
}
+91
查看文件
@@ -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("样式设置")
}
}
+23
查看文件
@@ -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")]
),
]
)