release(ios): 发布 0.2.1 并重构图标与界面细节

- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
chick
2026-09-14 16:30:39 +08:00
parent 27687868ef
commit 3607eddc9f
34 changed files with 2656 additions and 1213 deletions
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,6 @@ import SwiftUI
enum FleetGroupMode: String, CaseIterable, Identifiable {
case none
case status
case tags
case groups
var id: String { rawValue }
@@ -13,7 +12,6 @@ enum FleetGroupMode: String, CaseIterable, Identifiable {
switch self {
case .none: return "平铺"
case .status: return "状态"
case .tags: return "标签"
case .groups: return "分组"
}
}
@@ -184,12 +182,15 @@ final class FleetViewModel: ObservableObject {
@Published var rows: [Row] = []
@Published var searchText = ""
@Published var statusFilter: ConnectionStatus?
@Published var groupMode: FleetGroupMode = {
let stored = UserDefaults.standard.string(forKey: "fleet.groupMode")
return FleetGroupMode(rawValue: stored ?? "") ?? .groups
}() {
@Published var groupMode: FleetGroupMode = .groups {
didSet {
UserDefaults.standard.set(groupMode.rawValue, forKey: "fleet.groupMode")
preferences.set(groupMode.rawValue, forKey: "fleet.groupMode")
}
}
@Published var selectedTagFilters: Set<String> = [] {
didSet {
let values = Array(selectedTagFilters).sorted()
preferences.set(values, forKey: "fleet.selectedTagFilters")
}
}
@Published var selectedIDs: Set<UUID> = []
@@ -206,19 +207,35 @@ final class FleetViewModel: ObservableObject {
private let credentials: any CredentialStore
private let deviceService: (any DeviceSummaryService)?
private let groupStore: (any InstanceGroupStore)?
private let preferences: UserDefaults
init(
repository: any InstanceRepository,
probe: ConnectionProbe,
credentials: any CredentialStore,
deviceService: (any DeviceSummaryService)? = nil,
groupStore: (any InstanceGroupStore)? = nil
groupStore: (any InstanceGroupStore)? = nil,
preferences: UserDefaults = .standard
) {
self.repository = repository
self.probe = probe
self.credentials = credentials
self.deviceService = deviceService
self.groupStore = groupStore
self.preferences = preferences
// 旧版本的「按标签分组」已废弃,标签现在只作为筛选条件存在。
let storedMode = preferences.string(forKey: "fleet.groupMode")
groupMode = FleetGroupMode(rawValue: storedMode ?? "") ?? .groups
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
}
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
func pruneTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
if live != selectedTagFilters {
selectedTagFilters = live
}
}
func refresh() async {
@@ -243,6 +260,7 @@ final class FleetViewModel: ObservableObject {
} catch {
errorMessage = "无法刷新节点列表"
}
pruneTagFilters()
lastRefresh = Date()
isLoading = false
}
@@ -400,6 +418,13 @@ final class FleetViewModel: ObservableObject {
if let statusFilter, row.status != statusFilter {
return false
}
if selectedTagFilters.isEmpty == false {
let tags = Set(row.instance.tags.map { Self.normalizedTag($0) })
let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) })
guard needles.isSubset(of: tags) else {
return false
}
}
guard !searchText.isEmpty else { return true }
let haystacks: [String] = [
row.instance.name,
@@ -417,6 +442,47 @@ final class FleetViewModel: ObservableObject {
}
}
var allTags: [String] {
Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending }
}
var tagCounts: [String: Int] {
var counts: [String: Int] = [:]
for row in rows {
for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false {
counts[tag, default: 0] += 1
}
}
return counts
}
func countForTagFilter(_ tag: String) -> Int {
tagCounts[Self.normalizedTag(tag)] ?? 0
}
func isTagFilterActive(_ tag: String) -> Bool {
selectedTagFilters.contains(Self.normalizedTag(tag))
}
func toggleTagFilter(_ tag: String) {
let normalized = Self.normalizedTag(tag)
guard normalized.isEmpty == false else { return }
if selectedTagFilters.contains(normalized) {
selectedTagFilters.remove(normalized)
} else {
selectedTagFilters.insert(normalized)
}
}
func clearTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
selectedTagFilters.removeAll()
}
private static func normalizedTag(_ tag: String) -> String {
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
var groupedRows: [FleetGroup] {
let rows = visibleRows.sorted {
$0.instance.name.localizedCompare($1.instance.name) == .orderedAscending
@@ -431,19 +497,6 @@ final class FleetViewModel: ObservableObject {
guard !matched.isEmpty else { return nil }
return FleetGroup(id: status.rawValue, title: status.label, rows: matched)
}
case .tags:
let tags = Array(Set(rows.flatMap(\.instance.tags))).sorted()
let grouped = tags.map { tag in
FleetGroup(
id: tag,
title: tag,
rows: rows.filter { $0.instance.tags.contains(tag) }
)
}
let untagged = rows.filter { $0.instance.tags.isEmpty }
return grouped + (untagged.isEmpty
? []
: [FleetGroup(id: "__untagged__", title: "未打标签", rows: untagged)])
case .groups:
var groupedRows: [UUID: [Row]] = [:]
var ungroupedRows: [Row] = []
@@ -6,7 +6,8 @@ struct InstanceFormView: View {
let instance: Instance?
@State private var name = ""
@State private var originText = ""
@State private var tagsText = ""
@State private var tags: [String] = []
@State private var tagDraft = ""
@State private var password = ""
@State private var selectedGroupId: UUID?
@State private var validationMessage: String?
@@ -17,7 +18,7 @@ struct InstanceFormView: View {
self.instance = instance
_name = State(initialValue: instance?.name ?? "")
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
_tagsText = State(initialValue: instance?.tags.joined(separator: ", ") ?? "")
_tags = State(initialValue: instance?.tags ?? [])
_selectedGroupId = State(initialValue: instance?.groupId)
}
@@ -34,12 +35,12 @@ struct InstanceFormView: View {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
tagEditor
groupPicker
field(
"实例密码",
text: $password,
placeholder: instance == nil ? "可选,保存在本机沙盒" : "留空则不修改密码",
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
secure: true
)
}
@@ -104,6 +105,14 @@ struct InstanceFormView: View {
.accessibilityElement(children: .contain)
}
private var tagEditor: some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("标签")
TagEditorView(tags: $tags, suggestions: model.allTags)
}
.accessibilityElement(children: .contain)
}
private func field(
_ title: String,
text: Binding<String>,
@@ -121,12 +130,12 @@ struct InstanceFormView: View {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel(title)
.submitLabel(title == "地址" ? .next : .done)
.submitLabel(.done)
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled(title == "地址" || title == "标签")
.autocorrectionDisabled()
}
}
}
@@ -143,11 +152,6 @@ struct InstanceFormView: View {
isSaving = false
return
}
let tags =
tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: normalizedName,
origin: origin,
@@ -1,18 +1,35 @@
import SwiftUI
/// 批量归类:点一个分组就把目标实例移进去并立即生效,不再需要二次“保存”。
struct InstanceGroupEditorView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
let targetIDs: [UUID]
let onSaved: () -> Void
@State private var selectedGroupID: UUID?
@State private var isCreatingGroup = false
@State private var newGroupName = ""
@State private var isWorking = false
@State private var errorMessage: String?
private var selectedCount: Int { targetIDs.count }
private var targets: [UUID] {
targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
}
private var targetGroupIDs: [UUID?] {
let owner = Dictionary(model.rows.map { ($0.id, $0.instance.groupId) }, uniquingKeysWith: { a, _ in a })
return targets.compactMap { owner[$0] }
}
/// 目标实例当前是否同属一个分组;nil 表示 mixed,不显示对勾。
private var uniformGroup: UUID?? {
guard let first = targetGroupIDs.first else { return nil }
return targetGroupIDs.allSatisfy { $0 == first } ? .some(first) : nil
}
private var trimmedName: String {
newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationStack {
@@ -26,56 +43,63 @@ struct InstanceGroupEditorView: View {
)
}
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle(
"批量归类",
systemImage: "folder.badge.gearshape",
detail: selectedCount == 0 ? "全部实例" : "\(selectedCount) 个实例"
)
Text("选择一个分组后保存,所选实例会立即移入该分组。")
.font(.footnote)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
AISectionTitle(
"移动到",
systemImage: "folder",
detail: "\(targets.count) 台实例"
)
if isCreatingGroup {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 12) {
AIFieldLabel("新分组名称")
TextField("例如:家里、外场、测试机", text: $newGroupName)
.textFieldStyle(AIFieldStyle())
.submitLabel(.done)
}
}
} else {
AICard(padding: 8) {
VStack(spacing: 2) {
groupRow(
title: "未分组",
subtitle: "移出当前分组",
systemImage: "square.dashed",
value: nil,
isSelected: selectedGroupID == nil,
selectionTrait: selectedGroupID == nil
)
.onSubmit { Task { await createAndAssign() } }
AIActionRow(spacing: 8) {
Button("取消") {
isCreatingGroup = false
newGroupName = ""
errorMessage = nil
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.disabled(isWorking)
ForEach(model.groups) { group in
Divider()
.overlay(AITheme.line.opacity(0.55))
groupRow(
title: group.name,
subtitle: "\(instanceCount(group.id)) 个实例",
systemImage: "folder",
value: group.id,
isSelected: selectedGroupID == group.id,
selectionTrait: selectedGroupID == group.id
)
Button {
Task { await createAndAssign() }
} label: {
Label("创建并移入", systemImage: "folder.badge.plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(trimmedName.isEmpty || isWorking)
}
}
}
}
AICard(padding: 8) {
VStack(spacing: 2) {
groupRow(
title: "未分组",
subtitle: "不归属任何分组",
systemImage: "square.dashed",
groupID: nil
)
ForEach(model.groups) { group in
Divider()
.overlay(AITheme.line.opacity(0.55))
groupRow(
title: group.name,
subtitle: "\(instanceCount(group.id)) 台在此",
systemImage: "folder",
groupID: group.id
)
}
}
}
.opacity(isWorking ? 0.55 : 1)
}
.padding(16)
}
@@ -89,28 +113,13 @@ struct InstanceGroupEditorView: View {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button("新建") {
selectedGroupID = nil
isCreatingGroup = true
AIIconButton("新建分组", systemImage: "plus") {
errorMessage = nil
isCreatingGroup = true
}
.disabled(isWorking)
}
}
.aiBottomActionBar {
AIActionRow {
Button {
Task { await save() }
} label: {
Label(
selectedGroupID == nil ? "移出分组" : "移入分组",
systemImage: "tray.and.arrow.down"
)
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(isWorking || (selectedGroupID == nil && !isCreatingGroup))
}
}
}
}
@@ -118,23 +127,27 @@ struct InstanceGroupEditorView: View {
title: String,
subtitle: String,
systemImage: String,
value: UUID?,
isSelected: Bool,
selectionTrait: Bool
groupID: UUID?
) -> some View {
Button {
selectedGroupID = value
isCreatingGroup = false
newGroupName = ""
errorMessage = nil
guard uniformGroup != groupID else {
dismiss()
return
}
Task { await apply(groupID: groupID) }
} label: {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted)
.frame(width: 32, height: 32)
.background((isSelected ? AITheme.primary : AITheme.muted).opacity(0.10))
.background((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
// 深色下 10% 色洗几乎看不出边界,补一圈同色淡描边保住方块轮廓。
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.20))
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
@@ -150,56 +163,64 @@ struct InstanceGroupEditorView: View {
Spacer(minLength: 8)
Image(
systemName: isSelected
? "checkmark.circle.fill"
: "circle.dashed"
)
.font(.system(size: 18))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
Group {
if isWorking {
EmptyView()
} else if isCurrent(groupID) {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AITheme.primary)
}
}
.frame(width: 20, alignment: .trailing)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minHeight: 54)
.background(isSelected ? AITheme.primary.opacity(0.08) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title),\(subtitle)")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
.accessibilityLabel(isCurrent(groupID) ? "\(title),当前分组" : "移动到\(title)")
.accessibilityAddTraits(.isButton)
}
.buttonStyle(.plain)
.disabled(isWorking)
}
private func isCurrent(_ groupID: UUID?) -> Bool {
uniformGroup == groupID && targetGroupIDs.isEmpty == false
}
private func instanceCount(_ id: UUID) -> Int {
model.rows.filter { $0.instance.groupId == id }.count
}
private func save() async {
private func apply(groupID: UUID?) async {
guard !isWorking else { return }
isWorking = true
errorMessage = nil
do {
if isCreatingGroup {
let name = newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
let group = try await model.createGroup(named: name)
selectedGroupID = group.id
isCreatingGroup = false
newGroupName = ""
}
try await model.assign(
groupID: selectedGroupID,
to: targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
)
try await model.assign(groupID: groupID, to: targets)
onSaved()
dismiss()
} catch {
errorMessage = isCreatingGroup
? "创建分组失败,名称不能为空且不能重复。"
: "保存分组失败,请检查节点后重试。"
errorMessage = "分组保存失败,请重试。"
isWorking = false
}
}
private func createAndAssign() async {
guard !isWorking, trimmedName.isEmpty == false else { return }
isWorking = true
errorMessage = nil
do {
let group = try await model.createGroup(named: trimmedName)
try await model.assign(groupID: group.id, to: targets)
onSaved()
dismiss()
} catch {
errorMessage = "创建分组失败,名称不能为空且不能重复。"
isWorking = false
}
isWorking = false
}
}
@@ -8,6 +8,7 @@ struct InstanceGroupManagerView: View {
@State private var editingName = ""
@State private var validationMessage: String?
@State private var isWorking = false
@State private var pendingDelete: InstanceGroup?
var body: some View {
NavigationStack {
@@ -62,24 +63,16 @@ struct InstanceGroupManagerView: View {
Spacer()
Button {
AISymbolAction("重命名分组 \(group.name)", systemImage: "pencil", tint: AITheme.muted) {
editingGroup = group
editingName = group.name
validationMessage = nil
} label: {
Image(systemName: "pencil")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 44))
Button {
Task {
await delete(group)
}
} label: {
Image(systemName: "trash")
AISymbolAction("删除分组 \(group.name)", systemImage: "trash", tint: AITheme.danger) {
pendingDelete = group
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .toolbarInline, fixedWidth: 44))
.disabled(instanceCount(group.id) > 0 || isWorking)
.disabled(isWorking)
}
}
}
@@ -124,6 +117,23 @@ struct InstanceGroupManagerView: View {
} message: {
Text("分组名称最多 100 个字符。")
}
.confirmationDialog(
pendingDelete == nil
? "删除分组?"
: "删除「\(pendingDelete!.name)」?该分组下的 \(instanceCount(pendingDelete!.id)) 个实例会回到未分组。",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
titleVisibility: .visible
) {
if let group = pendingDelete {
Button("删除分组", role: .destructive) {
Task { await delete(group) }
}
}
Button("取消", role: .cancel) {}
}
.task {
await model.loadGroups()
}
@@ -163,6 +173,7 @@ struct InstanceGroupManagerView: View {
private func delete(_ group: InstanceGroup) async {
guard !isWorking else { return }
isWorking = true
pendingDelete = nil
do {
try await model.deleteGroup(group.id)
} catch {
@@ -0,0 +1,160 @@
import SwiftUI
/// 标签编辑器:已选标签用可删除胶囊展示,历史标签可点选复用。
struct TagEditorView: View {
@Binding var tags: [String]
let suggestions: [String]
@State private var draft = ""
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
TextField("新标签,回车添加", text: $draft)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
#endif
.onSubmit { commit(draft) }
Button {
commit(draft)
} label: {
Image(systemName: "plus")
.font(.system(size: 15, weight: .semibold))
.frame(width: 44, height: 44)
.foregroundStyle(canAdd ? AITheme.primary : AITheme.muted)
.background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.disabled(canAdd == false)
.accessibilityLabel("添加标签")
}
if tags.isEmpty == false || suggestions.isEmpty == false {
// 已选与可选分成两条横向胶囊带:自定义流式布局在弹层里拿到的宽度提案不稳定,
// 会出现高度算少、胶囊压到下一个字段的问题,横向滚动条带既稳又和节点页一致。
if tags.isEmpty == false {
chipStrip(ids: tags.map { "sel-\($0)" }) { index in
removableTag(tags[index])
}
}
if availableSuggestions.isEmpty == false {
chipStrip(ids: availableSuggestions.map { "sug-\($0)" }) { index in
suggestionTag(availableSuggestions[index])
}
}
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("标签")
}
private func chipStrip<Item: View>(
ids: [String],
@ViewBuilder content: @escaping (Int) -> Item
) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(ids.enumerated()), id: \.element) { index, _ in
content(index)
}
}
.padding(.vertical, 2)
}
.accessibilityHidden(false)
}
private var trimmedDraft: String {
draft.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canAdd: Bool {
trimmedDraft.isEmpty == false
}
private var availableSuggestions: [String] {
let taken = Set(tags.map(Self.key))
return suggestions
.filter { taken.contains(Self.key($0)) == false }
.prefix(10)
.map { $0 }
}
private func removableTag(_ tag: String) -> some View {
Button {
tags.removeAll { Self.key($0) == Self.key(tag) }
} label: {
HStack(spacing: 5) {
Text(tag)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.opacity(0.65)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.ink)
.background(AITheme.control)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.line)
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("移除标签 \(tag)")
}
private func suggestionTag(_ tag: String) -> some View {
Button {
add(tag)
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
.font(.system(size: 10, weight: .bold))
Text(tag)
.font(.footnote.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.primary)
.background(AITheme.primary.opacity(0.09))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.primary.opacity(0.24))
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("使用已有标签 \(tag)")
}
private func commit(_ raw: String) {
let parts = raw
.split(whereSeparator: { $0 == "," || $0 == "," || $0 == "\n" })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
parts.forEach(add)
draft = ""
}
private func add(_ tag: String) {
let value = tag.trimmingCharacters(in: .whitespacesAndNewlines)
guard value.isEmpty == false else { return }
guard tags.contains(where: { Self.key($0) == Self.key(value) }) == false else { return }
tags.append(value)
}
private static func key(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}