Simplify titles beside the iPad top tab bar
This commit is contained in:
1 parent
e7b3e650a4
commit
b60e68e3d5
5 files changed
+225
-7
No files matched your search
@@ -806,6 +806,11 @@ unsaved forms. Metrics has one independent stack in all widths; Vault search sho
|
||||
select that destination rather than constructing another Metrics page. Resizing never
|
||||
restarts live connections, transfers or Agent runs, or creates a new conversation.
|
||||
|
||||
Metrics detail and Assistant use the actual tab-bar placement to hide their toolbar
|
||||
titles only beside the top tab bar. Assistant retains its conversation switcher as
|
||||
an icon menu there; sidebar and bottom-bar layouts retain the original titles and
|
||||
title menus. Assistant library pages retain their titles in every placement.
|
||||
|
||||
Editor saves do not gate layout changes. Their tasks, duplicate-submit protection,
|
||||
unsaved-dismiss confirmation and error feedback survive width changes. Sheets follow
|
||||
the presenting window's width using stable presentation modifiers. Cards use container
|
||||
|
||||
@@ -21,6 +21,7 @@ struct MobileAssistantView: View {
|
||||
let app: MobileAppModel
|
||||
@Bindable var model: MobileAssistantSceneModel
|
||||
@Binding var libraryRoute: MobileAssistantLibraryRoute?
|
||||
@Environment(\.tabBarPlacement) private var tabBarPlacement
|
||||
@State private var restoreComposerFocusAfterLibrary = false
|
||||
@State private var nextComposerFocusRequest = 0
|
||||
@State private var composerFocusRequest: Int?
|
||||
@@ -41,11 +42,24 @@ struct MobileAssistantView: View {
|
||||
|
||||
var body: some View {
|
||||
detail
|
||||
.navigationTitle(
|
||||
model.conversation.isEmpty ? String(localized: "Assistant") : model.conversation.derivedTitle
|
||||
)
|
||||
.navigationTitle(conversationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarTitleMenu { conversationTitleMenu }
|
||||
.toolbar(removing: usesConversationIcon ? .title : nil)
|
||||
.toolbar {
|
||||
if usesConversationIcon {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Menu {
|
||||
conversationTitleMenu
|
||||
} label: {
|
||||
Label("Conversations", systemImage: "bubble.left.and.bubble.right")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.accessibilityValue(conversationTitle)
|
||||
}
|
||||
} else if libraryRoute == nil {
|
||||
ToolbarTitleMenu { conversationTitleMenu }
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
Button("New Conversation", systemImage: "plus.bubble", action: newConversation)
|
||||
.labelStyle(.iconOnly)
|
||||
@@ -133,6 +147,14 @@ struct MobileAssistantView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var conversationTitle: String {
|
||||
model.conversation.isEmpty ? String(localized: "Assistant") : model.conversation.derivedTitle
|
||||
}
|
||||
|
||||
private var usesConversationIcon: Bool {
|
||||
tabBarPlacement == .topBar && libraryRoute == nil
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
MobileAssistantConversation(
|
||||
app: app,
|
||||
|
||||
@@ -71,10 +71,11 @@ struct MobileMetricsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileMetricsDetailView: View {
|
||||
struct MobileMetricsDetailView: View {
|
||||
let app: MobileAppModel
|
||||
let server: ServerMetric
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.tabBarPlacement) private var tabBarPlacement
|
||||
@State private var page = MobileMetricsDetailPage.status
|
||||
@State private var observer = UUID()
|
||||
@State private var availableWidth: CGFloat = 0
|
||||
@@ -124,6 +125,7 @@ private struct MobileMetricsDetailView: View {
|
||||
}
|
||||
.navigationTitle(server.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(removing: tabBarPlacement == .topBar ? .title : nil)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
if !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
import UIKit
|
||||
@@ -102,6 +103,139 @@ struct MobileAdaptiveLayoutTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func assistantTitleAdaptsWithoutLosingItsConversationMenuOrDrafts(language: String) async throws {
|
||||
guard UIDevice.current.userInterfaceIdiom == .pad else { return }
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let scene = MobileSceneModel(app: app)
|
||||
scene.selectedDestination = .assistant
|
||||
await scene.assistant.load()
|
||||
let first = ConversationModel()
|
||||
first.appendUserTurn("Investigate the production database latency")
|
||||
first.customTitle = language == "en" ? "Investigate the production database latency" : "排查生产数据库延迟与连接问题"
|
||||
first.isPinned = true
|
||||
first.composer.draft = "Keep the first draft"
|
||||
let second = ConversationModel()
|
||||
second.appendUserTurn("Review the deployment")
|
||||
second.composer.draft = "Keep the second draft"
|
||||
scene.assistant.library.insertAndSelect(second)
|
||||
scene.assistant.library.insertAndSelect(first)
|
||||
try await withRoot(app: app, scene: scene, language: language) { controller in
|
||||
let tabs = try #require(find(UITabBarController.self, in: controller))
|
||||
let navigation = try #require(navigation(titled: first.derivedTitle, in: controller))
|
||||
let page = try #require(navigation.topViewController)
|
||||
for (sizeClass, sidebar): (UIUserInterfaceSizeClass, Bool) in [
|
||||
(.regular, false), (.regular, true), (.compact, false), (.regular, false),
|
||||
] {
|
||||
resize(controller, to: sizeClass, width: sizeClass == .regular ? 1366 : 430)
|
||||
tabs.sidebar.isHidden = !sidebar
|
||||
try await settle()
|
||||
let topBar = sizeClass == .regular && !sidebar
|
||||
capture(controller, name: "assistant-title-\(sizeClass.rawValue)-\(sidebar)-\(language)")
|
||||
#expect(navigation.topViewController === page)
|
||||
#expect(scene.assistant.conversation === first)
|
||||
#expect(first.composer.draft == "Keep the first draft")
|
||||
#expect(
|
||||
visibleLabels(in: navigation.navigationBar).contains { $0.text == first.derivedTitle } == !topBar)
|
||||
let switcher = toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation)
|
||||
#expect((switcher != nil) == topBar)
|
||||
if let switcher {
|
||||
let trailingButtons =
|
||||
page.navigationItem.trailingItemGroups.flatMap(\.barButtonItems)
|
||||
+ (page.navigationItem.rightBarButtonItems ?? [])
|
||||
// New Conversation and More, without a second overflow for the hidden title menu.
|
||||
#expect(Set(trailingButtons.map(ObjectIdentifier.init)).count == 2)
|
||||
let menu = try #require(switcher.menu)
|
||||
let actions = try await resolvedMenuActions(menu, in: page.view)
|
||||
#expect(actions.map(\.title).contains(first.derivedTitle))
|
||||
let selectSecond = try #require(actions.first { $0.title == second.derivedTitle })
|
||||
UIButton(primaryAction: selectSecond).sendActions(for: .primaryActionTriggered)
|
||||
try await settle()
|
||||
#expect(scene.assistant.conversation === second)
|
||||
#expect(second.composer.draft == "Keep the second draft")
|
||||
let updatedSwitcher = try #require(
|
||||
toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation))
|
||||
let updatedMenu = try #require(updatedSwitcher.menu)
|
||||
let updatedActions = try await resolvedMenuActions(updatedMenu, in: page.view)
|
||||
let selectFirst = try #require(updatedActions.first { $0.title == first.derivedTitle })
|
||||
UIButton(primaryAction: selectFirst).sendActions(for: .primaryActionTriggered)
|
||||
try await settle()
|
||||
#expect(scene.assistant.conversation === first)
|
||||
}
|
||||
}
|
||||
for route in [MobileAssistantLibraryRoute.conversations, .archived] {
|
||||
scene.assistantLibraryRoute = route
|
||||
try await settle()
|
||||
let title = try #require(navigation.topViewController?.navigationItem.title)
|
||||
#expect(!title.isEmpty && title != first.derivedTitle)
|
||||
#expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == title })
|
||||
capture(controller, name: "assistant-library-\(route.rawValue)-\(language)")
|
||||
scene.assistantLibraryRoute = nil
|
||||
try await settle()
|
||||
#expect(navigation.topViewController === page)
|
||||
#expect(!visibleLabels(in: navigation.navigationBar).contains { $0.text == first.derivedTitle })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func metricsDetailTitleAdaptsWhileItsPickerAndNavigationSurvive(language: String) async throws {
|
||||
guard UIDevice.current.userInterfaceIdiom == .pad else { return }
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let scene = MobileSceneModel(app: app)
|
||||
scene.selectedDestination = .metrics
|
||||
let server = ServerMetric(
|
||||
selection: MetricsSelection(id: UUID(), enabled: false),
|
||||
name: language == "en" ? "Production database server in Singapore" : "新加坡生产环境数据库服务器")
|
||||
var features = MobileFeatureRegistry()
|
||||
for feature in MobileFeatureRegistry.standard().registrations {
|
||||
features.register(
|
||||
feature.id == .metrics
|
||||
? MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in
|
||||
AnyView(MetricsDetailFeature(app: app, server: server))
|
||||
} : feature)
|
||||
}
|
||||
try await withRoot(app: app, scene: scene, language: language, features: features) { controller in
|
||||
let tabs = try #require(find(UITabBarController.self, in: controller))
|
||||
let navigation = try #require(navigation(titled: server.name, in: controller))
|
||||
let page = try #require(navigation.topViewController)
|
||||
var selectedProcesses = false
|
||||
for (sizeClass, width, sidebar): (UIUserInterfaceSizeClass, CGFloat, Bool) in [
|
||||
(.regular, 1024, false), (.regular, 600, false), (.regular, 1366, true),
|
||||
(.compact, 430, false), (.regular, 1024, false), (.compact, 430, false),
|
||||
] {
|
||||
resize(controller, to: sizeClass, width: width)
|
||||
tabs.sidebar.isHidden = !sidebar
|
||||
try await settle()
|
||||
capture(controller, name: "metrics-title-\(Int(width))-\(sidebar)-\(language)")
|
||||
#expect(navigation.topViewController === page)
|
||||
#expect(navigation.viewControllers.count == 2)
|
||||
#expect(page.navigationItem.title == server.name)
|
||||
#expect(toolbarButton(namedOneOf: ["Metrics", "监控", "監控"], in: navigation) != nil)
|
||||
if sidebar {
|
||||
#expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == server.name })
|
||||
} else if sizeClass == .regular {
|
||||
#expect(!visibleLabels(in: navigation.navigationBar).contains { $0.text == server.name })
|
||||
}
|
||||
// The bottom bar leaves the principal toolbar slot available for the existing picker.
|
||||
if sizeClass == .compact {
|
||||
let picker = try #require(find(UISegmentedControl.self, in: controller.view))
|
||||
#expect(picker.numberOfSegments == 2)
|
||||
#expect(picker.selectedSegmentIndex == (selectedProcesses ? 1 : 0))
|
||||
picker.selectedSegmentIndex = 1
|
||||
picker.sendActions(for: .valueChanged)
|
||||
selectedProcesses = true
|
||||
try await settle()
|
||||
#expect(find(UICollectionView.self, in: page.view) != nil)
|
||||
}
|
||||
}
|
||||
navigation.popViewController(animated: false)
|
||||
try await settle()
|
||||
#expect(navigation.viewControllers.count == 1)
|
||||
#expect(scene.selectedDestination == .metrics)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func settingsDepthAndUnsavedFormsSurviveWidthChanges() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let scene = MobileSceneModel(app: app)
|
||||
@@ -398,6 +532,47 @@ struct MobileAdaptiveLayoutTests {
|
||||
return (view as? UILabel).map { [$0] } ?? view.subviews.flatMap { visibleLabels(in: $0) }
|
||||
}
|
||||
|
||||
private func toolbarButton(namedOneOf names: Set<String>, in navigation: UINavigationController) -> UIBarButtonItem?
|
||||
{
|
||||
guard let item = navigation.topViewController?.navigationItem else { return nil }
|
||||
let buttons =
|
||||
item.leadingItemGroups.flatMap(\.barButtonItems)
|
||||
+ item.trailingItemGroups.flatMap(\.barButtonItems)
|
||||
+ (item.leftBarButtonItems ?? []) + (item.rightBarButtonItems ?? [])
|
||||
return buttons.first { button in
|
||||
(button.accessibilityLabel ?? button.title ?? button.customView?.accessibilityLabel).map(names.contains)
|
||||
== true
|
||||
}
|
||||
}
|
||||
|
||||
private func menuActions(_ menu: UIMenu) -> [UIAction] {
|
||||
menu.children.flatMap { element -> [UIAction] in
|
||||
if let action = element as? UIAction { return [action] }
|
||||
if let submenu = element as? UIMenu { return menuActions(submenu) }
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedMenuActions(_ menu: UIMenu, in view: UIView) async throws -> [UIAction] {
|
||||
// SwiftUI supplies deferred menu elements. Present the native menu to ask UIKit to resolve them.
|
||||
let button = UIButton(frame: CGRect(x: 20, y: 100, width: 44, height: 44))
|
||||
button.menu = menu
|
||||
button.showsMenuAsPrimaryAction = true
|
||||
view.addSubview(button)
|
||||
defer { button.removeFromSuperview() }
|
||||
button.performPrimaryAction()
|
||||
try await settle()
|
||||
let interaction = try #require(button.contextMenuInteraction)
|
||||
var actions: [UIAction] = []
|
||||
interaction.updateVisibleMenu { resolved in
|
||||
actions = menuActions(resolved)
|
||||
return resolved
|
||||
}
|
||||
interaction.dismissMenu()
|
||||
try await settle()
|
||||
return actions
|
||||
}
|
||||
|
||||
private func settle() async throws { try await Task.sleep(for: .milliseconds(500)) }
|
||||
|
||||
private func capture(_ controller: UIViewController, name: String) {
|
||||
@@ -408,6 +583,20 @@ struct MobileAdaptiveLayoutTests {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetricsDetailFeature: View {
|
||||
let app: MobileAppModel
|
||||
let server: ServerMetric
|
||||
@State private var showsDetail = true
|
||||
|
||||
var body: some View {
|
||||
Text("Metrics")
|
||||
.navigationTitle("Metrics")
|
||||
.navigationDestination(isPresented: $showsDetail) {
|
||||
MobileMetricsDetailView(app: app, server: server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable @MainActor private final class SavingDraft {
|
||||
enum Failure: Error { case expected }
|
||||
var isPresented = true
|
||||
|
||||
@@ -1770,7 +1770,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ICLOUD_CONTAINER_ENVIRONMENT = Development;
|
||||
INFOPLIST_FILE = TFI/Info.plist;
|
||||
@@ -2043,7 +2043,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ICLOUD_CONTAINER_ENVIRONMENT = Production;
|
||||
INFOPLIST_FILE = TFI/Info.plist;
|
||||
|
||||
Reference in new issue
Block a user