Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Hourleaf/App/ReviewRequestGate.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
9 changes: 9 additions & 0 deletions Hourleaf/AppIntents/HourleafShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}
}
97 changes: 97 additions & 0 deletions Hourleaf/AppIntents/PrepareMonthlyReportIntent.swift
Original file line number Diff line number Diff line change
@@ -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<String> & 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)
Comment on lines +78 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject future months before formatting reports

When the Shortcut supplies a date after the current month, ReportReadiness.draft still calculates a projection and the intent returns it with the success dialog, potentially presenting carry-forward or zero totals as a completed report for a period that has not occurred. The normal Progress flow prevents navigation beyond the current month (ProgressScreen.swift:127) and the persisted report lifecycle rejects current or future months (LedgerRepository.swift:882-887), so this path should validate requestedMonth against now before returning report text.

Useful? React with 👍 / 👎.


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
}
}
31 changes: 31 additions & 0 deletions Hourleaf/AppShortcuts.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 24 additions & 1 deletion Hourleaf/UI/ProgressScreen.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import StoreKit

private struct ReportSharePayload: Identifiable {
let id = UUID()
Expand All @@ -19,7 +20,9 @@ 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?
@State private var reviewRequestToken: UUID?

private var selectedMonth: MonthKey { model.selectedReportMonth }
private var earliestMonth: MonthKey { model.settings.ledgerStartMonth }
Expand Down Expand Up @@ -96,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 {
Expand Down Expand Up @@ -424,7 +444,10 @@ struct ProgressScreen: View {
sharePreparedButton(currentSnapshot)
Button {
let snapshot = currentSnapshot
Task { _ = await model.markReportSent(snapshot) }
Task { @MainActor in
guard await model.markReportSent(snapshot) else { return }
reviewRequestToken = UUID()
}
} label: {
Group {
if model.markingSentSnapshotIDs.contains(currentSnapshot.id) {
Expand Down
39 changes: 36 additions & 3 deletions Hourleaf/UI/SettingsScreen.swift
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions Hourleaf/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.";
Expand Down
10 changes: 10 additions & 0 deletions Hourleaf/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "Списывать остаток";
Expand Down Expand Up @@ -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" = "Создайте резервную копию в этом приложении.";
Expand Down
10 changes: 10 additions & 0 deletions Hourleaf/uk.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "Списувати залишок";
Expand Down Expand Up @@ -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" = "Створіть резервну копію в цьому застосунку.";
Expand Down
Loading
Loading