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:
alexandrev-tibco
2026-09-16 11:44:50 +02:00
parent a8a9ad64f3
commit 680255f69d
17 changed files with 485 additions and 547 deletions
+50 -69
View File
@@ -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 {