语记手表版 v0.1:一键录音、WatchConnectivity 传输、表盘复杂功能、iPhone 接收转写
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
*.xcworkspace/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
import SwiftData
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
/// iPhone 端接收胶水代码:拖进 YuJi iPhone 工程即可。
|
||||||
|
/// 在 YuJiApp 启动时调用 WatchSyncService.shared.activate(modelContext:)。
|
||||||
|
/// 收到手表传来的录音文件后:拷进 AudioStorage → 系统语音识别转写 → 生成一条 VoiceNote,
|
||||||
|
/// 之后在 App 里点 AI 整理即可(和正常录音的笔记完全一样)。
|
||||||
|
final class WatchSyncService: NSObject, WCSessionDelegate, ObservableObject {
|
||||||
|
static let shared = WatchSyncService()
|
||||||
|
|
||||||
|
private var modelContext: ModelContext?
|
||||||
|
private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN"))
|
||||||
|
|
||||||
|
func activate(modelContext: ModelContext) {
|
||||||
|
self.modelContext = modelContext
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
let session = WCSession.default
|
||||||
|
session.delegate = self
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
|
// 激活完成即可收文件
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||||
|
func sessionDidDeactivate(_ session: WCSession) {}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceive file: WCSessionFile) {
|
||||||
|
let fileName = file.fileURL.lastPathComponent
|
||||||
|
let destURL = AudioStorage.directory.appendingPathComponent(fileName)
|
||||||
|
do {
|
||||||
|
if FileManager.default.fileExists(atPath: destURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: destURL)
|
||||||
|
}
|
||||||
|
try FileManager.default.copyItem(at: file.fileURL, to: destURL)
|
||||||
|
} catch {
|
||||||
|
print("手表录音保存失败:\(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transcribeAndSave(audioURL: destURL, fileName: fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 转写并建笔记
|
||||||
|
|
||||||
|
private func transcribeAndSave(audioURL: URL, fileName: String) {
|
||||||
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
|
// 识别暂不可用:先建一条空转写笔记,用户可在 App 里重新整理
|
||||||
|
insertNote(title: "手表速记", transcript: "", fileName: fileName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let request = SFSpeechURLRecognitionRequest(url: audioURL)
|
||||||
|
if recognizer.supportsOnDeviceRecognition {
|
||||||
|
request.requiresOnDeviceRecognition = true
|
||||||
|
}
|
||||||
|
recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
|
guard let self else { return }
|
||||||
|
guard let result, result.isFinal else {
|
||||||
|
if error != nil {
|
||||||
|
self.insertNote(title: "手表速记", transcript: "", fileName: fileName)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let text = result.bestTranscription.formattedString
|
||||||
|
let title = text.count > 20 ? String(text.prefix(20)) + "…" : (text.isEmpty ? "手表速记" : text)
|
||||||
|
self.insertNote(title: title, transcript: text, fileName: fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func insertNote(title: String, transcript: String, fileName: String) {
|
||||||
|
guard let modelContext else { return }
|
||||||
|
let note = VoiceNote(title: title, transcript: transcript, audioFileName: fileName)
|
||||||
|
modelContext.insert(note)
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 语记手表版 YuJiWatch — Apple Watch 伴生 App
|
||||||
|
|
||||||
|
抬腕点一下就录,录完自动传到 iPhone:手机端系统语音识别转写,再 AI 整理成 Markdown。
|
||||||
|
手表只负责录和传,整理、存储、分享都在 iPhone 端复用语记现有能力。
|
||||||
|
|
||||||
|
## 能做什么
|
||||||
|
|
||||||
|
- ⌚ 手表主页一键录音(72pt 红色话筒钮),录音中显示计时,停止即发送
|
||||||
|
- 📲 WatchConnectivity 传文件到 iPhone,iPhone 端自动转写并生成笔记
|
||||||
|
- 🧩 表盘复杂功能:圆形 / 长条形 / 行内形三种,显示今日速记数,点一下打开手表 App 开始录音
|
||||||
|
- 📋 手表端最近录音列表(时间、时长、是否已传到 iPhone)
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
YuJiWatch/
|
||||||
|
├── YuJiWatchApp/ # → Watch App target
|
||||||
|
│ ├── YuJiWatchApp.swift # 入口
|
||||||
|
│ ├── ContentView.swift # 主页:录音 / 今日数 / 最近录音
|
||||||
|
│ ├── WatchRecorder.swift # AVAudioRecorder 录 AAC .m4a
|
||||||
|
│ ├── WatchSessionManager.swift # WCSession 传文件 + 今日数 + 刷新复杂功能
|
||||||
|
│ └── WatchStore.swift # 手表本地最近录音记录
|
||||||
|
├── YuJiWatchWidget/ # → Widget Extension target
|
||||||
|
│ └── Complication.swift # 三种复杂功能
|
||||||
|
└── Phone/ # → 拖进 YuJi iPhone 工程
|
||||||
|
└── WatchSyncService.swift # iPhone 端收文件、转写、建笔记
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`WatchSessionManager.swift` 要同时放进 Watch App 和 Widget Extension 两个 target,
|
||||||
|
因为复杂功能读今日数走它里面的 `loadTodayCount()`。两个 target 各有一个 `@main`,不要把
|
||||||
|
`Complication.swift` 放进 Watch App target,否则 `@main` 冲突。
|
||||||
|
|
||||||
|
## 在 Mac 上接进 YuJi 工程
|
||||||
|
|
||||||
|
1. 用 Xcode 16+ 打开 YuJi 工程 → File → New → Target → Watch App,部署目标选 watchOS 10
|
||||||
|
(watchOS 10 对应 Series 4 及以后、SE、Ultra 都能用;不需要 Ultra 专属功能)
|
||||||
|
2. 把 `YuJiWatchApp/` 里的文件加进 Watch App target;新建 Widget Extension target,
|
||||||
|
把 `YuJiWatchWidget/Complication.swift` 和 `WatchSessionManager.swift` 加进去
|
||||||
|
3. 把 `Phone/WatchSyncService.swift` 拖进 YuJi iPhone target,在 YuJiApp 启动时调一次:
|
||||||
|
`WatchSyncService.shared.activate(modelContext:)`(modelContext 就是 SwiftData 那个)
|
||||||
|
4. 真机联调:手表和 iPhone 配对后,手表录一段 → iPhone 端应该自动多出一条“手表速记”笔记
|
||||||
|
|
||||||
|
## 可选:App Group
|
||||||
|
|
||||||
|
现在今日数存在手表本地 UserDefaults,只给复杂功能用。如果想让 iPhone 端也读到今日数,
|
||||||
|
在 Xcode 里给 Watch App、Widget Extension、iPhone App 三个 target 开同一个 App Group,
|
||||||
|
把 `UserDefaults.standard` 换成 `UserDefaults(suiteName: "group.com.你的域名.yuji")` 即可。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 这份代码在 Linux 环境下写的,没在 Xcode 里实际编译过。第一次打开如果有个别编译报错,
|
||||||
|
把报错发给 Muse,一轮就能修好。
|
||||||
|
- 手表录音是短录音场景:抬腕说完就停,不要设计成长时间连续录(续航和后台限制)。
|
||||||
|
- 手表自己能录音,但转写和 AI 整理必须连上 iPhone 才回来;蜂窝版也一样,只是传输时机不同。
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 手表主页:一键录音、今日数、最近录音;录完传 iPhone,整理在手机端完成。
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject private var sessionManager: WatchSessionManager
|
||||||
|
@StateObject private var recorder = WatchRecorder()
|
||||||
|
@State private var phase = Phase.idle
|
||||||
|
@State private var notes = WatchStore.load()
|
||||||
|
@State private var lastURL: URL?
|
||||||
|
|
||||||
|
enum Phase {
|
||||||
|
case idle, recording, sending, sent, error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
recordButton
|
||||||
|
Text("今日速记 \(sessionManager.todayCount) 条")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case .recording:
|
||||||
|
recordingView
|
||||||
|
case .sending:
|
||||||
|
ProgressView("正在传到 iPhone…")
|
||||||
|
case .sent:
|
||||||
|
Label("已传到 iPhone", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Button("再录一条") { phase = .idle }
|
||||||
|
.font(.caption)
|
||||||
|
case .error(let message):
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
if let url = lastURL {
|
||||||
|
Button("重试传输") { send(url: url) }
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Button("返回") { phase = .idle }
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !notes.isEmpty {
|
||||||
|
Divider()
|
||||||
|
ForEach(notes.prefix(5)) { note in
|
||||||
|
HStack {
|
||||||
|
Text(note.createdAt, format: .dateTime.hour().minute())
|
||||||
|
.font(.caption2)
|
||||||
|
Spacer()
|
||||||
|
Text(formatTime(note.duration))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Image(systemName: note.sent ? "checkmark.icloud.fill" : "paperplane")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(note.sent ? .green : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recordButton: some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
guard await recorder.requestPermission() else {
|
||||||
|
phase = .error("没有麦克风权限,请在手表设置里开启。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try recorder.start()
|
||||||
|
phase = .recording
|
||||||
|
} catch {
|
||||||
|
phase = .error("录音启动失败:\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Color.red)
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recordingView: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle().fill(Color.red).frame(width: 8, height: 8)
|
||||||
|
Text("录音中 \(formatTime(recorder.elapsed))")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Button("停止并发送") {
|
||||||
|
guard let url = recorder.stop() else {
|
||||||
|
phase = .error("录音文件没生成,请再试一次。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastURL = url
|
||||||
|
send(url: url)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(url: URL) {
|
||||||
|
phase = .sending
|
||||||
|
sessionManager.transfer(url: url) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
notes.insert(WatchNote(createdAt: Date(), duration: recorder.elapsed, sent: true), at: 0)
|
||||||
|
WatchStore.append(notes[0])
|
||||||
|
phase = .sent
|
||||||
|
case .failure(let error):
|
||||||
|
phase = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ time: TimeInterval) -> String {
|
||||||
|
let total = Int(time)
|
||||||
|
return String(format: "%d:%02d", total / 60, total % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 手表端录音:AVAudioRecorder 写 AAC .m4a,存在手表 Documents 里,停后交给 WatchSessionManager 传到 iPhone。
|
||||||
|
@MainActor
|
||||||
|
final class WatchRecorder: ObservableObject {
|
||||||
|
@Published var isRecording = false
|
||||||
|
@Published var elapsed: TimeInterval = 0
|
||||||
|
|
||||||
|
private var recorder: AVAudioRecorder?
|
||||||
|
private var timer: Timer?
|
||||||
|
private var startDate: Date?
|
||||||
|
private(set) var currentFileURL: URL?
|
||||||
|
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() throws {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.record, mode: .default)
|
||||||
|
try session.setActive(true)
|
||||||
|
|
||||||
|
let fileName = "watch-note-\(Int(Date().timeIntervalSince1970)).m4a"
|
||||||
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let url = docs.appendingPathComponent(fileName)
|
||||||
|
currentFileURL = url
|
||||||
|
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||||
|
AVSampleRateKey: 44_100,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
||||||
|
]
|
||||||
|
recorder = try AVAudioRecorder(url: url, settings: settings)
|
||||||
|
recorder?.record()
|
||||||
|
|
||||||
|
isRecording = true
|
||||||
|
startDate = Date()
|
||||||
|
elapsed = 0
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, let start = self.startDate else { return }
|
||||||
|
self.elapsed = Date().timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止录音,返回录音文件 URL(nil 表示没录成)
|
||||||
|
func stop() -> URL? {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
recorder?.stop()
|
||||||
|
recorder = nil
|
||||||
|
isRecording = false
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false)
|
||||||
|
return currentFileURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
/// 手表端会话管理:激活 WCSession、传录音文件到 iPhone、维护今日速记数并刷新复杂功能。
|
||||||
|
@MainActor
|
||||||
|
final class WatchSessionManager: NSObject, ObservableObject, WCSessionDelegate {
|
||||||
|
static let todayCountKey = "yujiTodayCount"
|
||||||
|
static let todayDateKey = "yujiTodayDate"
|
||||||
|
|
||||||
|
@Published var transferState = ""
|
||||||
|
@Published var todayCount = 0
|
||||||
|
|
||||||
|
private var completions: [String: (Result<Void, Error>) -> Void] = [:]
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
todayCount = Self.loadTodayCount()
|
||||||
|
if WCSession.isSupported() {
|
||||||
|
let session = WCSession.default
|
||||||
|
session.delegate = self
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 今日速记数:按自然日归零,存在手表本地 UserDefaults
|
||||||
|
static func loadTodayCount() -> Int {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
|
if let saved = defaults.object(forKey: todayDateKey) as? Date,
|
||||||
|
Calendar.current.isDate(saved, inSameDayAs: today) {
|
||||||
|
return defaults.integer(forKey: todayCountKey)
|
||||||
|
}
|
||||||
|
defaults.set(today, forKey: todayDateKey)
|
||||||
|
defaults.set(0, forKey: todayCountKey)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bumpTodayCount() {
|
||||||
|
todayCount = Self.loadTodayCount() + 1
|
||||||
|
UserDefaults.standard.set(todayCount, forKey: todayCountKey)
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 传录音文件到 iPhone,完成后回调(成功时今日数 +1 并刷新复杂功能)
|
||||||
|
func transfer(url: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
guard WCSession.isSupported(), WCSession.default.activationState == .activated else {
|
||||||
|
completion(.failure(NSError(domain: "YuJiWatch", code: 1, userInfo: [NSLocalizedDescriptionKey: "手表与 iPhone 未连接,录音先存在手表上,连上后会自动重传。"])))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let transfer = WCSession.default.transferFile(url, metadata: ["createdAt": Date().timeIntervalSince1970])
|
||||||
|
completions[transfer.file.fileURL.lastPathComponent] = completion
|
||||||
|
transferState = "正在传到 iPhone…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
|
// 激活完成即可传文件,无需额外处理
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: Error?) {
|
||||||
|
let key = fileTransfer.file.fileURL.lastPathComponent
|
||||||
|
guard let completion = completions.removeValue(forKey: key) else { return }
|
||||||
|
if let error {
|
||||||
|
transferState = "传输失败:\(error.localizedDescription)"
|
||||||
|
completion(.failure(error))
|
||||||
|
} else {
|
||||||
|
transferState = "已传到 iPhone"
|
||||||
|
bumpTodayCount()
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 手表本地最近录音记录(仅用于手表端列表展示,真正笔记在 iPhone 端生成)
|
||||||
|
struct WatchNote: Identifiable, Codable {
|
||||||
|
var id = UUID()
|
||||||
|
var createdAt: Date
|
||||||
|
var duration: TimeInterval
|
||||||
|
var sent: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WatchStore {
|
||||||
|
private static let key = "yujiWatchNotes"
|
||||||
|
|
||||||
|
static func load() -> [WatchNote] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key),
|
||||||
|
let notes = try? JSONDecoder().decode([WatchNote].self, from: data) else { return [] }
|
||||||
|
return notes.sorted { $0.createdAt > $1.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func append(_ note: WatchNote) {
|
||||||
|
var notes = load()
|
||||||
|
notes.append(note)
|
||||||
|
if let data = try? JSONEncoder().encode(notes) {
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 语记手表版入口:watchOS 10+,SwiftUI
|
||||||
|
@main
|
||||||
|
struct YuJiWatchApp: App {
|
||||||
|
@StateObject private var sessionManager = WatchSessionManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(sessionManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
/// 语记复杂功能:显示今日速记数,点一下打开手表 App 开始录音。
|
||||||
|
/// 放进 Widget Extension target;今日数读的是手表本地 UserDefaults(与 WatchSessionManager 共用 key)。
|
||||||
|
struct YuJiEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct YuJiProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> YuJiEntry {
|
||||||
|
YuJiEntry(date: Date(), count: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (YuJiEntry) -> Void) {
|
||||||
|
completion(YuJiEntry(date: Date(), count: WatchSessionManager.loadTodayCount()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<YuJiEntry>) -> Void) {
|
||||||
|
let entry = YuJiEntry(date: Date(), count: WatchSessionManager.loadTodayCount())
|
||||||
|
let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
|
||||||
|
completion(Timeline(entries: [entry], policy: .after(nextMidnight)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct YuJiComplicationView: View {
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
var entry: YuJiEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryCircular:
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Image(systemName: "mic.fill").font(.caption2)
|
||||||
|
Text("\(entry.count)").font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .accessoryRectangular:
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("语记").font(.caption2)
|
||||||
|
Text("今日 \(entry.count) 条").font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "mic.fill").font(.caption2)
|
||||||
|
Text("\(entry.count)").font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct YuJiComplicationWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: "YuJiComplication", provider: YuJiProvider()) { entry in
|
||||||
|
YuJiComplicationView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("语记")
|
||||||
|
.description("今日速记数,点一下开始录音")
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct YuJiWidgets: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
YuJiComplicationWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user