fix(watch): 1.5.1 — 탭 이중 실행 방어(commandID)·미니 화면 접수 피드백 후 자동 메인 복귀
- 가끔 횟수 +2 실측: 응답 유실 시 큐 재전송이 같은 명령을 두 번 실행 — run 명령에 탭당 고유 commandID 동봉, iPhone이 최근 64개 기억해 중복 무시 - 컴플리케이션 미니 화면: 탭 → 체크 오버레이+눌림 축소(기존 진동과 함께) 0.7초 → 자동 메인 복귀, 접수 중 재탭 무시 (놔둔 채 오탭 방지 — 사용자 요구) - QA: -watchDetailAutoTap 신설, 시드 모드 미활성 세션 전송 가드 - 검증: 4종 빌드 그린, 워치 26.2·11.5 시각 QA, 페어링 E2E 실행 1회·중복 0 - MARKETING_VERSION 1.5.1(빌드 1), whats-new-1.5.1 3언어 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
This commit is contained in:
parent
eba01b570f
commit
7799998a2d
File diff suppressed because one or more lines are too long
@ -601,7 +601,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -620,7 +620,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@ -643,7 +643,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -662,7 +662,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@ -686,7 +686,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -697,7 +697,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -722,7 +722,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -733,7 +733,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -758,7 +758,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
|
||||
@ -770,7 +770,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.HaruDanim.watchkitapp.Haru-DanimWatchWidgets";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -794,7 +794,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
|
||||
@ -806,7 +806,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.HaruDanim.watchkitapp.Haru-DanimWatchWidgets";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -827,7 +827,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Widgets/Info.plist;
|
||||
@ -838,7 +838,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@ -853,7 +853,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Widgets/Info.plist;
|
||||
@ -864,7 +864,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@ -11,6 +11,9 @@
|
||||
// 탭 동작을 알리고, 아래 한 줄 힌트가 상태별 동작(시작/종료/1회 추가)을 설명한다.
|
||||
// 측정 중 시그널은 앱 전체 문법 그대로: 노란 테두리 + 노란 정지 배지 + 노란 타이머.
|
||||
// 이 구성은 가장 작은 워치(40mm)에서도 스크롤 없이 한 화면에 들어간다.
|
||||
// - 1.5.1: 탭하면 접수 피드백(체크 오버레이+눌림 축소, 기존 진동과 함께)을 잠깐 보여 준 뒤
|
||||
// 자동으로 메인에 복귀한다 — 화면을 놔둔 채 손목 오탭으로 또 실행되는 것 방지(사용자 요구).
|
||||
// 접수 중 재탭은 무시(워치 쪽 이중 실행 차단 — iPhone 쪽 commandID 중복 방어와 이중 안전망).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@ -18,8 +21,14 @@ import WatchKit
|
||||
|
||||
struct WatchActionDetailView: View {
|
||||
@State private var store = WatchStore.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let actionID: UUID
|
||||
|
||||
/// 실행 접수 상태 (1.5.1) — 탭 즉시 체크 오버레이+눌림 축소로 접수를 보여 주고,
|
||||
/// 잠깐 뒤 자동으로 메인 화면에 복귀한다(화면을 놔둔 채 잘못 또 누르는 것 방지 — 사용자 요구).
|
||||
/// 접수 중에는 재탭을 무시해 워치 쪽 이중 실행도 원천 차단
|
||||
@State private var confirmed = false
|
||||
|
||||
/// 작은 워치(40·41mm) 컴팩트 메트릭 — 힌트 줄까지 스크롤 없이 한 화면에 들어가도록
|
||||
/// 아이콘·간격만 살짝 줄인다 (화면 높이 기준 분기 — 기기별 고정값이라 결정적)
|
||||
private var compact: Bool { WKInterfaceDevice.current().screenBounds.height < 220 }
|
||||
@ -38,6 +47,29 @@ struct WatchActionDetailView: View {
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#if DEBUG
|
||||
// -watchDetailAutoTap YES: 진입 2초 뒤 아이콘 버튼과 같은 경로로 발화 —
|
||||
// 접수 오버레이·자동 메인 복귀를 CLI 스크린샷으로 검증하기 위한 훅 (1.5.1)
|
||||
.onAppear {
|
||||
guard UserDefaults.standard.bool(forKey: "watchDetailAutoTap") else { return }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
if let action { fire(action) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 아이콘 버튼 탭 = 실행 + 접수 피드백 + 자동 메인 복귀 (1.5.1 — 접수 중 재탭 무시)
|
||||
private func fire(_ action: WatchActionInfo) {
|
||||
guard !confirmed else { return }
|
||||
store.run(action)
|
||||
withAnimation(.spring(duration: 0.25)) { confirmed = true }
|
||||
Task { @MainActor in
|
||||
// 체크 표시·진동을 인지할 만큼만 머문 뒤 메인으로 복귀
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func detail(_ action: WatchActionInfo) -> some View {
|
||||
@ -75,7 +107,7 @@ struct WatchActionDetailView: View {
|
||||
let runningTime = action.isRunning && !action.isCount
|
||||
let side: CGFloat = compact ? 54 : 64
|
||||
return Button {
|
||||
store.run(action)
|
||||
fire(action)
|
||||
} label: {
|
||||
Image(safeSymbol: action.symbolName)
|
||||
.font(.system(size: compact ? 22 : 26, weight: .semibold))
|
||||
@ -94,6 +126,18 @@ struct WatchActionDetailView: View {
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
actionBadge(action, runningTime: runningTime)
|
||||
}
|
||||
// 접수 오버레이 — 타일을 살짝 어둡게 + 흰 체크 (진동과 함께 시각 피드백)
|
||||
.overlay {
|
||||
if confirmed {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.black.opacity(0.35))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: compact ? 24 : 28, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(confirmed ? 0.9 : 1)
|
||||
// 배지가 프레임 밖으로 살짝 나가므로 히트 영역은 타일 전체로 명시
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
@ -63,14 +63,22 @@ final class WatchStore: NSObject {
|
||||
// 손목에서는 화면을 안 보고 탭하는 경우가 많다 — 접수 즉시 햅틱으로 확인.
|
||||
// .click은 실사용에서 너무 약하다는 보고(1.4)로 가장 강한 축인 .success로 상향
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
// commandID: 탭 1회 = ID 1개 — 응답 유실로 큐 채널 재전송이 겹쳐도
|
||||
// iPhone이 같은 ID의 두 번째 실행을 무시한다 (1.5.1 — 횟수 +2 실측 수정)
|
||||
send([
|
||||
WatchSync.commandKey: WatchSync.runCommand,
|
||||
WatchSync.actionIDKey: action.id.uuidString,
|
||||
WatchSync.sentAtKey: Date.now.timeIntervalSince1970,
|
||||
WatchSync.commandIDKey: UUID().uuidString,
|
||||
])
|
||||
}
|
||||
|
||||
private func send(_ message: [String: Any]) {
|
||||
#if DEBUG
|
||||
// 시드 모드(-watchSeedSnapshot)는 세션을 활성화하지 않으므로 전송도 하지 않는다
|
||||
// (미활성 세션에 transferUserInfo를 부르는 QA 크래시 방지 — 1.5.1 auto-tap QA용)
|
||||
if UserDefaults.standard.bool(forKey: "watchSeedSnapshot") { return }
|
||||
#endif
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else {
|
||||
// 활성화가 끝나기 전(앱 시작 직후)의 실행 명령도 유실되지 않게 큐 채널로 전송
|
||||
|
||||
@ -211,13 +211,30 @@ final class WatchSyncManager: NSObject {
|
||||
|
||||
// MARK: 명령 처리
|
||||
|
||||
/// 최근 처리한 실행 명령 ID (1.5.1) — sendMessage가 전달됐는데 응답만 유실되면
|
||||
/// 워치가 같은 명령을 transferUserInfo 큐로 재전송한다(탭 1회가 두 번 실행 — 횟수 +2).
|
||||
/// 같은 ID의 두 번째 도착은 실행 없이 스냅숏만 돌려준다. 구버전 워치(ID 없음)는 기존 동작
|
||||
private var handledRunCommandIDs: [String] = []
|
||||
|
||||
private func isDuplicateRunCommand(_ commandID: String?) -> Bool {
|
||||
guard let commandID else { return false }
|
||||
if handledRunCommandIDs.contains(commandID) { return true }
|
||||
handledRunCommandIDs.append(commandID)
|
||||
if handledRunCommandIDs.count > 64 {
|
||||
handledRunCommandIDs.removeFirst(handledRunCommandIDs.count - 64)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// 워치의 명령을 처리하고 최신 스냅숏 응답을 만든다
|
||||
fileprivate func handle(command: String?, actionID: String?) -> [String: Any] {
|
||||
fileprivate func handle(command: String?, actionID: String?, commandID: String?) -> [String: Any] {
|
||||
let context = DataStore.shared.mainContext
|
||||
if command == WatchSync.runCommand,
|
||||
let idString = actionID,
|
||||
let id = UUID(uuidString: idString) {
|
||||
if PremiumGate.isUnlocked(.watchApp) {
|
||||
if isDuplicateRunCommand(commandID) {
|
||||
watchSyncLog.notice("run command duplicate ignored: \(idString, privacy: .public)")
|
||||
} else if PremiumGate.isUnlocked(.watchApp) {
|
||||
let actions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
|
||||
if let action = actions.first(where: { $0.uuid == id }) {
|
||||
watchSyncLog.notice("run command: \(action.name, privacy: .public)")
|
||||
@ -264,8 +281,11 @@ extension WatchSyncManager: WCSessionDelegate {
|
||||
) {
|
||||
let command = message[WatchSync.commandKey] as? String
|
||||
let actionID = message[WatchSync.actionIDKey] as? String
|
||||
let commandID = message[WatchSync.commandIDKey] as? String
|
||||
Task { @MainActor in
|
||||
let reply = WatchSyncManager.shared.handle(command: command, actionID: actionID)
|
||||
let reply = WatchSyncManager.shared.handle(
|
||||
command: command, actionID: actionID, commandID: commandID
|
||||
)
|
||||
replyHandler(reply)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
@ -275,6 +295,7 @@ extension WatchSyncManager: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
let command = userInfo[WatchSync.commandKey] as? String
|
||||
let actionID = userInfo[WatchSync.actionIDKey] as? String
|
||||
let commandID = userInfo[WatchSync.commandIDKey] as? String
|
||||
watchSyncLog.notice("didReceiveUserInfo command=\(command ?? "nil", privacy: .public)")
|
||||
guard command == WatchSync.runCommand else { return }
|
||||
// 큐에 오래 머문 명령이 뒤늦게 실행돼 엉뚱한 토글을 일으키지 않도록 유효 시간 확인
|
||||
@ -283,7 +304,9 @@ extension WatchSyncManager: WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
_ = WatchSyncManager.shared.handle(command: command, actionID: actionID)
|
||||
_ = WatchSyncManager.shared.handle(
|
||||
command: command, actionID: actionID, commandID: commandID
|
||||
)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
22
myApp/HaruDanim/Marketing/AppStore/whats-new-1.5.1.txt
Normal file
22
myApp/HaruDanim/Marketing/AppStore/whats-new-1.5.1.txt
Normal file
@ -0,0 +1,22 @@
|
||||
# 버전 1.5.1 '새로운 기능' (로케일별)
|
||||
|
||||
[ko]
|
||||
애플워치 사용성을 다듬은 작은 업데이트예요.
|
||||
|
||||
• 워치 컴플리케이션으로 여는 행동 화면에서 버튼을 누르면, 접수 표시(체크+진동)를 보여 준 뒤 자동으로 첫 화면에 돌아가요 — 화면이 열린 채 실수로 또 눌리는 일을 막아요
|
||||
• 워치에서 누른 기록이 아주 가끔 두 번 반영되던 문제를 고쳤어요
|
||||
• 여러 기기를 쓰신다면 모든 기기에서 함께 업데이트해 주세요
|
||||
|
||||
[en]
|
||||
A small update polishing the Apple Watch experience.
|
||||
|
||||
• On the action screen opened from a watch complication, tapping the button now shows a confirmation (check + haptic) and automatically returns to the main screen — so a lingering screen can't be tapped again by accident
|
||||
• Fixed a rare issue where a tap on the watch could be recorded twice
|
||||
• If you use multiple devices, please update the app on all of them
|
||||
|
||||
[ja]
|
||||
Apple Watchの使い勝手を磨いた小さなアップデートです。
|
||||
|
||||
• コンプリケーションから開くアクション画面でボタンを押すと、受付表示(チェック+振動)のあと自動的にメイン画面へ戻ります — 画面が開いたままの誤タップを防ぎます
|
||||
• Watchでのタップがごくまれに2回記録される問題を修正しました
|
||||
• 複数のデバイスをお使いの場合は、すべてのデバイスでアップデートしてください
|
||||
@ -20,6 +20,10 @@ nonisolated enum WatchSync {
|
||||
static let actionIDKey = "actionID"
|
||||
/// 명령이 워치에서 만들어진 시각 (초, since 1970) — 큐 지연 전달 판별용
|
||||
static let sentAtKey = "sentAt"
|
||||
/// 탭 1회마다 새로 만드는 명령 고유 ID (1.5.1) — sendMessage가 실제로는 전달됐는데
|
||||
/// 응답만 유실되면 워치가 같은 명령을 큐 채널로 재전송한다. iPhone이 이 ID로
|
||||
/// 중복을 걸러내지 않으면 탭 한 번에 두 번 실행된다(횟수 +2 실측 보고의 원인)
|
||||
static let commandIDKey = "commandID"
|
||||
/// 명령 종류
|
||||
static let runCommand = "run"
|
||||
static let refreshCommand = "refresh"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user