diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index b34a4c8..f1cba3e 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -118,7 +118,7 @@ enum L: CaseIterable { case langFollow // 设置 —— 主界面 - case showRecentsTitle, showRecentsDesc, resetWindowTitle + case showRecentsTitle, showRecentsDesc, resetWindowTitle, resetWindowDesc // 设置 —— 更新 case autoUpdate, autoUpdateDescOn, autoUpdateDescOff @@ -286,6 +286,7 @@ enum L: CaseIterable { case .showRecentsTitle: return ("展示最近分享", "Show Recent Shares") case .showRecentsDesc: return ("关闭后主界面不再列出最近分享", "When off, the main screen won't list recent shares") case .resetWindowTitle: return ("恢复默认窗口尺寸", "Reset Window Size") + case .resetWindowDesc: return ("把窗口还原成默认大小", "Restore the window to its default size") case .autoUpdate: return ("自动更新", "Automatic Updates") case .autoUpdateDescOn: return ("关闭后不自动更新、不弹提示;仍可在菜单「检查更新…」手动检查", diff --git a/Sources/LocalShare/SettingsScreen.swift b/Sources/LocalShare/SettingsScreen.swift index d950971..fb61b69 100644 --- a/Sources/LocalShare/SettingsScreen.swift +++ b/Sources/LocalShare/SettingsScreen.swift @@ -26,109 +26,112 @@ struct SettingsScreen: View { } content: { VStack(alignment: .leading, spacing: 0) { // MARK: 网络(监听端口 + 可见范围) - SectionLabel(t: t, text: L.sectionNetwork(lang)).padding(.bottom, 8) - - // 监听端口:IP 前缀 + 端口输入框 + 实时可用性校验。 - HStack(spacing: 10) { - Text("\(state.selectedInterface?.ip ?? L.thisMachine(lang)) :").font(.mono(14)).foregroundStyle(t.inkMute) - TextField("", text: $portText) - .textFieldStyle(.plain) - .font(.mono(15, .bold)).foregroundStyle(t.ink) - .frame(width: 72) - .padding(.horizontal, 10).padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(t.field)) - .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(pv.state == .ok ? t.lineStrong : pColor, lineWidth: 1.5)) - .onChange(of: portText) { portText = String($0.filter(\.isNumber).prefix(5)) } - .onSubmit { apply(pv, changed: changed) } - Spacer() - HStack(spacing: 5) { - Image(systemName: pv.state == .ok ? "checkmark" : "questionmark.circle") - .font(.system(size: 13, weight: .bold)) - Text(pv.state == .ok ? L.portOk(lang) : (pv.state == .occupied ? L.portOccupied(lang) : L.portInvalid(lang))) - .font(.sans(11.5, .bold)) - } - .foregroundStyle(pColor) - } - .padding(.leading, 14).padding(.trailing, 10).padding(.vertical, 10) - .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(t.surface)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(pv.state == .ok ? t.line : pColor, lineWidth: 1)) - - HStack(alignment: .top, spacing: 8) { - Text(pv.state == .ok ? L.portOkHint(lang) : pv.message) - .font(.sans(11.5, pv.state == .ok ? .regular : .semibold)) - .foregroundStyle(pv.state == .ok ? t.inkMute : pColor) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 4) - if let s = pv.suggest { - Button { portText = String(s) } label: { - Text(LStr.changeToPort(s, lang)).font(.sans(11.5, .bold)).foregroundStyle(t.accent) - .padding(.horizontal, 10).frame(height: 24) - .background(Capsule().fill(t.accentSoft)) - }.buttonStyle(.plain) - } - } - .padding(.top, 8) - - // 改了才出现这排操作:放弃(还原成当前生效端口,无效输入也可退回)+ 应用。 - // 用纯色文字而非实心全宽块——重启服务不是破坏性动作,不必视觉吓人。 - if changed { - HStack(spacing: 18) { - Spacer() - Button { portText = String(state.configuredPort) } label: { + eyebrow(L.sectionNetwork(lang), first: true) + groupBox { + // 端口编辑单元:IP 前缀 + 端口框 + 实时校验 +(改动时)放弃/应用,整体作卡内首格。 + // 不再单独套一层 surface 卡(外层 groupBox 已提供卡面);校验态由输入框边框 + 下方提示色承载。 + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Text("\(state.selectedInterface?.ip ?? L.thisMachine(lang)) :").font(.mono(14)).foregroundStyle(t.inkMute) + TextField("", text: $portText) + .textFieldStyle(.plain) + .font(.mono(15, .bold)).foregroundStyle(t.ink) + .frame(width: 72) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(t.field)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(pv.state == .ok ? t.lineStrong : pColor, lineWidth: 1.5)) + .onChange(of: portText) { portText = String($0.filter(\.isNumber).prefix(5)) } + .onSubmit { apply(pv, changed: changed) } + Spacer() HStack(spacing: 5) { - Image(systemName: "arrow.uturn.backward").font(.system(size: 12, weight: .semibold)) - Text(L.discardChanges(lang)).font(.sans(13, .semibold)) + Image(systemName: pv.state == .ok ? "checkmark" : "questionmark.circle") + .font(.system(size: 13, weight: .bold)) + Text(pv.state == .ok ? L.portOk(lang) : (pv.state == .occupied ? L.portOccupied(lang) : L.portInvalid(lang))) + .font(.sans(11.5, .bold)) } - .foregroundStyle(t.inkMute) + .foregroundStyle(pColor) } - .buttonStyle(.plain) - if pv.state != .invalid { - Button { apply(pv, changed: changed) } label: { - Text(L.applyRestart(lang)).font(.sans(13, .semibold)).foregroundStyle(t.accent) + + HStack(alignment: .top, spacing: 8) { + Text(pv.state == .ok ? L.portOkHint(lang) : pv.message) + .font(.sans(11.5, pv.state == .ok ? .regular : .semibold)) + .foregroundStyle(pv.state == .ok ? t.inkMute : pColor) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + if let s = pv.suggest { + Button { portText = String(s) } label: { + Text(LStr.changeToPort(s, lang)).font(.sans(11.5, .bold)).foregroundStyle(t.accent) + .padding(.horizontal, 10).frame(height: 24) + .background(Capsule().fill(t.accentSoft)) + }.buttonStyle(.plain) } - .buttonStyle(.plain) + } + .padding(.top, 8) + + // 改了才出现这排操作:放弃(还原成当前生效端口,无效输入也可退回)+ 应用。 + // 用纯色文字而非实心全宽块——重启服务不是破坏性动作,不必视觉吓人。 + if changed { + HStack(spacing: 18) { + Spacer() + Button { portText = String(state.configuredPort) } label: { + HStack(spacing: 5) { + Image(systemName: "arrow.uturn.backward").font(.system(size: 12, weight: .semibold)) + Text(L.discardChanges(lang)).font(.sans(13, .semibold)) + } + .foregroundStyle(t.inkMute) + } + .buttonStyle(.plain) + if pv.state != .invalid { + Button { apply(pv, changed: changed) } label: { + Text(L.applyRestart(lang)).font(.sans(13, .semibold)).foregroundStyle(t.accent) + } + .buttonStyle(.plain) + } + } + .padding(.top, 12) } } - .padding(.top, 12) - } + .padding(.vertical, 12) - // 仅当前网络可见:同属网络设置,紧随端口、以分隔线归组。只有同时连了多个网络时才有意义, - // 故描述按是否多网卡分两种措辞,避免单网卡时给出空泛的“其它网络”字样。 - settingRow(top: true, title: L.bindOnlyTitle(lang), - desc: state.interfaces.count > 1 - ? L.bindOnlyDescMulti(lang) - : L.bindOnlyDescSingle(lang)) { - ToggleSwitch(t: t, isOn: state.bindSelectedOnly) { state.setBindSelectedOnly(!state.bindSelectedOnly) } + // 仅当前网络可见:同属网络设置,紧随端口、以分隔线归组。只有同时连了多个网络时才有意义, + // 故描述按是否多网卡分两种措辞,避免单网卡时给出空泛的“其它网络”字样。 + settingRow(top: true, title: L.bindOnlyTitle(lang), + desc: state.interfaces.count > 1 + ? L.bindOnlyDescMulti(lang) + : L.bindOnlyDescSingle(lang)) { + ToggleSwitch(t: t, isOn: state.bindSelectedOnly) { state.setBindSelectedOnly(!state.bindSelectedOnly) } + } } - .padding(.top, 14) - // MARK: 访问权限 - HStack { + // MARK: 访问权限(眼标右侧挂「当前权限」胶囊) + HStack(spacing: 8) { SectionLabel(t: t, text: L.sectionPermission(lang)) - Spacer() + Spacer(minLength: 8) Text("\(L.currentColon(lang))\(ps.tag)").font(.sans(11, .bold)) .foregroundStyle(ps.writable ? t.accent : t.inkMute) .padding(.horizontal, 9).padding(.vertical, 2) .background(Capsule().fill(ps.writable ? t.accentSoft : .clear)) .overlay(Capsule().strokeBorder(ps.writable ? .clear : t.line, lineWidth: 1)) } - .padding(.top, 24).padding(.bottom, 4) + .padding(.top, 22).padding(.bottom, 7).padding(.horizontal, 2) - permRow(name: L.permReadName(lang), desc: L.permReadDesc(lang), locked: true, on: true) - permRow(name: L.permUploadName(lang), - desc: state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang), - locked: !state.canToggleUpload, - on: state.permission.add, top: true) { - state.setUploadAllowed(!state.permission.add) - } - // 收文本:独立闸门,不限分享形态(甚至什么都没分享也能开),故不随 share 置灰。 - permRow(name: L.recvInboxTitle(lang), desc: L.recvInboxDesc(lang), - locked: false, on: state.textInboxEnabled, top: true) { - state.setTextInboxEnabled(!state.textInboxEnabled) + groupBox { + permRow(name: L.permReadName(lang), desc: L.permReadDesc(lang), locked: true, on: true) + permRow(name: L.permUploadName(lang), + desc: state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang), + locked: !state.canToggleUpload, + on: state.permission.add, top: true) { + state.setUploadAllowed(!state.permission.add) + } + // 收文本:独立闸门,不限分享形态(甚至什么都没分享也能开),故不随 share 置灰。 + permRow(name: L.recvInboxTitle(lang), desc: L.recvInboxDesc(lang), + locked: false, on: state.textInboxEnabled, top: true) { + state.setTextInboxEnabled(!state.textInboxEnabled) + } } + // 权限级别说明 + 明文传输提示:作小节脚注列在卡片下方(非卡内行),与 macOS 分组表的页脚同姿态。 HStack(alignment: .top, spacing: 8) { Image(systemName: "info.circle").font(.system(size: 14)).foregroundStyle(t.accent).padding(.top, 1) Text(ps.writable ? L.permInfoWritable(lang) : L.permInfoReadonly(lang)) @@ -149,7 +152,8 @@ struct SettingsScreen: View { .padding(.top, 12) // MARK: 外观 - SectionLabel(t: t, text: L.sectionAppearance(lang)).padding(.top, 24).padding(.bottom, 8) + // 分段选择器本身即成组控件,自带分组感——不再套 groupBox(盒中盒),眼标下直接放分段条。 + eyebrow(L.sectionAppearance(lang)) HStack(spacing: 8) { appearanceSeg(L.appearanceFollow(lang), .system) appearanceSeg(L.appearanceLight(lang), .light) @@ -157,7 +161,7 @@ struct SettingsScreen: View { } // MARK: 语言 - SectionLabel(t: t, text: L.sectionLanguage(lang)).padding(.top, 24).padding(.bottom, 8) + eyebrow(L.sectionLanguage(lang)) HStack(spacing: 8) { langSeg(L.langFollow(lang), .system) langSeg("中文", .zh) // 语言名用本族文字,不翻译 @@ -165,19 +169,21 @@ struct SettingsScreen: View { } // MARK: 主界面(最近分享展示 + 窗口尺寸) - SectionLabel(t: t, text: L.sectionMain(lang)).padding(.top, 24).padding(.bottom, 4) - settingRow(title: L.showRecentsTitle(lang), desc: L.showRecentsDesc(lang)) { - ToggleSwitch(t: t, isOn: state.showRecents) { state.setShowRecents(!state.showRecents) } - } - settingRow(top: true, title: L.rememberTextTitle(lang), desc: L.rememberTextDesc(lang)) { - ToggleSwitch(t: t, isOn: state.persistText) { state.setPersistText(!state.persistText) } - } - settingRow(top: true, title: L.persistRecvTitle(lang), desc: L.persistRecvDesc(lang)) { - ToggleSwitch(t: t, isOn: state.persistReceivedText) { state.setPersistReceivedText(!state.persistReceivedText) } - } - settingRow(top: true, title: L.resetWindowTitle(lang)) { - GhostButton(t: t, title: L.resetDefault(lang), systemImage: "arrow.counterclockwise") { - state.resetWindowSize() + eyebrow(L.sectionMain(lang)) + groupBox { + settingRow(title: L.showRecentsTitle(lang), desc: L.showRecentsDesc(lang)) { + ToggleSwitch(t: t, isOn: state.showRecents) { state.setShowRecents(!state.showRecents) } + } + settingRow(top: true, title: L.rememberTextTitle(lang), desc: L.rememberTextDesc(lang)) { + ToggleSwitch(t: t, isOn: state.persistText) { state.setPersistText(!state.persistText) } + } + settingRow(top: true, title: L.persistRecvTitle(lang), desc: L.persistRecvDesc(lang)) { + ToggleSwitch(t: t, isOn: state.persistReceivedText) { state.setPersistReceivedText(!state.persistReceivedText) } + } + settingRow(top: true, title: L.resetWindowTitle(lang), desc: L.resetWindowDesc(lang)) { + GhostButton(t: t, title: L.resetDefault(lang), systemImage: "arrow.counterclockwise") { + state.resetWindowSize() + } } } @@ -185,48 +191,52 @@ struct SettingsScreen: View { // 始终展示这一组:开关留在设置里,用户才能确认「自动更新」这个功能确实存在。 // dev / 未签名构建里 updater 未启动(占位 EdDSA 公钥),此时只把开关置灰、并改说明文案 // 点明原因——是「此构建未启用」而非把整段藏掉。isActive 只决定可用态,不决定是否渲染。 - SectionLabel(t: t, text: L.sectionUpdate(lang)).padding(.top, 24).padding(.bottom, 4) - settingRow(title: L.autoUpdate(lang), - desc: updater.isActive - ? L.autoUpdateDescOn(lang) - : L.autoUpdateDescOff(lang)) { - ToggleSwitch(t: t, isOn: updater.automaticChecks, locked: !updater.isActive) { - updater.setAutomaticChecks(!updater.automaticChecks) + eyebrow(L.sectionUpdate(lang)) + groupBox { + settingRow(title: L.autoUpdate(lang), + desc: updater.isActive + ? L.autoUpdateDescOn(lang) + : L.autoUpdateDescOff(lang)) { + ToggleSwitch(t: t, isOn: updater.automaticChecks, locked: !updater.isActive) { + updater.setAutomaticChecks(!updater.automaticChecks) + } } } // MARK: 命令行工具 // 裸二进制(swift run)没有 .app 可指:不给安装按钮,状态/卸载照常。 - SectionLabel(t: t, text: L.sectionCLI(lang)).padding(.top, 24).padding(.bottom, 8) - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text("localshare").font(.mono(13.5, .bold)).foregroundStyle(t.ink) - Text(cliHint).font(.sans(11.5)).foregroundStyle(t.inkMute) - .lineLimit(1).truncationMode(.middle) - } - Spacer(minLength: 8) - if state.cliStatus != .notInstalled { - Button { state.uninstallCLI() } label: { - Text(L.uninstall(lang)).font(.sans(13, .semibold)).foregroundStyle(t.inkMute) + eyebrow(L.sectionCLI(lang)) + groupBox { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("localshare").font(.mono(13.5, .bold)).foregroundStyle(t.ink) + Text(cliHint).font(.sans(11.5)).foregroundStyle(t.inkMute) + .lineLimit(1).truncationMode(.middle) } - .buttonStyle(.plain) - .padding(.trailing, 4) - } - if state.cliStatus == .installed { - HStack(spacing: 5) { - Image(systemName: "checkmark").font(.system(size: 13, weight: .bold)) - Text(L.installed(lang)).font(.sans(11.5, .bold)) + Spacer(minLength: 8) + if state.cliStatus != .notInstalled { + Button { state.uninstallCLI() } label: { + Text(L.uninstall(lang)).font(.sans(13, .semibold)).foregroundStyle(t.inkMute) + } + .buttonStyle(.plain) + .padding(.trailing, 4) } - .foregroundStyle(t.ok) - } else if CLIInstaller.binaryPath() != nil { - GhostButton(t: t, - title: state.cliStatus == .notInstalled ? L.install(lang) : L.reinstall(lang), - systemImage: "terminal") { - state.installCLI() + if state.cliStatus == .installed { + HStack(spacing: 5) { + Image(systemName: "checkmark").font(.system(size: 13, weight: .bold)) + Text(L.installed(lang)).font(.sans(11.5, .bold)) + } + .foregroundStyle(t.ok) + } else if CLIInstaller.binaryPath() != nil { + GhostButton(t: t, + title: state.cliStatus == .notInstalled ? L.install(lang) : L.reinstall(lang), + systemImage: "terminal") { + state.installCLI() + } } } + .padding(.vertical, 12) } - .padding(.vertical, 4) } } .onAppear { @@ -248,6 +258,22 @@ struct SettingsScreen: View { } } + // 分组卡:surface 底 + line 描边的圆角容器,把同一小节的行包成一张卡。分组切分由卡片边界承担, + // 眼标(SectionLabel)只负责命名——这样安静的小标签不必再独力分隔七个组,「组标题比组内项小」的倒挂观感随之消除。 + // 组内多行靠 settingRow / permRow 自带的内缩顶分隔线区隔(行宽 = 卡内宽,分隔线天然内缩,不触卡缘)。 + @ViewBuilder private func groupBox(@ViewBuilder content: () -> Content) -> some View { + VStack(spacing: 0, content: content) + .padding(.horizontal, 14) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + // 小节眼标 + 统一上下留白:组间距 22、标签到卡片 7;首组贴页顶不留上间距。 + private func eyebrow(_ text: String, first: Bool = false) -> some View { + SectionLabel(t: t, text: text) + .padding(.top, first ? 0 : 22).padding(.bottom, 7).padding(.horizontal, 2) + } + private func appearanceSeg(_ label: String, _ pref: AppState.AppearancePref) -> some View { let on = state.appearance == pref return Button { state.setAppearance(pref) } label: { @@ -273,8 +299,8 @@ struct SettingsScreen: View { .buttonStyle(.plain) } - // 通用设置行:「标题 +(可选)说明 + 右侧控件」。同一分组内多行靠 top 顶部分隔线对齐, - // 紧贴小节标题的首行不画线(top 默认 false)——分隔线只用来区隔相邻行,不重复标题已有的分隔。 + // 通用设置行:「标题 +(可选)说明 + 右侧控件」。卡内多行靠 top 顶部分隔线区隔, + // 每组首行不画线(top 默认 false)——分隔线只用来区隔相邻行,不与卡片上缘重复。 private func settingRow(top: Bool = false, title: String, desc: String? = nil, @ViewBuilder trailing: () -> Trailing) -> some View { HStack(spacing: 12) {