import SwiftUI struct NotesListView: View { @Environment(AppEnvironment.self) private var env @Bindable var store: WorkspaceStore @Binding var path: NavigationPath var onCreateNote: (() -> Void)? = nil var onCreateFromTemplate: (() -> Void)? = nil var onCreateNotebook: (() -> Void)? = nil /// First-login / first-mirror bootstrap (Android `initialSyncProgress`). private var hasBootstrapProgress: Bool { env.bootstrapProgress != nil } private var bootstrapLoaded: Int { env.bootstrapProgress?.loadedCount ?? 0 } private var bootstrapTotal: Int { env.bootstrapProgress?.totalCount ?? 0 } private var bootstrapPercent: Double { guard bootstrapTotal > 0 else { return 0 } return min(1, Double(bootstrapLoaded) / Double(bootstrapTotal)) } private var bootstrapTitle: String { env.preferences.t("正在同步笔记", en: "Syncing notes", pl: "Synchronizowanie notatek") } private var bootstrapDescription: String { if bootstrapTotal > 0 { return env.preferences.t( "已加载 \(bootstrapLoaded) / \(bootstrapTotal) 条笔记", en: "Loaded \(bootstrapLoaded) of \(bootstrapTotal) notes", pl: "Wczytane notatki: \(bootstrapLoaded) z \(bootstrapTotal)" ) } return env.preferences.t("正在准备首次同步…", en: "Preparing first sync…", pl: "Przygotowywanie pierwszej synchronizacji…") } var body: some View { Group { // Android MemoList: full loading card while first mirror is empty. if hasBootstrapProgress && store.memos.isEmpty { bootstrapLoadingCard .transition(Motion.softFade) } else if store.memos.isEmpty && env.lastSyncError != nil && !store.isLoadingList { // Failed first sync with no cached notes — don't pretend the account is empty. initialSyncErrorCard .transition(Motion.softFade) } else if store.notebooks.isEmpty && store.memos.isEmpty && !store.isLoadingList { emptyCard( title: env.preferences.t("暂无笔记本", en: "No notebooks", pl: "Brak notatników"), description: env.preferences.t( "先创建一个笔记本,再开始记录。", en: "Create a notebook to start taking notes.", ja: "ノートブックを作成して記録を始めましょう。", pl: "Utwórz notatnik, aby zacząć robić notatki." ), showCreate: false, showCreateNotebook: true ) .transition(Motion.softFade) } else if store.memos.isEmpty && !store.isLoadingList { emptyCard( title: emptyTitle, description: emptyDescription, showCreate: store.searchText.isEmpty && store.filter == .all && store.selectedTag == nil ) .transition(Motion.softFade) } else { ScrollView { // Android FlatList `list`: paddingTop 12, paddingHorizontal 12, paddingBottom 18 LazyVStack(spacing: 0) { if hasBootstrapProgress { bootstrapProgressBanner // memoSyncBanner marginBottom: 10 .padding(.bottom, 10) } else if env.lastSyncError != nil, !store.memos.isEmpty { syncPausedBanner .padding(.bottom, 10) } ForEach(store.memos, id: \.id) { memo in memoCard(for: memo) .padding(.bottom, env.preferences.listDensity.cardBottomMargin) .onAppear { if memo.id == store.memos.last?.id { store.loadMore(env: env) } } } if store.isLoadingMore { ProgressView() .tint(AppTheme.title) .padding(.vertical, 18) } } .padding(.horizontal, 12) .padding(.top, 12) .padding(.bottom, 18) .animation(Motion.search, value: hasBootstrapProgress) } .contentMargins(.bottom, 0, for: .scrollContent) .background(AppTheme.background) } } .animation(Motion.listContent, value: store.memos.isEmpty) .animation(Motion.search, value: hasBootstrapProgress) .overlay(alignment: .bottom) { if let err = store.listError { Text(err) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(AppTheme.danger) .padding(10) .frame(maxWidth: .infinity) .background(AppTheme.card.opacity(0.96)) .transition(Motion.softFade) .edgeEverErrorShake(on: err) } } .animation(Motion.search, value: store.listError) .accessibilityElement(children: .contain) } // MARK: - First-sync progress (pixel tokens from Android workspace-styles.ts) /// Android `memoListStateWrap` + `memoListLoadingCard`. private var bootstrapLoadingCard: some View { VStack(spacing: 0) { VStack(spacing: 0) { ProgressView() .controlSize(.large) .tint(AppTheme.syncProgressFill) Text(bootstrapTitle) .font(.system(size: 16, weight: AppTheme.heavy)) .foregroundStyle(AppTheme.title) .multilineTextAlignment(.center) .padding(.top, 14) Text(bootstrapDescription) .font(.system(size: 13)) .foregroundStyle(AppTheme.secondary) .lineSpacing(4) // ~lineHeight 20 on 13pt .multilineTextAlignment(.center) .padding(.top, 7) .frame(maxWidth: 300) if bootstrapTotal > 0 { // Android `memoSyncProgressTrack` marginTop: 10 bootstrapProgressTrack(percent: bootstrapPercent) .padding(.top, 10) } } .frame(maxWidth: .infinity) .padding(.horizontal, 16) .padding(.vertical, 34) .background(AppTheme.card) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 4])) .foregroundStyle(AppTheme.accentBorder) ) .clipShape(RoundedRectangle(cornerRadius: 8)) // memoListStateWrap: paddingHorizontal 12, paddingTop 16 .padding(.horizontal, 12) .padding(.top, 16) .accessibilityElement(children: .combine) .accessibilityLabel("\(bootstrapTitle). \(bootstrapDescription)") Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(AppTheme.background) } /// Android `memoSyncBanner` (+ track always visible while bootstrap is active). private var bootstrapProgressBanner: some View { HStack(alignment: .center, spacing: 12) { ProgressView() .controlSize(.small) .tint(AppTheme.syncProgressFill) VStack(alignment: .leading, spacing: 0) { Text(bootstrapTitle) .font(.system(size: 13, weight: AppTheme.heavy)) .foregroundStyle(AppTheme.accentText) Text(bootstrapDescription) .font(.system(size: 12)) .foregroundStyle(AppTheme.accentStrong) .padding(.top, 2) bootstrapProgressTrack(percent: bootstrapPercent) .padding(.top, 10) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 14) .padding(.vertical, 12) .background(AppTheme.accentSoft) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(AppTheme.accentBorder, lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: 8)) .accessibilityElement(children: .combine) .accessibilityLabel("\(bootstrapTitle). \(bootstrapDescription)") } /// Android `memoSyncProgressTrack` / `memoSyncProgressFill`. private func bootstrapProgressTrack(percent: Double) -> some View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() .fill(AppTheme.syncProgressTrack) Capsule() .fill(AppTheme.syncProgressFill) .frame(width: max(0, geo.size.width * percent)) .animation(.easeOut(duration: 0.2), value: percent) } } .frame(height: 4) .frame(maxWidth: .infinity) .clipShape(Capsule()) } /// Android `memoListErrorCard` + retry. private var initialSyncErrorCard: some View { VStack(spacing: 0) { VStack(spacing: 0) { Text(env.preferences.t("暂时没有拉到笔记", en: "Could not load notes", pl: "Nie udało się wczytać notatek")) .font(.system(size: 14, weight: AppTheme.heavy)) .foregroundStyle(AppTheme.syncErrorTitle) .multilineTextAlignment(.center) Text(env.preferences.t( "网络或 PWA 后台恢复可能短暂中断了同步。这里不会把它当作空笔记本。", en: "A network hiccup may have interrupted sync. This is not treated as an empty notebook.", pl: "Synchronizacja mogła zostać przerwana przez chwilowy problem z siecią. Nie jest to traktowane jako pusty notatnik." )) .font(.system(size: 12)) .foregroundStyle(AppTheme.syncErrorBody) .lineSpacing(4) // ~lineHeight 20 .multilineTextAlignment(.center) .padding(.top, 8) .frame(maxWidth: 300) Button { Task { await env.runSyncCycle(force: true) } } label: { HStack(spacing: 7) { Image(systemName: "arrow.clockwise") .font(.system(size: 15, weight: .semibold)) Text(env.preferences.t("重试", en: "Retry", pl: "Spróbuj ponownie")) .font(.system(size: 13, weight: AppTheme.heavy)) } .foregroundStyle(AppTheme.syncErrorBody) .padding(.horizontal, 12) .frame(minHeight: 36) .background(AppTheme.syncErrorRetryFill) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .padding(.top, 16) .accessibilityLabel(env.preferences.t("重试加载", en: "Retry loading", pl: "Wczytaj ponownie")) } .frame(maxWidth: .infinity) .padding(.horizontal, 16) .padding(.vertical, 34) .background(AppTheme.syncErrorBackground) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 4])) .foregroundStyle(AppTheme.syncErrorBorder) ) .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(.horizontal, 12) .padding(.top, 16) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(AppTheme.background) } /// Android `memoSyncErrorBanner` (no technical error string in the banner body). private var syncPausedBanner: some View { HStack(alignment: .center, spacing: 10) { VStack(alignment: .leading, spacing: 0) { Text(env.preferences.t("同步已暂停", en: "Sync paused", pl: "Synchronizacja wstrzymana")) .font(.system(size: 13, weight: AppTheme.heavy)) .foregroundStyle(AppTheme.syncErrorTitle) Text(env.preferences.t( "已加载的笔记仍可使用,请检查网络后重试。", en: "Loaded notes remain available. Check your connection and retry.", pl: "Wczytane notatki nadal są dostępne. Sprawdź połączenie i spróbuj ponownie." )) .font(.system(size: 12)) .foregroundStyle(AppTheme.syncErrorBody) .lineSpacing(3) // ~lineHeight 18 .fixedSize(horizontal: false, vertical: true) .padding(.top, 2) } .frame(maxWidth: .infinity, alignment: .leading) Button { Task { await env.runSyncCycle(force: true) } } label: { HStack(spacing: 5) { Image(systemName: "arrow.clockwise") .font(.system(size: 13, weight: .semibold)) Text(env.preferences.t("重试", en: "Retry", pl: "Spróbuj ponownie")) .font(.system(size: 13, weight: AppTheme.heavy)) } .foregroundStyle(AppTheme.syncErrorBody) .padding(.horizontal, 4) .padding(.vertical, 8) } .buttonStyle(.plain) } .padding(.horizontal, 14) .padding(.vertical, 12) .background(AppTheme.syncErrorBackground) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(AppTheme.syncErrorBorder, lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: 8)) } /// With sub-notebooks hidden, an empty parent must not look like its notes are gone. private var hiddenDescendantMemoCount: Int { guard !env.preferences.showDescendantNotes, store.searchText.trimmingCharacters(in: .whitespaces).isEmpty, store.filter == .all, store.selectedTag == nil, let notebookId = store.selectedNotebookId else { return 0 } return NotebookHierarchy.descendantMemoCount(notebooks: store.notebooks, targetNotebookId: notebookId) } private var emptyTitle: String { if hiddenDescendantMemoCount > 0 { return env.preferences.t("本级暂无笔记", en: "No notes directly in this notebook", ja: "このノートブック直下にノートはありません", pl: "Brak notatek bezpośrednio w tym notatniku") } if !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty { return env.preferences.t("没有找到匹配笔记", en: "No matching notes", pl: "Brak pasujących notatek") } if store.filter != .all || store.selectedTag != nil { return env.preferences.t("没有符合筛选的笔记", en: "No notes match this filter", pl: "Żadne notatki nie pasują do tego filtra") } return env.preferences.t("暂无笔记", en: "No notes yet", pl: "Brak notatek") } private var emptyDescription: String { let hiddenCount = hiddenDescendantMemoCount if hiddenCount > 0 { return env.preferences.t( "子笔记本中还有 \(hiddenCount) 条笔记。可以打开子笔记本查看,或在设置中开启“父笔记本中显示子笔记本笔记”。", en: "Its sub-notebooks still contain \(hiddenCount) notes. Open a sub-notebook to see them, or turn on \"Show notes from sub-notebooks\" in Settings.", ja: "サブノートブックにはまだ \(hiddenCount) 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。", pl: "Podnotatniki nadal zawierają notatki (liczba: \(hiddenCount)). Otwórz podnotatnik, aby je zobaczyć, lub włącz w Ustawieniach opcję „Pokazuj notatki z podnotatników”." ) } if !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty { return env.preferences.t("换个关键词再试", en: "Try another keyword", pl: "Spróbuj innego słowa kluczowego") } if store.filter != .all || store.selectedTag != nil { return env.preferences.t("试试切换筛选条件,或调整搜索关键词。", en: "Try another filter or search.", pl: "Spróbuj innego filtra lub wyszukiwania.") } return env.preferences.t( "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。", en: "Create a note to preview, search, and batch-organize here.", pl: "Utwórz notatkę, aby tutaj ją podglądać, wyszukiwać i porządkować zbiorczo." ) } /// Android `memoListEmptyCard` + `emptyTitle` / `mutedText` + dual create actions. private func emptyCard(title: String, description: String, showCreate: Bool, showCreateNotebook: Bool = false) -> some View { VStack(spacing: 10) { Text(title) .font(.system(size: 16, weight: AppTheme.heavy)) .foregroundStyle(AppTheme.meta) Text(description) .font(.system(size: 13)) .foregroundStyle(AppTheme.secondary) .multilineTextAlignment(.center) if showCreateNotebook, let onCreateNotebook { Button(action: onCreateNotebook) { Label(env.preferences.t("新建笔记本", en: "New notebook", ja: "新しいノートブック", pl: "Nowy notatnik"), systemImage: "plus") .font(.system(size: 13, weight: .bold)) .foregroundStyle(.white) .padding(.horizontal, 14) .frame(height: 38) .background(AppTheme.accent) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .accessibilityIdentifier("emptyCreateNotebook") } if showCreate, !store.notebooks.isEmpty { HStack(spacing: 8) { if let onCreateNote { Button(action: onCreateNote) { HStack(spacing: 8) { Image(systemName: "plus") .font(.system(size: 14, weight: .bold)) Text(env.preferences.t("新建笔记", en: "New note", pl: "Nowa notatka")) .font(.system(size: 13, weight: .heavy)) } .foregroundStyle(Color.white) .padding(.horizontal, 14) .frame(minHeight: 38) .background(AppTheme.title) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } .buttonStyle(.plain) .accessibilityIdentifier("emptyCreateNote") } if let onCreateFromTemplate { Button(action: onCreateFromTemplate) { HStack(spacing: 8) { Image(systemName: "square.grid.2x2") .font(.system(size: 13, weight: .semibold)) Text(env.preferences.t("从模板新建", en: "New from template", pl: "Nowa z szablonu")) .font(.system(size: 13, weight: .heavy)) } .foregroundStyle(AppTheme.title) .padding(.horizontal, 14) .frame(minHeight: 38) .background(AppTheme.card) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(AppTheme.border, lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } .buttonStyle(.plain) .accessibilityIdentifier("emptyCreateFromTemplate") } } .padding(.top, 0) } } .frame(maxWidth: .infinity) .padding(.horizontal, 16) .padding(.vertical, 34) .background(AppTheme.card) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 4])) .foregroundStyle(AppTheme.emptyDashBorder) ) .clipShape(RoundedRectangle(cornerRadius: 8)) // memoListEmptyCard: marginHorizontal 12, marginTop 12 .padding(.horizontal, 12) .padding(.top, 12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(AppTheme.background) } @ViewBuilder private func memoCard(for memo: MemoSummary) -> some View { let selected = store.selectedMemoIds.contains(memo.id) let density = env.preferences.listDensity Button { if store.selectionMode { store.toggleSelected(memo.id) } else { path.append(memo.id) } } label: { memoCardChrome(memo: memo, selected: selected, density: density) .contextMenu { Button { Task { await store.togglePin(env: env, memo: memo) } } label: { Label( memo.isPinned ? env.preferences.t("取消置顶", en: "Unpin", pl: "Odepnij") : env.preferences.t("置顶", en: "Pin", pl: "Przypnij"), systemImage: memo.isPinned ? "pin.slash" : "pin" ) } Button(role: .destructive) { Task { await store.softDelete(env: env, memoId: memo.id) } } label: { Label(env.preferences.t("删除", en: "Delete", pl: "Usuń"), systemImage: "trash") } } } .buttonStyle(.plain) .edgeEverSelectionFeedback(selected) .simultaneousGesture( LongPressGesture(minimumDuration: 0.52).onEnded { _ in if !store.selectionMode { store.enterSelection(memoId: memo.id) UIImpactFeedbackGenerator(style: .medium).impactOccurred() } } ) } /// Visual chrome for a list card. @ViewBuilder private func memoCardChrome(memo: MemoSummary, selected: Bool, density: ListDensity) -> some View { HStack(alignment: .center, spacing: 0) { if store.selectionMode { ZStack { Circle() .stroke(selected ? AppTheme.title : AppTheme.border, lineWidth: 1) .background(Circle().fill(selected ? AppTheme.title : Color.clear)) .frame(width: 24, height: 24) if selected { Image(systemName: "checkmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) } } .frame(width: 44) .padding(.leading, 8) .animation(Motion.chip, value: selected) } MemoCardContent( memo: memo, density: density, locale: env.preferences.resolvedLocale, isEnglish: env.preferences.isEnglish, language: env.preferences.uiLanguage, sort: store.sort ) .padding(density.cardPadding) .padding(.leading, store.selectionMode ? 12 : density.cardPadding) } .frame(maxWidth: .infinity, minHeight: density.cardMinHeight, alignment: .leading) .background(selected ? AppTheme.background : AppTheme.card) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(selected ? AppTheme.border : AppTheme.cardBorder, lineWidth: 1) ) .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .animation(Motion.chip, value: selected) } } /// Android MemoCard content: title (+ pin star) → optional excerpt → date + tag chips. struct MemoCardContent: View { let memo: MemoSummary var density: ListDensity = .preview var locale: Locale = .current var isEnglish: Bool = false var language: AppUILanguage = .chinese var sort: MemoSortMode = .updatedDesc var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center, spacing: 6) { if memo.isPinned { Text("★") .font(.system(size: 16)) .foregroundStyle(AppTheme.secondary) .frame(width: 16, height: 16) } Text(displayTitle) .font(AppTheme.memoTitleFont) .foregroundStyle(AppTheme.title) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) } if density.showsExcerpt { Text(memo.excerpt.isEmpty ? emptyNoteLabel : memo.excerpt) .font(AppTheme.memoExcerptFont) .foregroundStyle(AppTheme.body) .lineLimit(2) .lineSpacing(2) .frame(minHeight: 40, alignment: .topLeading) .padding(.top, 8) } HStack(alignment: .center, spacing: 8) { Text("\(timestampLabel) \(MemoPreviewDate.format(timestampField.value(from: memo), locale: locale, isEnglish: isEnglish, language: language))") .font(AppTheme.memoDateFont) .foregroundStyle(AppTheme.meta) ForEach(Array(memo.tags.prefix(3)), id: \.self) { tag in Text("#\(tag)") .font(AppTheme.tagFont) .foregroundStyle(AppTheme.title) .padding(.horizontal, 6) .padding(.vertical, 2) .background(AppTheme.tagBackground) .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) } } .padding(.top, density.metaTop) } .frame(maxWidth: .infinity, alignment: .leading) } private var displayTitle: String { let t = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return t.isEmpty ? untitledNoteLabel : t } private var emptyNoteLabel: String { switch language { case .japanese: return "空のノート" case .polish: return "Pusta notatka" case .english: return "Empty note" case .chinese: return "空笔记" } } private var untitledNoteLabel: String { switch language { case .japanese: return "無題のノート" case .polish: return "Notatka bez tytułu" case .english: return "Untitled note" case .chinese: return "无标题笔记" } } private var timestampField: MemoListTimestampField { MemoListTimestampField.resolve(for: sort) } private var timestampLabel: String { switch timestampField { case .createdAt: switch language { case .japanese: return "作成" case .polish: return "Utworzono" case .english: return "Created" case .chinese: return "创建" } case .updatedAt: switch language { case .japanese: return "更新" case .polish: return "Zaktualizowano" case .english: return "Updated" case .chinese: return "更新" } } } }