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")]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
在新工单中引用
屏蔽一个用户