iPhone Duo (fase 1): navegación adaptativa por size class y layouts regular×regular
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab. - Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho regular, stack colapsable en compacto) con selección nativa de List. - Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos en regular, sheet en compacto). - Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:), sin ignoresSafeArea en contenido interactivo. - Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
This commit is contained in:
@@ -41,13 +41,14 @@ struct ContentView: View {
|
||||
@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
|
||||
/// Per-scene tab restoration: the interface is re-laid out when iPhone Duo
|
||||
/// opens/closes or a window is resized — the selected tab must survive it.
|
||||
@SceneStorage("selectedTab") private var restoredTab: Int = -1
|
||||
|
||||
private var currentVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
@@ -75,8 +76,6 @@ struct ContentView: View {
|
||||
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
|
||||
} else if resolvedOnboardingCompleted == false {
|
||||
OnboardingView(onboardingCompleted: $onboardingCompleted)
|
||||
} else if horizontalSizeClass == .regular {
|
||||
iPadMainContent
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
@@ -92,6 +91,12 @@ struct ContentView: View {
|
||||
} else {
|
||||
isUnlocked = !lockOnLaunch
|
||||
}
|
||||
if restoredTab >= 0, AppTab(rawValue: restoredTab) != nil {
|
||||
tabSelection.selectedTab = restoredTab
|
||||
}
|
||||
}
|
||||
.onChange(of: tabSelection.selectedTab) { _, tab in
|
||||
restoredTab = tab
|
||||
}
|
||||
.onChange(of: lockEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
@@ -119,7 +124,7 @@ struct ContentView: View {
|
||||
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion,
|
||||
!WhatsNewCatalog.pendingReleases(since: lastSeenWhatsNewVersion, current: currentVersion).isEmpty {
|
||||
// Small delay to let the UI settle before showing sheet
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
showingWhatsNew = true
|
||||
}
|
||||
}
|
||||
@@ -144,18 +149,15 @@ struct ContentView: View {
|
||||
))
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in
|
||||
tabSelection.selectedTab = 1
|
||||
sidebarSelection = .sources
|
||||
tabSelection.selectedTab = AppTab.sources.rawValue
|
||||
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in
|
||||
tabSelection.selectedTab = 0
|
||||
sidebarSelection = .dashboard
|
||||
tabSelection.selectedTab = AppTab.dashboard.rawValue
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if url.host == "quickupdate" {
|
||||
tabSelection.selectedTab = 0
|
||||
sidebarSelection = .dashboard
|
||||
tabSelection.selectedTab = AppTab.dashboard.rawValue
|
||||
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
||||
}
|
||||
}
|
||||
@@ -168,7 +170,7 @@ struct ContentView: View {
|
||||
private func waitForDataAndResolveOnboarding() async {
|
||||
// Wait for Core Data to be loaded
|
||||
while !coreDataStack.isLoaded {
|
||||
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
||||
try? await Task.sleep(for: .milliseconds(50)) // 50ms
|
||||
}
|
||||
|
||||
// If CloudKit is enabled and no local data yet, wait briefly for the
|
||||
@@ -192,7 +194,7 @@ struct ContentView: View {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if hasExistingData() { return }
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
|
||||
try? await Task.sleep(for: .milliseconds(300)) // poll every 300ms
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,33 +242,50 @@ struct ContentView: View {
|
||||
return ((try? context.count(for: accountRequest)) ?? 0) > 0
|
||||
}
|
||||
|
||||
// MARK: - iPad Layout
|
||||
// MARK: - Adaptive navigation
|
||||
//
|
||||
// One TabView for every size class. iOS 18+ uses the sidebar-adaptable
|
||||
// style: a tab bar in compact width (iPhone, iPhone Duo outer screen) and a
|
||||
// top tab bar / sidebar in regular width (iPad, iPhone Duo inner screen).
|
||||
// Because the hierarchy never changes, opening or closing the Duo keeps
|
||||
// every tab's navigation and scroll state. Each tab that has a list+detail
|
||||
// structure (Sources, Journal) owns a NavigationSplitView that collapses
|
||||
// to a stack in compact width and shows both columns in regular width.
|
||||
|
||||
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)
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if #available(iOS 18.0, *) {
|
||||
adaptiveTabs
|
||||
} else {
|
||||
legacyTabs
|
||||
}
|
||||
.onChange(of: sidebarSelection) { _, tab in
|
||||
guard let tab else { return }
|
||||
if tabSelection.selectedTab != tab.rawValue {
|
||||
tabSelection.selectedTab = tab.rawValue
|
||||
}
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
private var adaptiveTabs: some View {
|
||||
TabView(selection: $tabSelection.selectedTab) {
|
||||
ForEach(AppTab.allCases, id: \.self) { tab in
|
||||
Tab(tab.title, systemImage: tab.icon, value: tab.rawValue) {
|
||||
tabContent(for: tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: tabSelection.selectedTab) { _, value in
|
||||
if let tab = AppTab(rawValue: value), sidebarSelection != tab {
|
||||
sidebarSelection = tab
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
}
|
||||
|
||||
/// iOS 17 fallback: classic tab bar in every size class.
|
||||
private var legacyTabs: some View {
|
||||
TabView(selection: $tabSelection.selectedTab) {
|
||||
ForEach(AppTab.allCases, id: \.self) { tab in
|
||||
tabContent(for: tab)
|
||||
.tabItem { Label(tab.title, systemImage: tab.icon) }
|
||||
.tag(tab.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iPadDetailView(for tab: AppTab) -> some View {
|
||||
private func tabContent(for tab: AppTab) -> some View {
|
||||
switch tab {
|
||||
case .dashboard:
|
||||
bannerInsetView(DashboardView())
|
||||
@@ -281,44 +300,6 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user