From 042b1774d4fe251b4cb1d2aca741bc97837628e6 Mon Sep 17 00:00:00 2001 From: Nick <25382531+kiku-jw@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:38:11 +0300 Subject: [PATCH 1/2] feat: add privacy-safe growth actions --- Hourleaf/App/ReviewRequestGate.swift | 35 +++ Hourleaf/AppIntents/HourleafShortcuts.swift | 9 + .../PrepareMonthlyReportIntent.swift | 97 ++++++++ Hourleaf/AppShortcuts.xcstrings | 31 +++ Hourleaf/UI/ProgressScreen.swift | 9 +- Hourleaf/UI/SettingsScreen.swift | 39 +++- Hourleaf/en.lproj/Localizable.strings | 10 + Hourleaf/ru.lproj/Localizable.strings | 10 + Hourleaf/uk.lproj/Localizable.strings | 10 + HourleafTests/AppIntentTests.swift | 208 +++++++++++++++++- HourleafUITests/HourleafUITests.swift | 24 ++ scripts/test-verify-release-readiness.sh | 2 +- scripts/verify-release-readiness.sh | 2 +- 13 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 Hourleaf/App/ReviewRequestGate.swift create mode 100644 Hourleaf/AppIntents/PrepareMonthlyReportIntent.swift diff --git a/Hourleaf/App/ReviewRequestGate.swift b/Hourleaf/App/ReviewRequestGate.swift new file mode 100644 index 0000000..90060da --- /dev/null +++ b/Hourleaf/App/ReviewRequestGate.swift @@ -0,0 +1,35 @@ +import Foundation + +@MainActor +enum ReviewRequestGate { + static let lastRequestedVersionKey = "hourleaf.review.lastRequestedVersion" + + @discardableResult + static func requestIfEligible( + bundle: Bundle = .main, + defaults: UserDefaults = .standard, + request: () -> Void + ) -> Bool { + requestIfEligible( + version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + defaults: defaults, + request: request + ) + } + + @discardableResult + static func requestIfEligible( + version: String?, + defaults: UserDefaults = .standard, + request: () -> Void + ) -> Bool { + guard let version, !version.isEmpty else { return false } + guard defaults.string(forKey: lastRequestedVersionKey) != version else { return false } + + // Record before handing control to StoreKit so a repeated completion or + // a suppressed system request cannot ask again for this app version. + defaults.set(version, forKey: lastRequestedVersionKey) + request() + return true + } +} diff --git a/Hourleaf/AppIntents/HourleafShortcuts.swift b/Hourleaf/AppIntents/HourleafShortcuts.swift index 07f6dbf..3a770b3 100644 --- a/Hourleaf/AppIntents/HourleafShortcuts.swift +++ b/Hourleaf/AppIntents/HourleafShortcuts.swift @@ -31,5 +31,14 @@ struct HourleafShortcuts: AppShortcutsProvider { shortTitle: "intent.shortcut.open_quick_entry", systemImageName: "square.and.pencil" ) + AppShortcut( + intent: PrepareMonthlyReportIntent(), + phrases: [ + "Prepare monthly report in \(.applicationName)", + "\(.applicationName), prepare monthly report" + ], + shortTitle: "intent.shortcut.prepare_report", + systemImageName: "doc.text" + ) } } diff --git a/Hourleaf/AppIntents/PrepareMonthlyReportIntent.swift b/Hourleaf/AppIntents/PrepareMonthlyReportIntent.swift new file mode 100644 index 0000000..5f9ba3d --- /dev/null +++ b/Hourleaf/AppIntents/PrepareMonthlyReportIntent.swift @@ -0,0 +1,97 @@ +import AppIntents +import Foundation + +enum PrepareMonthlyReportIntentError: LocalizedError, Equatable, Sendable { + case noDraft + case changed + case unavailable + + var errorDescription: String? { + switch self { + case .noDraft: + String(localized: "intent.report.no_draft") + case .changed: + String(localized: "intent.report.changed") + case .unavailable: + String(localized: "intent.report.unavailable") + } + } +} + +struct PrepareMonthlyReportIntent: AppIntent { + static var title: LocalizedStringResource { + "intent.shortcut.prepare_report" + } + + static var description: IntentDescription { + IntentDescription("intent.report.description") + } + + static var openAppWhenRun: Bool { false } + static var isDiscoverable: Bool { true } + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresLocalDeviceAuthentication + } + + @Parameter(title: "intent.report.month", kind: .date) + var month: Date? + + @AppDependency private var repository: CoreDataLedgerRepository + + init() { + _repository = AppDependency() + } + + init( + month: Date? = nil, + dependencyManager: AppDependencyManager = .shared + ) { + self.month = month + _repository = AppDependency(manager: dependencyManager) + } + + static var parameterSummary: some ParameterSummary { + Summary("intent.report.summary") { + \.$month + } + } + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let text = try await prepare(using: repository, now: .now) + return .result(value: text, dialog: IntentDialog("intent.report.success")) + } + + /// Reads the existing report projection twice around formatting. The + /// equality check proves this read-only action did not cross a mutation + /// boundary while it prepared the text. + func prepare( + using repository: CoreDataLedgerRepository, + now: Date + ) async throws -> String { + let before: LedgerSnapshot + do { + before = try await repository.ledgerSnapshot() + } catch { + throw PrepareMonthlyReportIntentError.unavailable + } + + let requestedMonth = MonthKey(month ?? now, calendar: .hourleaf) + let draft = ReportReadiness.draft(for: requestedMonth, in: before) + + let after: LedgerSnapshot + do { + after = try await repository.ledgerSnapshot() + } catch { + throw PrepareMonthlyReportIntentError.unavailable + } + guard before == after else { + throw PrepareMonthlyReportIntentError.changed + } + + guard let draft else { + throw PrepareMonthlyReportIntentError.noDraft + } + + return draft.text + } +} diff --git a/Hourleaf/AppShortcuts.xcstrings b/Hourleaf/AppShortcuts.xcstrings index 7aa800b..838e1a6 100644 --- a/Hourleaf/AppShortcuts.xcstrings +++ b/Hourleaf/AppShortcuts.xcstrings @@ -93,6 +93,37 @@ } } } + }, + "Prepare monthly report in ${applicationName}" : { + "localizations" : { + "en" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "Prepare monthly report in ${applicationName}", + "${applicationName}, prepare monthly report" + ] + } + }, + "ru" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "Подготовить месячный отчёт в ${applicationName}", + "${applicationName}, подготовь месячный отчёт" + ] + } + }, + "uk" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "Підготувати місячний звіт у ${applicationName}", + "${applicationName}, підготуй місячний звіт" + ] + } + } + } } }, "version" : "1.1" diff --git a/Hourleaf/UI/ProgressScreen.swift b/Hourleaf/UI/ProgressScreen.swift index bacb941..b305cd2 100644 --- a/Hourleaf/UI/ProgressScreen.swift +++ b/Hourleaf/UI/ProgressScreen.swift @@ -1,4 +1,5 @@ import SwiftUI +import StoreKit private struct ReportSharePayload: Identifiable { let id = UUID() @@ -19,6 +20,7 @@ enum ReportPreviewText { struct ProgressScreen: View { @EnvironmentObject private var model: AppModel @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.requestReview) private var requestReview @State private var sharePayload: ReportSharePayload? private var selectedMonth: MonthKey { model.selectedReportMonth } @@ -424,7 +426,12 @@ struct ProgressScreen: View { sharePreparedButton(currentSnapshot) Button { let snapshot = currentSnapshot - Task { _ = await model.markReportSent(snapshot) } + Task { @MainActor in + guard await model.markReportSent(snapshot) else { return } + _ = ReviewRequestGate.requestIfEligible { + requestReview() + } + } } label: { Group { if model.markingSentSnapshotIDs.contains(currentSnapshot.id) { diff --git a/Hourleaf/UI/SettingsScreen.swift b/Hourleaf/UI/SettingsScreen.swift index 0122421..f9098ca 100644 --- a/Hourleaf/UI/SettingsScreen.swift +++ b/Hourleaf/UI/SettingsScreen.swift @@ -1,6 +1,33 @@ import SwiftUI import UIKit +enum HourleafGuideURL { + static func make(anchor: String, preferredLanguage: String) -> URL { + let languagePath: String + switch preferredLanguage.lowercased() { + case let language where language.hasPrefix("ru"): + languagePath = "ru/" + case let language where language.hasPrefix("uk"): + languagePath = "uk/" + default: + languagePath = "" + } + return URL(string: "https://kikuai.dev/hourleaf/guide/\(languagePath)#\(anchor)")! + } + + static func make(anchor: String, bundle: Bundle = .main) -> URL { + make( + anchor: anchor, + preferredLanguage: bundle.preferredLocalizations.first ?? "en" + ) + } +} + +private enum HourleafStoreLinks { + static let app = URL(string: "https://apps.apple.com/app/id6801032003")! + static let review = URL(string: "https://apps.apple.com/app/id6801032003?action=write-review")! +} + struct SettingsScreen: View { let dataManagementActions: DataManagementActions @@ -290,6 +317,14 @@ struct SettingsScreen: View { Label("settings.developer_github", systemImage: "chevron.left.forwardslash.chevron.right") } .accessibilityIdentifier("developerGitHubLink") + ShareLink(item: HourleafStoreLinks.app) { + Label("settings.share_hourleaf", systemImage: "square.and.arrow.up") + } + .accessibilityIdentifier("shareHourleafButton") + Link(destination: HourleafStoreLinks.review) { + Label("settings.rate_hourleaf", systemImage: "star") + } + .accessibilityIdentifier("rateHourleafButton") } header: { Text("settings.about") } } .navigationTitle("settings.title") @@ -362,9 +397,7 @@ struct SettingsScreen: View { } private func hourleafGuideURL(anchor: String) -> URL { - let preferredLanguage = Bundle.main.preferredLocalizations.first ?? "en" - let languagePath = preferredLanguage.hasPrefix("ru") ? "ru/" : "" - return URL(string: "https://kikuai.dev/hourleaf/guide/\(languagePath)#\(anchor)")! + HourleafGuideURL.make(anchor: anchor) } private func reminderRow(_ reminder: ReminderSchedule) -> some View { diff --git a/Hourleaf/en.lproj/Localizable.strings b/Hourleaf/en.lproj/Localizable.strings index 5d98162..630f4c6 100644 --- a/Hourleaf/en.lproj/Localizable.strings +++ b/Hourleaf/en.lproj/Localizable.strings @@ -209,6 +209,8 @@ "settings.developer_website" = "kikuai.dev"; "settings.developer_telegram" = "t.me/kiku_ai"; "settings.developer_github" = "github.com/kiku-jw"; +"settings.share_hourleaf" = "Share Hourleaf"; +"settings.rate_hourleaf" = "Rate Hourleaf"; "policy.carry" = "Carry remainder"; "policy.round" = "Round to nearest hour"; "policy.discard" = "Discard remainder"; @@ -252,6 +254,14 @@ "intent.shortcut.add_service" = "Record service"; "intent.shortcut.add_credit" = "Record credit"; "intent.shortcut.open_quick_entry" = "Open Add Time"; +"intent.shortcut.prepare_report" = "Prepare monthly report"; +"intent.report.description" = "Returns the selected month’s report without changing your records."; +"intent.report.month" = "Month"; +"intent.report.summary" = "Prepare monthly report"; +"intent.report.success" = "Your monthly report is ready."; +"intent.report.no_draft" = "There is no monthly report draft for that month."; +"intent.report.changed" = "Your records changed while the report was being prepared. Try again."; +"intent.report.unavailable" = "The monthly report could not be read. Please try again."; "data_management.title" = "Backup and export"; "data_management.local_migration.title" = "Moving to the TestFlight or App Store version"; "data_management.local_migration.create" = "Create a backup in this app."; diff --git a/Hourleaf/ru.lproj/Localizable.strings b/Hourleaf/ru.lproj/Localizable.strings index 898d6be..dce791e 100644 --- a/Hourleaf/ru.lproj/Localizable.strings +++ b/Hourleaf/ru.lproj/Localizable.strings @@ -209,6 +209,8 @@ "settings.developer_website" = "kikuai.dev"; "settings.developer_telegram" = "t.me/kiku_ai"; "settings.developer_github" = "github.com/kiku-jw"; +"settings.share_hourleaf" = "Поделиться Hourleaf"; +"settings.rate_hourleaf" = "Оценить Hourleaf"; "policy.carry" = "Переносить остаток"; "policy.round" = "Округлять до часа"; "policy.discard" = "Списывать остаток"; @@ -252,6 +254,14 @@ "intent.shortcut.add_service" = "Запиши служение"; "intent.shortcut.add_credit" = "Запиши кредит"; "intent.shortcut.open_quick_entry" = "Открыть добавление времени"; +"intent.shortcut.prepare_report" = "Подготовить месячный отчёт"; +"intent.report.description" = "Возвращает отчёт за выбранный месяц, не изменяя записи."; +"intent.report.month" = "Месяц"; +"intent.report.summary" = "Подготовить месячный отчёт"; +"intent.report.success" = "Месячный отчёт готов."; +"intent.report.no_draft" = "За этот месяц нет черновика отчёта."; +"intent.report.changed" = "Записи изменились во время подготовки отчёта. Попробуйте ещё раз."; +"intent.report.unavailable" = "Не удалось прочитать месячный отчёт. Попробуйте ещё раз."; "data_management.title" = "Резервные копии и экспорт"; "data_management.local_migration.title" = "Переход на версию из TestFlight или App Store"; "data_management.local_migration.create" = "Создайте резервную копию в этом приложении."; diff --git a/Hourleaf/uk.lproj/Localizable.strings b/Hourleaf/uk.lproj/Localizable.strings index 1ad41b4..7cde3c7 100644 --- a/Hourleaf/uk.lproj/Localizable.strings +++ b/Hourleaf/uk.lproj/Localizable.strings @@ -209,6 +209,8 @@ "settings.developer_website" = "kikuai.dev"; "settings.developer_telegram" = "t.me/kiku_ai"; "settings.developer_github" = "github.com/kiku-jw"; +"settings.share_hourleaf" = "Поділитися Hourleaf"; +"settings.rate_hourleaf" = "Оцінити Hourleaf"; "policy.carry" = "Переносити залишок"; "policy.round" = "Округляти до години"; "policy.discard" = "Списувати залишок"; @@ -252,6 +254,14 @@ "intent.shortcut.add_service" = "Запиши служіння"; "intent.shortcut.add_credit" = "Запиши кредит"; "intent.shortcut.open_quick_entry" = "Відкрити додавання часу"; +"intent.shortcut.prepare_report" = "Підготувати місячний звіт"; +"intent.report.description" = "Повертає звіт за вибраний місяць, не змінюючи записи."; +"intent.report.month" = "Місяць"; +"intent.report.summary" = "Підготувати місячний звіт"; +"intent.report.success" = "Місячний звіт готовий."; +"intent.report.no_draft" = "За цей місяць немає чернетки звіту."; +"intent.report.changed" = "Записи змінилися під час підготовки звіту. Спробуйте ще раз."; +"intent.report.unavailable" = "Не вдалося прочитати місячний звіт. Спробуйте ще раз."; "data_management.title" = "Резервні копії та експорт"; "data_management.local_migration.title" = "Перехід на версію з TestFlight або App Store"; "data_management.local_migration.create" = "Створіть резервну копію в цьому застосунку."; diff --git a/HourleafTests/AppIntentTests.swift b/HourleafTests/AppIntentTests.swift index 6551279..50cb2d6 100644 --- a/HourleafTests/AppIntentTests.swift +++ b/HourleafTests/AppIntentTests.swift @@ -220,7 +220,7 @@ final class AppIntentTests: XCTestCase { }) ) - XCTAssertEqual(HourleafShortcuts.appShortcuts.count, 3) + XCTAssertEqual(HourleafShortcuts.appShortcuts.count, 4) XCTAssertEqual(shortcutRevision.source, EntryMutationSource.shortcut.rawValue) XCTAssertEqual(shortcutRevision.localDay, LocalDay(year: 2029, month: 12, day: 20).key) XCTAssertEqual(shortcutRevision.minutes, 30) @@ -527,8 +527,8 @@ final class AppIntentTests: XCTestCase { XCTAssertFalse(resolvedIdentity.isEmpty) } - func testExactlyThreeShortcutsArePromoted() { - XCTAssertEqual(HourleafShortcuts.appShortcuts.count, 3) + func testExactlyFourShortcutsArePromoted() { + XCTAssertEqual(HourleafShortcuts.appShortcuts.count, 4) } func testPromotedServiceShortcutUsesItsFixedKindAction() throws { @@ -541,9 +541,207 @@ final class AppIntentTests: XCTestCase { ) XCTAssertTrue(source.contains("intent: RecordServiceTimeIntent()")) + XCTAssertTrue(source.contains("intent: PrepareMonthlyReportIntent()")) XCTAssertFalse(source.contains("intent: RecordTimeIntent(kind: .service)")) } + func testPrepareMonthlyReportIntentReturnsLocalizedReadOnlyDraft() async throws { + let repositoryNow = makeDate(year: 2030, month: 1, day: 10, hour: 12, minute: 0) + let month = MonthKey(year: 2029, month: 12) + let repository = try await makeRepository( + ledgerStartMonth: MonthKey(year: 2029, month: 9), + reportLanguage: .english, + clock: { repositoryNow } + ) + let note = "Private note that must stay out of the report" + let serviceID = UUID(uuidString: "20000000-0000-0000-0000-000000000001")! + let creditID = UUID(uuidString: "20000000-0000-0000-0000-000000000002")! + + _ = try await repository.apply( + EntryMutationCommand( + mutationID: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + entryID: serviceID, + expectedRevision: nil, + operation: .create, + values: EntryMutationValues( + kind: .service, + day: LocalDay(year: 2029, month: 12, day: 3), + minutes: 125, + note: note + ), + occurredAt: makeDate(year: 2030, month: 1, day: 2, hour: 9, minute: 0), + source: .appQuickEntry + ) + ) + _ = try await repository.apply( + EntryMutationCommand( + mutationID: UUID(uuidString: "10000000-0000-0000-0000-000000000002")!, + entryID: creditID, + expectedRevision: nil, + operation: .create, + values: EntryMutationValues( + kind: .credit, + day: LocalDay(year: 2029, month: 12, day: 4), + minutes: 75, + note: nil + ), + occurredAt: makeDate(year: 2030, month: 1, day: 2, hour: 9, minute: 1), + source: .appQuickEntry + ) + ) + _ = try await repository.setBibleStudyCount( + 2, + for: month, + at: makeDate(year: 2030, month: 1, day: 3, hour: 9, minute: 0) + ) + + let before = try await repository.ledgerSnapshot() + let intent = PrepareMonthlyReportIntent(month: month.date(calendar: .hourleaf)) + let text = try await intent.prepare(using: repository, now: repositoryNow) + let after = try await repository.ledgerSnapshot() + let expected = try XCTUnwrap(ReportReadiness.draft(for: month, in: before)) + + XCTAssertEqual(text, expected.text) + XCTAssertTrue(text.contains("Hours: 2"), "Unexpected report text: \(text)") + XCTAssertTrue(text.contains("Credit hours: 1"), "Unexpected report text: \(text)") + XCTAssertTrue(text.contains("Bible studies: 2"), "Unexpected report text: \(text)") + XCTAssertFalse(text.contains(note)) + XCTAssertFalse(text.contains(serviceID.uuidString)) + XCTAssertFalse(text.contains(creditID.uuidString)) + XCTAssertEqual(before, after) + } + + func testPrepareMonthlyReportIntentUsesCurrentMonthWhenDateIsOmitted() async throws { + let repositoryNow = makeDate(year: 2030, month: 1, day: 10, hour: 12, minute: 0) + let currentMonth = MonthKey(repositoryNow, calendar: .hourleaf) + let repository = try await makeRepository( + ledgerStartMonth: MonthKey(year: 2029, month: 9), + clock: { repositoryNow } + ) + let before = try await repository.ledgerSnapshot() + let intent = PrepareMonthlyReportIntent() + + let text = try await intent.prepare(using: repository, now: repositoryNow) + + XCTAssertEqual( + text, + try XCTUnwrap(ReportReadiness.draft(for: currentMonth, in: before)).text + ) + } + + func testPrepareMonthlyReportIntentReportsMissingDraftWithoutWriting() async throws { + let repositoryNow = makeDate(year: 2030, month: 1, day: 10, hour: 12, minute: 0) + let repository = try await makeRepository( + ledgerStartMonth: MonthKey(year: 2030, month: 1), + clock: { repositoryNow } + ) + let before = try await repository.ledgerSnapshot() + let intent = PrepareMonthlyReportIntent( + month: MonthKey(year: 2029, month: 12).date(calendar: .hourleaf) + ) + + do { + _ = try await intent.prepare(using: repository, now: repositoryNow) + XCTFail("A month before the ledger start must not return a report.") + } catch let error as PrepareMonthlyReportIntentError { + XCTAssertEqual(error, .noDraft) + XCTAssertFalse(error.errorDescription?.isEmpty ?? true) + } + + let after = try await repository.ledgerSnapshot() + XCTAssertEqual(before, after) + } + + func testPrepareMonthlyReportIntentRequiresLocalAuthenticationAndIsDiscoverable() { + XCTAssertFalse(PrepareMonthlyReportIntent.openAppWhenRun) + XCTAssertTrue(PrepareMonthlyReportIntent.isDiscoverable) + XCTAssertEqual( + PrepareMonthlyReportIntent.authenticationPolicy, + .requiresLocalDeviceAuthentication + ) + } + + func testReviewRequestGateAllowsOneRequestPerAppVersionAndRecordsBeforeRequest() { + let suiteName = "ReviewRequestGateTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + var requestCount = 0 + XCTAssertTrue( + ReviewRequestGate.requestIfEligible(version: "1.0", defaults: defaults) { + XCTAssertEqual( + defaults.string(forKey: ReviewRequestGate.lastRequestedVersionKey), + "1.0" + ) + requestCount += 1 + } + ) + XCTAssertFalse( + ReviewRequestGate.requestIfEligible(version: "1.0", defaults: defaults) { + requestCount += 1 + } + ) + XCTAssertTrue( + ReviewRequestGate.requestIfEligible(version: "1.1", defaults: defaults) { + requestCount += 1 + } + ) + + XCTAssertEqual(requestCount, 2) + XCTAssertEqual( + defaults.string(forKey: ReviewRequestGate.lastRequestedVersionKey), + "1.1" + ) + } + + func testSettingsGuideURLRoutesEnglishRussianAndUkrainian() { + XCTAssertEqual( + HourleafGuideURL.make(anchor: "voice", preferredLanguage: "en").absoluteString, + "https://kikuai.dev/hourleaf/guide/#voice" + ) + XCTAssertEqual( + HourleafGuideURL.make(anchor: "voice", preferredLanguage: "ru").absoluteString, + "https://kikuai.dev/hourleaf/guide/ru/#voice" + ) + XCTAssertEqual( + HourleafGuideURL.make(anchor: "voice", preferredLanguage: "uk").absoluteString, + "https://kikuai.dev/hourleaf/guide/uk/#voice" + ) + } + + func testSettingsStoreActionsUseLocalizedLabelsAndRequiredURLs() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let expected = [ + "en": ("Share Hourleaf", "Rate Hourleaf"), + "ru": ("Поделиться Hourleaf", "Оценить Hourleaf"), + "uk": ("Поділитися Hourleaf", "Оцінити Hourleaf") + ] + + for (language, labels) in expected { + let url = root.appendingPathComponent( + "Hourleaf/\(language).lproj/Localizable.strings" + ) + let data = try Data(contentsOf: url) + let values = try XCTUnwrap( + PropertyListSerialization.propertyList(from: data, format: nil) + as? [String: String] + ) + XCTAssertEqual(values["settings.share_hourleaf"], labels.0) + XCTAssertEqual(values["settings.rate_hourleaf"], labels.1) + } + + let source = try String( + contentsOf: root.appendingPathComponent("Hourleaf/UI/SettingsScreen.swift"), + encoding: .utf8 + ) + XCTAssertTrue(source.contains("https://apps.apple.com/app/id6801032003")) + XCTAssertTrue(source.contains("action=write-review")) + XCTAssertTrue(source.contains("shareHourleafButton")) + XCTAssertTrue(source.contains("rateHourleafButton")) + } + func testSpokenDurationPromptExplicitlyRequestsMinutesInEveryLanguage() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -603,6 +801,7 @@ final class AppIntentTests: XCTestCase { private func makeRepository( ledgerStartMonth: MonthKey, + reportLanguage: ReportLanguage? = nil, clock: @escaping @Sendable () -> Date = { .now } ) async throws -> CoreDataLedgerRepository { let repository = CoreDataLedgerRepository( @@ -615,6 +814,9 @@ final class AppIntentTests: XCTestCase { year: ledgerStartMonth.month >= 9 ? ledgerStartMonth.year : ledgerStartMonth.year - 1, month: 9 ) + if let reportLanguage { + settings.reportLanguage = reportLanguage + } try await repository.saveSettings(settings) return repository } diff --git a/HourleafUITests/HourleafUITests.swift b/HourleafUITests/HourleafUITests.swift index 39b474a..237a53e 100644 --- a/HourleafUITests/HourleafUITests.swift +++ b/HourleafUITests/HourleafUITests.swift @@ -1141,6 +1141,30 @@ final class HourleafUITests: XCTestCase { } app.tabBars.buttons.element(boundBy: 3).tap() + let storeActions: [(String, String)] = switch language { + case "ru": + [ + ("shareHourleafButton", "Поделиться Hourleaf"), + ("rateHourleafButton", "Оценить Hourleaf") + ] + case "uk": + [ + ("shareHourleafButton", "Поділитися Hourleaf"), + ("rateHourleafButton", "Оцінити Hourleaf") + ] + default: + [ + ("shareHourleafButton", "Share Hourleaf"), + ("rateHourleafButton", "Rate Hourleaf") + ] + } + for (identifier, expectedLabel) in storeActions { + let action = app.descendants(matching: .any)[identifier] + XCTAssertTrue(scrollUntilHittable(action, in: app), "Missing \(identifier) in \(language)") + XCTAssertEqual(action.label, expectedLabel, "Unexpected \(identifier) label in \(language)") + assertNonEmptyAccessibilityLabel(identifier, in: app) + } + let dataManagement = app.buttons["dataManagementButton"] XCTAssertTrue(scrollUntilHittable(dataManagement, in: app)) assertNonEmptyAccessibilityLabel("dataManagementButton", in: app) diff --git a/scripts/test-verify-release-readiness.sh b/scripts/test-verify-release-readiness.sh index 243f98a..f6a7fcb 100755 --- a/scripts/test-verify-release-readiness.sh +++ b/scripts/test-verify-release-readiness.sh @@ -288,7 +288,7 @@ assert_failure_contains "Core Data model is not local-only" reset_fixture print 'AppShortcut(intent: OpenQuickEntryIntent())' \ >> "$fixture_root/Hourleaf/AppIntents/HourleafShortcuts.swift" -assert_failure_contains "expected exactly three AppShortcut declarations" +assert_failure_contains "expected exactly four AppShortcut declarations" reset_fixture print 'AppShortcut(intent: WatchRecordServiceTimeIntent())' \ diff --git a/scripts/verify-release-readiness.sh b/scripts/verify-release-readiness.sh index 9bf94c6..75d0672 100755 --- a/scripts/verify-release-readiness.sh +++ b/scripts/verify-release-readiness.sh @@ -379,7 +379,7 @@ for watch_source_root in "$repo_root/HourleafWatch" "$repo_root/HourleafWatchSha done shortcut_count="$(find "$repo_root/Hourleaf" -type f -name '*.swift' -exec grep -Eho 'AppShortcut[[:space:]]*\(' {} + 2>/dev/null | wc -l | tr -d '[:space:]' || true)" -[[ "$shortcut_count" == "3" ]] || fail "expected exactly three AppShortcut declarations; found $shortcut_count" +[[ "$shortcut_count" == "4" ]] || fail "expected exactly four AppShortcut declarations; found $shortcut_count" watch_shortcut_count="$(find "$repo_root/HourleafWatch" -type f -name '*.swift' -exec grep -Eho 'AppShortcut[[:space:]]*\(' {} + 2>/dev/null | wc -l | tr -d '[:space:]' || true)" [[ "$watch_shortcut_count" == "2" ]] || fail "expected exactly two Watch AppShortcut declarations; found $watch_shortcut_count" From b0e855b7263dff681011f9080894f403eb64f92d Mon Sep 17 00:00:00 2001 From: Nick <25382531+kiku-jw@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:50:29 +0300 Subject: [PATCH 2/2] fix: defer the review prompt --- Hourleaf/UI/ProgressScreen.swift | 22 +++++++++++++++++++--- HourleafTests/AppIntentTests.swift | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Hourleaf/UI/ProgressScreen.swift b/Hourleaf/UI/ProgressScreen.swift index b305cd2..74ac31d 100644 --- a/Hourleaf/UI/ProgressScreen.swift +++ b/Hourleaf/UI/ProgressScreen.swift @@ -22,6 +22,7 @@ struct ProgressScreen: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.requestReview) private var requestReview @State private var sharePayload: ReportSharePayload? + @State private var reviewRequestToken: UUID? private var selectedMonth: MonthKey { model.selectedReportMonth } private var earliestMonth: MonthKey { model.settings.ledgerStartMonth } @@ -98,6 +99,23 @@ struct ProgressScreen: View { .onChange(of: model.settings.ledgerStartMonth) { _, _ in normalizeSelectedMonth() } .onChange(of: model.currentMonth) { _, _ in normalizeSelectedMonth() } } + .task(id: reviewRequestToken) { + guard reviewRequestToken != nil else { return } + defer { reviewRequestToken = nil } + + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return + } + + _ = ReviewRequestGate.requestIfEligible { + requestReview() + } + } + .onDisappear { + reviewRequestToken = nil + } } private var monthSelector: some View { @@ -428,9 +446,7 @@ struct ProgressScreen: View { let snapshot = currentSnapshot Task { @MainActor in guard await model.markReportSent(snapshot) else { return } - _ = ReviewRequestGate.requestIfEligible { - requestReview() - } + reviewRequestToken = UUID() } } label: { Group { diff --git a/HourleafTests/AppIntentTests.swift b/HourleafTests/AppIntentTests.swift index 50cb2d6..9029e33 100644 --- a/HourleafTests/AppIntentTests.swift +++ b/HourleafTests/AppIntentTests.swift @@ -694,6 +694,20 @@ final class AppIntentTests: XCTestCase { ) } + func testReviewRequestWaitsAndCancelsWhenProgressScreenDisappears() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: root.appendingPathComponent("Hourleaf/UI/ProgressScreen.swift"), + encoding: .utf8 + ) + + XCTAssertTrue(source.contains("Task.sleep(for: .seconds(2))")) + XCTAssertTrue(source.contains(".onDisappear")) + XCTAssertTrue(source.contains("reviewRequestToken = nil")) + } + func testSettingsGuideURLRoutesEnglishRussianAndUkrainian() { XCTAssertEqual( HourleafGuideURL.make(anchor: "voice", preferredLanguage: "en").absoluteString,