import Foundation import UIKit /// Port of `@edgeever/shared/mobile-ui` — keep behavior identical to Android RN. enum MobileUIMetrics { static let bottomNavigationHeight: CGFloat = 52 static let compactControlHeight: CGFloat = 36 /// Legacy Android float size (52). Bottom-tab create uses `bottomCreateButtonSize` instead /// so the circle sits fully under the separator without straddling the top border. static let floatingCreateButtonSize: CGFloat = 52 /// Slightly smaller create disc for the bottom tab bar (below separator). static let bottomCreateButtonSize: CGFloat = 44 static let floatingSheetCornerRadius: CGFloat = 10 static let minimumTouchTarget: CGFloat = 44 /// Home-indicator / gesture bar inset — same role as RN `useSafeAreaInsets().bottom`. /// Used so bottom chrome height is `bottomNavigationHeight + bottomSafeInset` (Android parity). static var bottomSafeInset: CGFloat { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } for scene in scenes { if let key = scene.windows.first(where: \.isKeyWindow) { return key.safeAreaInsets.bottom } } return scenes.flatMap(\.windows).first?.safeAreaInsets.bottom ?? 0 } /// Total bottom chrome height including home indicator (Android `height: 52 + safeAreaInsets.bottom`). static var bottomChromeHeight: CGFloat { bottomNavigationHeight + bottomSafeInset } } enum MobileMemoFilterMode: String, CaseIterable, Sendable { case all case tagged case untagged case pinned } /// Matches Android `MobileMemoListDensity`: preview | compact. enum ListDensity: String, CaseIterable, Sendable { case preview case compact var showsExcerpt: Bool { self == .preview } var cardMinHeight: CGFloat { self == .compact ? 84 : 132 } var cardPadding: CGFloat { self == .compact ? 13 : 16 } var cardBottomMargin: CGFloat { self == .compact ? 10 : 12 } var metaTop: CGFloat { self == .compact ? 8 : 20 } } enum MobileUI { /// Toggle filter chip: pressing the active filter returns to `all`. static func toggleMemoFilterMode( current: MobileMemoFilterMode, requested: MobileMemoFilterMode ) -> MobileMemoFilterMode { guard requested != .all else { return .all } return current == requested ? .all : requested } static func toggleMemoSelection(current: Set, memoId: String) -> Set { var next = current if next.contains(memoId) { next.remove(memoId) } else { next.insert(memoId) } return next } static func toggleTagSelection(current: [String], tag: String, maxSelections: Int) -> [String] { guard maxSelections > 0 else { return [] } if let index = current.firstIndex(of: tag) { var next = current next.remove(at: index) return next } if maxSelections == 1 { return [tag] } guard current.count < maxSelections else { return current } return current + [tag] } static func memoHasExactTag(tags: [String], tag: String) -> Bool { let normalized = tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !normalized.isEmpty else { return true } return tags.contains { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized } } } struct NotebookTreeItem: Identifiable, Equatable { var id: String var name: String var parentId: String? var depth: Int var memoCount: Int } enum NotebookHierarchy { /// Port of `getNotebookDescendantIds` from packages/shared notebooks.ts static func descendantIds(notebooks: [Notebook], targetNotebookId: String) -> [String] { var childrenByParent: [String: [String]] = [:] for notebook in notebooks { guard let parentId = notebook.parentId else { continue } childrenByParent[parentId, default: []].append(notebook.id) } var descendantIds: [String] = [] var visited = Set() var pending = [targetNotebookId] while let notebookId = pending.popLast() { if visited.contains(notebookId) { continue } visited.insert(notebookId) descendantIds.append(notebookId) pending.append(contentsOf: childrenByParent[notebookId] ?? []) } return descendantIds } /// Port of `getNotebookScopeIds`: the notebooks a notebook view lists. static func scopeIds(notebooks: [Notebook], targetNotebookId: String, includeDescendants: Bool) -> [String] { includeDescendants ? descendantIds(notebooks: notebooks, targetNotebookId: targetNotebookId) : [targetNotebookId] } /// Port of `getNotebookDescendantMemoCount`: notes in sub-notebooks, not the notebook itself. /// Expects direct per-notebook counts, as `LocalMirrorRepository.listNotebooks` returns them. static func descendantMemoCount(notebooks: [Notebook], targetNotebookId: String) -> Int { let memoCountById = Dictionary(notebooks.map { ($0.id, $0.memoCount) }, uniquingKeysWith: { first, _ in first }) return descendantIds(notebooks: notebooks, targetNotebookId: targetNotebookId) .filter { $0 != targetNotebookId } .reduce(0) { $0 + (memoCountById[$1] ?? 0) } } static func treeItems(from notebooks: [Notebook]) -> [NotebookTreeItem] { let byParent = Dictionary(grouping: notebooks) { $0.parentId ?? "" } var items: [NotebookTreeItem] = [] func walk(parentId: String?, depth: Int) { let key = parentId ?? "" let children = (byParent[key] ?? []).sorted { if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder } return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } for child in children { items.append( NotebookTreeItem( id: child.id, name: child.name, parentId: child.parentId, depth: depth, memoCount: child.memoCount ) ) walk(parentId: child.id, depth: depth + 1) } } walk(parentId: nil, depth: 0) return items } /// Port of `getMobileNotebookSearchVisibleIds` static func searchVisibleIds(notebooks: [Notebook], searchText: String) -> Set { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let byId = Dictionary(uniqueKeysWithValues: notebooks.map { ($0.id, $0) }) if query.isEmpty { return Set(byId.keys) } var childrenByParent: [String: [String]] = [:] for notebook in notebooks { guard let parentId = notebook.parentId, byId[parentId] != nil else { continue } childrenByParent[parentId, default: []].append(notebook.id) } var visible = Set() func includeDescendants(_ id: String, visited: inout Set) { if visited.contains(id) { return } visited.insert(id) visible.insert(id) for child in childrenByParent[id] ?? [] { includeDescendants(child, visited: &visited) } } for notebook in notebooks where notebook.name.lowercased().contains(query) { var visited = Set() includeDescendants(notebook.id, visited: &visited) var parentId = notebook.parentId var seen: Set = [notebook.id] while let pid = parentId, !seen.contains(pid) { seen.insert(pid) visible.insert(pid) parentId = byId[pid]?.parentId } } return visible } /// Parent ids that have at least one child (collapse targets). static func parentIdsWithChildren(from notebooks: [Notebook]) -> Set { var parents = Set() let ids = Set(notebooks.map(\.id)) for notebook in notebooks { if let parentId = notebook.parentId, ids.contains(parentId) { parents.insert(parentId) } } return parents } /// Ancestors of `notebookId` (not including itself). static func ancestorIds(notebooks: [Notebook], notebookId: String) -> Set { let byId = Dictionary(uniqueKeysWithValues: notebooks.map { ($0.id, $0) }) var ancestors = Set() var current = byId[notebookId] while let parentId = current?.parentId { ancestors.insert(parentId) current = byId[parentId] } return ancestors } /// Port of `filterCollapsedNotebookOptions`. static func filterCollapsed(items: [NotebookTreeItem], collapsedIds: Set) -> [NotebookTreeItem] { if collapsedIds.isEmpty { return items } var visible: [NotebookTreeItem] = [] var hiddenDepth: Int? for item in items { if let hiddenDepth, item.depth > hiddenDepth { continue } hiddenDepth = nil visible.append(item) if collapsedIds.contains(item.id) { hiddenDepth = item.depth } } return visible } } enum MemoPreviewDate { /// Port of Android `formatMemoPreviewDate`: today → time, yesterday → 昨天, else y/m/d. static func format(_ iso: String, locale: Locale = .current, isEnglish: Bool = false, language: AppUILanguage? = nil) -> String { let parsers = [ISO8601DateFormatter.edgeEver, ISO8601DateFormatter.edgeEverFallback] guard let date = parsers.compactMap({ $0.date(from: iso) }).first else { return "" } let calendar = Calendar.current let now = Date() if calendar.isDateInToday(date) { let f = DateFormatter() f.locale = locale f.dateStyle = .none f.timeStyle = .short return f.string(from: date) } if calendar.isDateInYesterday(date) { switch language ?? (isEnglish ? .english : .chinese) { case .japanese: return "昨日" case .polish: return "Wczoraj" case .english: return "Yesterday" case .chinese: return "昨天" } } let f = DateFormatter() f.locale = locale f.setLocalizedDateFormatFromTemplate("yMd") return f.string(from: date) } } enum MemoListTimestampField { case createdAt case updatedAt static func resolve(for sort: MemoSortMode) -> Self { sort == .createdDesc ? .createdAt : .updatedAt } func value(from memo: MemoSummary) -> String { switch self { case .createdAt: memo.createdAt case .updatedAt: memo.updatedAt } } } enum MemoDetailDate { static func format( _ iso: String, locale: Locale = .current, timeZone: TimeZone = .current ) -> String { let parsers = [ISO8601DateFormatter.edgeEver, ISO8601DateFormatter.edgeEverFallback] guard let date = parsers.compactMap({ $0.date(from: iso) }).first else { return "" } let formatter = DateFormatter() formatter.locale = locale formatter.timeZone = timeZone formatter.dateStyle = .medium formatter.timeStyle = .short return formatter.string(from: date) } }