语记手表版 v0.1:一键录音、WatchConnectivity 传输、表盘复杂功能、iPhone 接收转写
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user