Release 1.2.1: iCloud sync improvements + ASO multilingual metadata

iCloud sync:
- Force viewContext.refreshAllObjects() on remote change notifications so
  data from other devices is picked up immediately without app restart
- Call refreshFromCloudKit() on foreground to merge any changes made while
  the device was inactive
- Wait up to 10s for initial CloudKit sync on launch before showing onboarding
  (shows "Checking iCloud..." during the wait)
- New OnboardingICloudCheckView: shown on fresh installs with iCloud available,
  lets user restore from iCloud before starting onboarding from scratch

Localization:
- Added de, fr, it, ja, pt-BR lproj folders
- New iCloud onboarding strings in en + es-ES (+ button literals)

ASO metadata (fastlane):
- Updated en-US: new subtitle, keywords, description (fixed "no cloud sync"
  claim), promotional text, release notes
- Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total)
- All keyword fields validated ≤100 Unicode chars

Infrastructure:
- Gemfile + Gemfile.lock for fastlane
- Scripts/archive_and_upload_appstore.sh for CI/CD

Version: 1.2.1 (build 7)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-03-25 11:54:35 +01:00
parent 7cb5f92cf4
commit 10f6d0ca20
108 changed files with 4069 additions and 238 deletions
@@ -518,6 +518,28 @@ struct EvolutionChartView: View {
var id: String { rawValue }
}
private static let compactXAxisDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
return formatter
}()
private var xAxisMonthStride: Int {
switch data.count {
case ...8:
return 1
case ...16:
return 2
case ...30:
return 3
case ...48:
return 4
default:
return 6
}
}
private var stackedCategoryData: [CategoryEvolutionPoint] {
guard !categoryData.isEmpty else { return [] }
@@ -622,8 +644,17 @@ struct EvolutionChartView: View {
}
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated).year())
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
.foregroundStyle(Color.secondary.opacity(0.28))
AxisValueLabel {
if let date = value.as(Date.self) {
Text(date, formatter: Self.compactXAxisDateFormatter)
.font(.caption2)
}
}
}
}
.chartYAxis {
@@ -413,9 +413,7 @@ struct TotalValueCard: View {
struct MonthlyCheckInCard: View {
let lastUpdated: String
let lastUpdatedDate: Date?
@State private var showingStartOptions = false
@State private var startDestinationActive = false
@State private var shouldDuplicatePrevious = false
@State private var cachedCompletionDate: Date?
private var effectiveLastCheckInDate: Date? {
@@ -466,11 +464,46 @@ struct MonthlyCheckInCard: View {
)
}
/// True during the first half of the month (before mid-month threshold) when the previous
/// period's check-in should be offered for update instead of starting a new one.
private var isBeforeMidMonth: Bool {
guard effectiveLastCheckInDate != nil else { return false }
let cal = Calendar.current
let day = cal.component(.day, from: Date())
let daysInMonth = cal.range(of: .day, in: .month, for: Date())?.count ?? 30
return day < daysInMonth / 2
}
/// Reference date to pass to MonthlyCheckInView depending on the current phase.
private var navigationReferenceDate: Date {
if isBeforeMidMonth {
return Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
}
// Use day 25 so effectiveMonth always resolves to the current calendar month
var comps = Calendar.current.dateComponents([.year, .month], from: Date())
comps.day = 25
return Calendar.current.date(from: comps) ?? Date()
}
private var buttonLabel: String {
if isBeforeMidMonth {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
formatter.locale = .current
let prev = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
return String(format: NSLocalizedString("checkin_update_month", comment: ""), formatter.string(from: prev))
}
return NSLocalizedString("checkin_start_new", comment: "")
}
private var currentMonthLabel: String {
let formatter = DateFormatter()
formatter.dateFormat = "LLLL yyyy"
let effectiveMonth = MonthlyCheckInStore.effectiveMonth(for: Date(), relativeTo: Date())
return formatter.string(from: effectiveMonth)
if isBeforeMidMonth {
let prev = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
return formatter.string(from: prev)
}
return formatter.string(from: Date())
}
var body: some View {
@@ -521,9 +554,9 @@ struct MonthlyCheckInCard: View {
}
Button {
showingStartOptions = true
startDestinationActive = true
} label: {
Text("Start")
Text(buttonLabel)
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
@@ -535,7 +568,7 @@ struct MonthlyCheckInCard: View {
NavigationLink(
isActive: $startDestinationActive
) {
MonthlyCheckInView(duplicatePrevious: shouldDuplicatePrevious)
MonthlyCheckInView(referenceDate: navigationReferenceDate)
} label: {
EmptyView()
}
@@ -544,47 +577,6 @@ struct MonthlyCheckInCard: View {
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.sheet(isPresented: $showingStartOptions) {
VStack(spacing: 16) {
Text("Start Monthly Check-in")
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 24)
Button {
showingStartOptions = false
shouldDuplicatePrevious = false
startDestinationActive = true
} label: {
Text("Start from scratch")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color(.systemGray5))
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button {
showingStartOptions = false
shouldDuplicatePrevious = true
startDestinationActive = true
} label: {
Text("Duplicate previous month")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color(.systemGray5))
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button("Cancel", role: .cancel) {
showingStartOptions = false
}
.padding(.bottom, 16)
}
.padding(.horizontal, 24)
.presentationDetents([.height(280)])
}
.onAppear {
cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
}
@@ -9,6 +9,7 @@ struct EvolutionChartCard: View {
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@State private var showGoalLines = true
@State private var chartWidth: CGFloat = 300
enum ChartMode: String, CaseIterable, Identifiable {
case total = "Total"
@@ -17,6 +18,30 @@ struct EvolutionChartCard: View {
var id: String { rawValue }
}
private static let compactXAxisDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
return formatter
}()
/// Calculates the optimal month stride so labels never overlap,
/// using the actual rendered width of the chart instead of just data count.
private func xAxisMonthStride(for width: CGFloat) -> Int {
// ~50pt for Y-axis, ~44pt per "Jan 24" label
let usableWidth = max(width - 50, 80)
let maxLabels = max(2, Int(usableWidth / 44))
let rawStride = max(1, Int(ceil(Double(data.count) / Double(maxLabels))))
switch rawStride {
case ...1: return 1
case ...2: return 2
case ...3: return 3
case ...4: return 4
case ...6: return 6
default: return 12
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
headerView
@@ -84,8 +109,17 @@ struct EvolutionChartCard: View {
}
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 3)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated).year())
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride(for: chartWidth))) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
.foregroundStyle(Color.secondary.opacity(0.28))
AxisValueLabel {
if let date = value.as(Date.self) {
Text(date, formatter: Self.compactXAxisDateFormatter)
.font(.caption2)
}
}
}
}
.chartYAxis {
@@ -124,6 +158,13 @@ struct EvolutionChartCard: View {
}
}
.frame(height: 200)
.background(
GeometryReader { geo in
Color.clear
.onAppear { chartWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in chartWidth = w }
}
)
// Performance: Use GPU rendering for smoother scrolling
.drawingGroup()
}
@@ -5,7 +5,6 @@ struct MonthlyCheckInView: View {
@EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel = MonthlyCheckInViewModel()
@State private var referenceDate: Date
let duplicatePrevious: Bool
@State private var monthlyNote: String
@State private var starRating: Int
@@ -13,14 +12,12 @@ struct MonthlyCheckInView: View {
@FocusState private var noteFocused: Bool
@State private var editingSnapshot: Snapshot?
@State private var addingSource: InvestmentSource?
@State private var didApplyDuplicate = false
@State private var showBatchUpdate = false
@State private var showAchievementSatisfactionDialog = false
@State private var showAppStoreReviewAlert = false
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
init(referenceDate: Date = Date()) {
_referenceDate = State(initialValue: referenceDate)
self.duplicatePrevious = duplicatePrevious
_monthlyNote = State(initialValue: MonthlyCheckInStore.note(for: referenceDate))
_starRating = State(initialValue: MonthlyCheckInStore.rating(for: referenceDate) ?? 0)
_selectedMood = State(initialValue: MonthlyCheckInStore.mood(for: referenceDate))
@@ -109,9 +106,32 @@ struct MonthlyCheckInView: View {
}
.padding()
}
.navigationTitle("Monthly Check-in")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 12) {
Button {
navigateMonth(offset: -1)
} label: {
Image(systemName: "chevron.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
}
Text(monthLabel)
.font(.headline)
Button {
navigateMonth(offset: 1)
} label: {
Image(systemName: "chevron.right")
.font(.subheadline.weight(.semibold))
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
}
.disabled(!canGoToNextMonth)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
shareMonthlyCheckIn()
@@ -124,10 +144,6 @@ struct MonthlyCheckInView: View {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.selectedRange = DateRange.month(containing: referenceDate)
if duplicatePrevious, !didApplyDuplicate {
viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate)
didApplyDuplicate = true
}
viewModel.refresh()
monthlyNote = MonthlyCheckInStore.note(for: referenceDate)
starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0
@@ -164,92 +180,56 @@ struct MonthlyCheckInView: View {
MonthlyCheckInStore.setMood(newValue, for: referenceDate)
}
.confirmationDialog(
"How much are you enjoying Portfolio Journal?",
"checkin_enjoying_dialog_title",
isPresented: $showAchievementSatisfactionDialog,
titleVisibility: .visible
) {
ForEach(1...5, id: \.self) { value in
Button("\(value) Star\(value > 1 ? "s" : "")") {
Button(value == 1
? String(localized: "rating_1_star")
: String(format: NSLocalizedString("rating_n_stars", comment: ""), value)
) {
if value == 5 {
showAppStoreReviewAlert = true
}
}
}
Button("Not Now", role: .cancel) {}
Button(String(localized: "not_now"), role: .cancel) {}
} message: {
Text("Congrats on your new achievement! Your feedback helps us improve.")
Text("checkin_enjoying_dialog_message")
}
.alert("Would you like to leave an App Store review?", isPresented: $showAppStoreReviewAlert) {
Button("Not Now", role: .cancel) {}
Button("Write a Review") {
.alert("app_store_review_title", isPresented: $showAppStoreReviewAlert) {
Button(String(localized: "not_now"), role: .cancel) {}
Button(String(localized: "write_review")) {
ReviewPromptService.shared.markStoreReviewCompleted()
openURL(ReviewPromptService.appStoreWriteReviewURL())
}
} message: {
Text("Thanks for the 5 stars. It really helps other investors discover the app.")
Text("app_store_review_message")
}
}
private var headerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Button {
navigateMonth(offset: -1)
} label: {
Image(systemName: "chevron.left")
.font(.headline)
.foregroundColor(.appPrimary)
}
if referenceDate.isSameMonth(as: Date()) {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: checkInProgress)
.tint(progressBarTint)
Spacer()
Text(monthLabel)
.font(.title3.weight(.bold))
Spacer()
Button {
navigateMonth(offset: 1)
} label: {
Image(systemName: "chevron.right")
.font(.headline)
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
}
.disabled(!canGoToNextMonth)
}
if let date = lastCompletionDate {
Text(
String(
format: NSLocalizedString("last_check_in", comment: ""),
date.friendlyDescription
)
)
.font(.subheadline)
.foregroundColor(.secondary)
} else {
Text("No check-in yet this month")
.font(.subheadline)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: checkInProgress)
.tint(progressBarTint)
if let nextDate = nextCheckInDate {
Text(
String(
format: NSLocalizedString("next_check_in", comment: ""),
nextDate.mediumDateString
if let nextDate = nextCheckInDate {
Text(
String(
format: NSLocalizedString("next_check_in", comment: ""),
nextDate.mediumDateString
)
)
)
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Start your first check-in anytime.")
.font(.caption)
.foregroundColor(.secondary)
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Start your first check-in anytime.")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
@@ -968,7 +948,9 @@ struct BatchUpdateView: View {
Button {
saveAll()
} label: {
Text("Save \(filledCount) Snapshot\(filledCount == 1 ? "" : "s")")
Text(filledCount == 1
? String(localized: "save_1_snapshot")
: String(format: NSLocalizedString("save_n_snapshots", comment: ""), filledCount))
.font(.headline)
.frame(maxWidth: .infinity)
}
@@ -0,0 +1,141 @@
import SwiftUI
/// Shown before onboarding on a fresh install when iCloud is available.
/// Lets the user choose between restoring from iCloud or starting fresh.
struct OnboardingICloudCheckView: View {
/// Called when the user decides to start fresh (no iCloud restore).
let onSkip: () -> Void
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
@State private var showRestartPrompt = false
var body: some View {
if showRestartPrompt {
restartPromptView
} else {
checkView
}
}
// MARK: - Check View
private var checkView: some View {
VStack {
Spacer()
VStack(spacing: 28) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 140, height: 140)
Circle()
.fill(Color.appPrimary.opacity(0.22))
.frame(width: 100, height: 100)
Image(systemName: "icloud.fill")
.font(.system(size: 50))
.foregroundColor(.appPrimary)
}
VStack(spacing: 14) {
Text(String(localized: "icloud_check_title"))
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text(String(localized: "icloud_check_description"))
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
Spacer()
Spacer()
VStack(spacing: 12) {
Button {
cloudSyncEnabled = true
showRestartPrompt = true
} label: {
Label("Restore from iCloud", systemImage: "icloud.and.arrow.down")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button {
onSkip()
} label: {
Text("Start Fresh")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.background(AppBackground())
}
// MARK: - Restart Prompt View
private var restartPromptView: some View {
VStack {
Spacer()
VStack(spacing: 28) {
ZStack {
Circle()
.fill(Color.positiveGreen.opacity(0.12))
.frame(width: 140, height: 140)
Circle()
.fill(Color.positiveGreen.opacity(0.22))
.frame(width: 100, height: 100)
Image(systemName: "checkmark.icloud.fill")
.font(.system(size: 50))
.foregroundColor(.positiveGreen)
}
VStack(spacing: 14) {
Text(String(localized: "icloud_enabled_title"))
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text(String(localized: "icloud_enabled_description"))
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
Spacer()
Spacer()
// "Got it" just acknowledges the user must close and reopen manually.
// The button stays active so it doesn't look broken.
Button {
// No-op: user needs to close and reopen the app.
// Nothing to navigate to; this session has no CloudKit container.
} label: {
Text("Got it")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.positiveGreen)
.cornerRadius(AppConstants.UI.cornerRadius)
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.background(AppBackground())
}
}
#Preview {
OnboardingICloudCheckView(onSkip: {})
}
@@ -185,46 +185,7 @@ struct AddSourceView: View {
}
private func parseDecimal(_ string: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
let stripped = string
.replacingOccurrences(of: currencySymbol, with: "")
.trimmingCharacters(in: .whitespaces)
guard !stripped.isEmpty else { return nil }
let decimalSep = locale.decimalSeparator ?? "."
let groupingSep = locale.groupingSeparator ?? ""
// Detect alternate decimal BEFORE removing separators
let usesAlternateDecimal =
(decimalSep == "," && stripped.contains(".") && !stripped.contains(",")) ||
(decimalSep == "." && stripped.contains(",") && !stripped.contains("."))
if usesAlternateDecimal {
let normalized = stripped
.replacingOccurrences(of: groupingSep, with: "")
.replacingOccurrences(of: ",", with: ".")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
let cleaned = stripped.replacingOccurrences(of: groupingSep, with: "")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
if let value = formatter.number(from: cleaned)?.decimalValue {
return value
}
// Fallback for mixed locale input
let normalized = cleaned
.replacingOccurrences(of: decimalSep, with: ".")
.replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
}
private var currencySymbol: String {
@@ -559,13 +520,16 @@ struct AddSnapshotView: View {
let repository = SnapshotRepository()
if let snapshot = snapshot {
let contributionTextIsEmpty = viewModel.contributionString
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
repository.updateSnapshot(
snapshot,
date: viewModel.date,
value: value,
contribution: viewModel.contribution,
notes: viewModel.notes.isEmpty ? nil : viewModel.notes,
clearContribution: !viewModel.includeContribution,
clearContribution: !viewModel.includeContribution || contributionTextIsEmpty,
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
} else {