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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user