b48a47ce10
- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes) - 1B: Badge de racha mensual en Dashboard (streak counter) - 1C: Empty states mejorados en Goals y Journal con CTAs claros - 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla - 2B: Notificación batch update redirige al Quick Update sheet - 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update - 3A: App Store version check banner en Settings - 3C: What's New sheet en primer launch de versión nueva - Localización completa en 7 idiomas para todas las nuevas strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
326 lines
12 KiB
Swift
326 lines
12 KiB
Swift
import SwiftUI
|
|
import CoreData
|
|
|
|
// MARK: - App Tab
|
|
|
|
enum AppTab: Int, Hashable, CaseIterable {
|
|
case dashboard = 0, sources = 1, charts = 2, journal = 3, settings = 4
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .dashboard: String(localized: "tab_dashboard")
|
|
case .sources: String(localized: "tab_sources")
|
|
case .charts: String(localized: "tab_charts")
|
|
case .journal: "Journal"
|
|
case .settings: String(localized: "tab_settings")
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .dashboard: "house.fill"
|
|
case .sources: "list.bullet"
|
|
case .charts: "chart.xyaxis.line"
|
|
case .journal: "book.closed"
|
|
case .settings: "gearshape.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Content View
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject var iapService: IAPService
|
|
@EnvironmentObject var adMobService: AdMobService
|
|
@EnvironmentObject var tabSelection: TabSelectionStore
|
|
@EnvironmentObject var coreDataStack: CoreDataStack
|
|
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
|
|
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
|
@AppStorage("pinEnabled") private var pinEnabled = false
|
|
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
|
|
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
|
@AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = ""
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@State private var isUnlocked = false
|
|
@State private var resolvedOnboardingCompleted: Bool?
|
|
@State private var iCloudCheckDone = false
|
|
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
|
|
@State private var sidebarSelection: AppTab? = .dashboard
|
|
@State private var showingWhatsNew = false
|
|
|
|
private var currentVersion: String {
|
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
|
}
|
|
|
|
private var lockEnabled: Bool {
|
|
faceIdEnabled || pinEnabled
|
|
}
|
|
|
|
/// True when a fresh install with iCloud available and sync not yet enabled.
|
|
/// Only relevant before onboarding is completed.
|
|
private var needsICloudCheck: Bool {
|
|
guard resolvedOnboardingCompleted == false else { return false }
|
|
guard !UserDefaults.standard.bool(forKey: "cloudSyncEnabled") else { return false }
|
|
return FileManager.default.ubiquityIdentityToken != nil
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Group {
|
|
if !isReadyForContent {
|
|
AppLaunchLoadingView(messageKey: loadingMessageKey)
|
|
} else if needsICloudCheck && !iCloudCheckDone {
|
|
// Fresh install with iCloud available: ask before showing onboarding
|
|
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
|
|
} else if resolvedOnboardingCompleted == false {
|
|
OnboardingView(onboardingCompleted: $onboardingCompleted)
|
|
} else if horizontalSizeClass == .regular {
|
|
iPadMainContent
|
|
} else {
|
|
mainContent
|
|
}
|
|
}
|
|
|
|
if onboardingCompleted && lockEnabled && !isUnlocked {
|
|
AppLockView(isUnlocked: $isUnlocked)
|
|
}
|
|
}
|
|
.onAppear {
|
|
if !lockEnabled {
|
|
isUnlocked = true
|
|
} else {
|
|
isUnlocked = !lockOnLaunch
|
|
}
|
|
}
|
|
.onChange(of: lockEnabled) { _, enabled in
|
|
if !enabled {
|
|
isUnlocked = true
|
|
} else {
|
|
isUnlocked = !lockOnLaunch
|
|
}
|
|
}
|
|
.onChange(of: onboardingCompleted) { _, completed in
|
|
if completed && lockEnabled {
|
|
isUnlocked = !lockOnLaunch
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { _, phase in
|
|
guard onboardingCompleted, lockEnabled else { return }
|
|
if phase == .background && lockOnBackground {
|
|
isUnlocked = false
|
|
}
|
|
}
|
|
.task {
|
|
await waitForDataAndResolveOnboarding()
|
|
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion {
|
|
// Small delay to let the UI settle before showing sheet
|
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
showingWhatsNew = true
|
|
}
|
|
}
|
|
.onChange(of: onboardingCompleted) { _, completed in
|
|
resolvedOnboardingCompleted = completed
|
|
}
|
|
.sheet(isPresented: $showingWhatsNew, onDismiss: {
|
|
lastSeenWhatsNewVersion = currentVersion
|
|
}) {
|
|
WhatsNewView()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in
|
|
tabSelection.selectedTab = 1
|
|
sidebarSelection = .sources
|
|
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in
|
|
tabSelection.selectedTab = 0
|
|
sidebarSelection = .dashboard
|
|
}
|
|
.onOpenURL { url in
|
|
if url.host == "quickupdate" {
|
|
tabSelection.selectedTab = 0
|
|
sidebarSelection = .dashboard
|
|
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var isReadyForContent: Bool {
|
|
coreDataStack.isLoaded && resolvedOnboardingCompleted != nil
|
|
}
|
|
|
|
private func waitForDataAndResolveOnboarding() async {
|
|
// Wait for Core Data to be loaded
|
|
while !coreDataStack.isLoaded {
|
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
}
|
|
|
|
// If CloudKit is enabled and no local data yet, wait briefly for the
|
|
// initial iCloud sync so data from other devices appears before we
|
|
// decide whether to show onboarding.
|
|
if UserDefaults.standard.bool(forKey: "cloudSyncEnabled") && !hasExistingData() {
|
|
await MainActor.run { loadingMessageKey = "checking_icloud" }
|
|
await waitForInitialCloudKitSync(timeout: 10)
|
|
await MainActor.run { loadingMessageKey = "loading_data" }
|
|
}
|
|
|
|
// Resolve onboarding state on main thread
|
|
await MainActor.run {
|
|
syncOnboardingState()
|
|
}
|
|
}
|
|
|
|
/// Polls for existing data up to `timeout` seconds, returning as soon as
|
|
/// any data appears. Used to wait for the initial CloudKit sync on launch.
|
|
private func waitForInitialCloudKitSync(timeout: TimeInterval) async {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if hasExistingData() { return }
|
|
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
|
|
}
|
|
}
|
|
|
|
private func syncOnboardingState() {
|
|
let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext)
|
|
var resolved = settings.onboardingCompleted || onboardingCompleted
|
|
|
|
// If user has existing data, skip onboarding
|
|
if !resolved && hasExistingData() {
|
|
resolved = true
|
|
}
|
|
|
|
// Persist the resolved state
|
|
if settings.onboardingCompleted != resolved {
|
|
settings.onboardingCompleted = resolved
|
|
CoreDataStack.shared.save()
|
|
}
|
|
if onboardingCompleted != resolved {
|
|
onboardingCompleted = resolved
|
|
}
|
|
|
|
resolvedOnboardingCompleted = resolved
|
|
}
|
|
|
|
private func hasExistingData() -> Bool {
|
|
let context = coreDataStack.viewContext
|
|
|
|
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
|
sourceRequest.fetchLimit = 1
|
|
sourceRequest.resultType = .countResultType
|
|
if (try? context.count(for: sourceRequest)) ?? 0 > 0 {
|
|
return true
|
|
}
|
|
|
|
let snapshotRequest: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
|
snapshotRequest.fetchLimit = 1
|
|
snapshotRequest.resultType = .countResultType
|
|
if (try? context.count(for: snapshotRequest)) ?? 0 > 0 {
|
|
return true
|
|
}
|
|
|
|
let accountRequest: NSFetchRequest<Account> = Account.fetchRequest()
|
|
accountRequest.fetchLimit = 1
|
|
accountRequest.resultType = .countResultType
|
|
return ((try? context.count(for: accountRequest)) ?? 0) > 0
|
|
}
|
|
|
|
// MARK: - iPad Layout
|
|
|
|
private var iPadMainContent: some View {
|
|
NavigationSplitView(columnVisibility: .constant(.all)) {
|
|
List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in
|
|
Label(tab.title, systemImage: tab.icon)
|
|
}
|
|
.navigationTitle("Portfolio Journal")
|
|
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280)
|
|
} detail: {
|
|
iPadDetailView(for: sidebarSelection ?? .dashboard)
|
|
}
|
|
.onChange(of: sidebarSelection) { _, tab in
|
|
guard let tab else { return }
|
|
if tabSelection.selectedTab != tab.rawValue {
|
|
tabSelection.selectedTab = tab.rawValue
|
|
}
|
|
}
|
|
.onChange(of: tabSelection.selectedTab) { _, value in
|
|
if let tab = AppTab(rawValue: value), sidebarSelection != tab {
|
|
sidebarSelection = tab
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func iPadDetailView(for tab: AppTab) -> some View {
|
|
switch tab {
|
|
case .dashboard:
|
|
bannerInsetView(DashboardView())
|
|
case .sources:
|
|
bannerInsetView(SourceListView(iapService: iapService))
|
|
case .charts:
|
|
bannerInsetView(ChartsContainerView(iapService: iapService))
|
|
case .journal:
|
|
bannerInsetView(JournalView())
|
|
case .settings:
|
|
bannerInsetView(SettingsView(iapService: iapService))
|
|
}
|
|
}
|
|
|
|
// MARK: - iPhone Layout
|
|
|
|
private var mainContent: some View {
|
|
ZStack {
|
|
TabView(selection: $tabSelection.selectedTab) {
|
|
bannerInsetView(DashboardView())
|
|
.tabItem {
|
|
Label("Home", systemImage: "house.fill")
|
|
}
|
|
.tag(0)
|
|
|
|
bannerInsetView(SourceListView(iapService: iapService))
|
|
.tabItem {
|
|
Label("Sources", systemImage: "list.bullet")
|
|
}
|
|
.tag(1)
|
|
|
|
bannerInsetView(ChartsContainerView(iapService: iapService))
|
|
.tabItem {
|
|
Label("Charts", systemImage: "chart.xyaxis.line")
|
|
}
|
|
.tag(2)
|
|
|
|
bannerInsetView(JournalView())
|
|
.tabItem {
|
|
Label("Journal", systemImage: "book.closed")
|
|
}
|
|
.tag(3)
|
|
|
|
bannerInsetView(SettingsView(iapService: iapService))
|
|
.tabItem {
|
|
Label("Settings", systemImage: "gearshape.fill")
|
|
}
|
|
.tag(4)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
|
|
content.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
if !iapService.isPremium && adMobService.canShowAds {
|
|
BannerAdView()
|
|
.frame(height: AppConstants.UI.bannerAdHeight)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(.systemBackground))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
.environmentObject(CoreDataStack.shared)
|
|
.environmentObject(IAPService())
|
|
.environmentObject(AdMobService())
|
|
.environmentObject(AccountStore(iapService: IAPService()))
|
|
.environmentObject(TabSelectionStore())
|
|
}
|