Files
web-terminal/ios/App/WebTerm/Screens/ProjectsScreen.swift
Yaojia Wang 660a40491a feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.

Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).

Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
2026-07-05 22:00:31 +02:00

288 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import APIClient
import SwiftUI
/// T-iOS-26 · Projects web v0.6 projects namespace
/// + + dirty + Active now +
/// /// `ProjectsViewModel`
///
/// //**** `Text(verbatim:)`
/// LocalizedStringKey/Markdown
struct ProjectsScreen: View {
@Bindable var viewModel: ProjectsViewModel
/// "" AppCoordinator.openProject
var onOpen: (ProjectOpenRequest) -> Void = { _ in }
/// T-iPad-4 · idiom `ProjectsGridLayout`/
/// `ProjectsSheetSizing` iPhone List
/// iPad + sheet detentsidiom
/// @Environment `ProjectsLayout`
/// idiom size classiPad sheet compact
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
private enum Copy {
static let emptyNoProjectsTitle = "暂无项目"
static let emptyNoMatchTitle = "无匹配项目"
}
var body: some View {
adaptiveContent
.navigationTitle(ProjectsCopy.title)
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchText, prompt: ProjectsCopy.searchPrompt)
.refreshable { await viewModel.refresh() }
.task { await viewModel.load() }
.onChange(of: viewModel.openRequest) { _, request in
guard let request else { return }
onOpen(request)
}
.navigationDestination(for: ProjectRoute.self) { route in
ProjectDetailScreen(
viewModel: viewModel.makeDetailViewModel(path: route.path),
endpoint: viewModel.host.endpoint,
http: viewModel.http,
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
)
}
// T-iPad-4 · iPhone sheet iPad
// detents
.adaptiveProjectsSheetDetents(idiom: idiom)
}
// MARK: - idiom ProjectsGridLayout
/// iPhone `list`iPad
/// `gridList`///prefs `ProjectsViewModel`
///
@ViewBuilder private var adaptiveContent: some View {
if ProjectsGridLayout.usesGrid(idiom: idiom) {
gridList
} else {
list
}
}
// MARK: - List
private var list: some View {
List {
errorRows
if let message = viewModel.emptyStateMessage {
emptyState(message)
.frame(maxWidth: .infinity)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
ForEach(viewModel.groups) { group in
groupSection(group)
}
}
.listStyle(.insetGrouped)
.overlay {
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
ProgressView()
}
}
}
/// symbol + friendly
private func emptyState(_ message: String) -> some View {
ContentUnavailableView {
Label(
viewModel.isSearching ? Copy.emptyNoMatchTitle : Copy.emptyNoProjectsTitle,
systemImage: viewModel.isSearching ? "magnifyingglass" : "folder"
)
} description: {
Text(message)
}
}
// MARK: - Gridregular
/// iPad / `ProjectsGridLayout.columnCount`
/// ****GeometryReader/// List
/// builder`groupHeader`/`projectRow`/`errorRows`
/// //prefs
private var gridList: some View {
GeometryReader { proxy in
let columns = ProjectsGridLayout.columnCount(
availableWidth: proxy.size.width,
idiom: idiom
)
ScrollView {
LazyVStack(alignment: .leading, spacing: DS.Space.md12) {
errorRows
if let message = viewModel.emptyStateMessage {
emptyState(message)
.frame(maxWidth: .infinity)
}
ForEach(viewModel.groups) { group in
gridSection(group, columns: columns)
}
}
.padding(DS.Space.lg16)
}
.overlay {
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
ProgressView()
}
}
}
}
@ViewBuilder private func gridSection(_ group: ProjectGroup, columns: Int) -> some View {
let isCollapsed = viewModel.isCollapsed(group)
VStack(alignment: .leading, spacing: DS.Space.sm8) {
if group.kind != .flat {
groupHeader(group, isCollapsed: isCollapsed)
.padding(.top, DS.Space.md12)
}
if !isCollapsed {
LazyVGrid(
columns: gridColumns(columns),
alignment: .leading,
spacing: DS.Space.sm8
) {
ForEach(group.projects, id: \.path) { project in
// = DS Cardsecondary + + md12
Card(padding: DS.Space.md12) {
projectRow(project, group: group)
}
}
}
}
}
}
/// `columns >= 1` `ProjectsGridLayout`
private func gridColumns(_ count: Int) -> [GridItem] {
Array(
repeating: GridItem(.flexible(), spacing: DS.Space.sm8),
count: max(count, ProjectsGridLayout.singleColumn)
)
}
/// prefs
@ViewBuilder private var errorRows: some View {
ForEach(
[
viewModel.fetchErrorMessage,
viewModel.prefsErrorMessage,
viewModel.prefsSyncErrorMessage,
viewModel.openErrorMessage,
].compactMap(\.self),
id: \.self
) { message in
Label(message, systemImage: "exclamationmark.triangle")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.listRowSeparator(.hidden)
}
}
// MARK: - Group sectionflat chromenamespace/other
@ViewBuilder private func groupSection(_ group: ProjectGroup) -> some View {
let isCollapsed = viewModel.isCollapsed(group)
Section {
if !isCollapsed {
ForEach(group.projects, id: \.path) { project in
projectRow(project, group: group)
}
}
} header: {
if group.kind != .flat {
groupHeader(group, isCollapsed: isCollapsed)
}
}
}
private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View {
HStack(spacing: DS.Space.sm8) {
if group.isCollapsible {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
// namespace label verbatim
Text(verbatim: group.label)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
// tabular
Text(verbatim: "\(group.projects.count)")
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textTertiary)
// web
if group.kind != .active && group.activeCount > 0 {
Text(ProjectsCopy.activeCountBadge(group.activeCount))
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.statusWorking)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
.onTapGesture {
guard group.isCollapsible else { return }
Task { await viewModel.toggleCollapsed(key: group.key) }
}
.accessibilityAddTraits(group.isCollapsible ? .isButton : [])
}
// MARK: - Project row
private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View {
NavigationLink(value: ProjectRoute(path: project.path)) {
HStack(spacing: DS.Space.sm8) {
favouriteButton(project)
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(verbatim: ProjectGrouping.displayLabel(
name: project.name, groupKey: group.key
))
.font(DS.Typography.body.weight(.medium))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
projectChips(project)
}
Spacer(minLength: 0)
// DS + + VoiceOver
if ProjectGrouping.hasRunningSession(project) {
StatusBadge(status: .working)
}
}
}
}
private func favouriteButton(_ project: ProjectInfo) -> some View {
Button {
DS.Haptics.selection()
Task { await viewModel.toggleFavourite(path: project.path) }
} label: {
let isFav = viewModel.isFavourite(project.path)
Image(systemName: isFav ? "star.fill" : "star")
.foregroundStyle(isFav ? DS.Palette.accent : DS.Palette.textTertiary)
}
.buttonStyle(.borderless) // List
}
@ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View {
HStack(spacing: DS.Space.sm8) {
if let branch = project.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
if project.dirty == true {
DirtyBadge()
}
}
}
}
/// push value-based navigation
struct ProjectRoute: Hashable {
let path: String
}