From 89f712367f1344184ddec731052df71fdb702ebb Mon Sep 17 00:00:00 2001 From: Near Date: Mon, 9 Nov 2026 01:31:43 +0800 Subject: [PATCH] Detail pages: dedicated dishwasher kitchen form art (Fixes #100) Kitchen dishwasher models mapped to .kitchen were rendered with CookerArt. Add DeviceForm.dishwasher, DishwasherArt, and DishwasherDetail so dishwashers get their own form, art, and detail page. --- Sources/MijiaBarApp/Art.swift | 44 ++++++++++++++++++++ Sources/MijiaBarApp/Snapshot.swift | 10 +++++ Sources/MijiaBarApp/Theme.swift | 7 +++- Sources/MijiaBarApp/TypeDetails.swift | 33 ++++++++++++++- Sources/MijiaCore/Translations.swift | 2 + Tests/MijiaBarAppTests/TypeDetailTests.swift | 39 ++++++++++++++++- docs/DESIGN.md | 4 +- i18n/zh-Hant.tsv | 2 + 8 files changed, 133 insertions(+), 8 deletions(-) diff --git a/Sources/MijiaBarApp/Art.swift b/Sources/MijiaBarApp/Art.swift index 6da0a0f..221d937 100644 --- a/Sources/MijiaBarApp/Art.swift +++ b/Sources/MijiaBarApp/Art.swift @@ -554,6 +554,50 @@ struct CookerArt: View { } } +struct DishwasherArt: View { + let washing: Bool, tint: Color + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { + PowerTimeline(on: washing, running: true) { time, level in + ZStack { + Ambient(tint, level * 0.6, width: 170, height: 116).position(x: 90, y: 76) + RoundedRectangle(cornerRadius: 9, style: .continuous).appliance(tint, level) + .frame(width: 112, height: 118).position(x: 90, y: 70) + RoundedRectangle(cornerRadius: 4).fill(darkGlass) + .frame(width: 100, height: 17).position(x: 90, y: 24) + HStack(spacing: 5) { + Circle().fill(aluminum).frame(width: 5, height: 5) + Capsule().fill(tint).frame(width: 22, height: 3).opacity(0.15 + level * 0.85) + Spacer() + Circle().fill(tint).frame(width: 3, height: 3).opacity(0.15 + level * 0.85) + } + .frame(width: 80).position(x: 90, y: 24) + RoundedRectangle(cornerRadius: 6).fill(aluminum) + .frame(width: 100, height: 84).position(x: 90, y: 78) + RoundedRectangle(cornerRadius: 4).fill(darkGlass) + .overlay { + ZStack { + Rectangle().fill(LinearGradient(colors: [tint.opacity(0.06), tint.opacity(0.45)], startPoint: .top, endPoint: .bottom)).opacity(level) + ForEach(0..<8) { index in + let phase = (time * (reduceMotion ? 0 : 0.45) + Double(index) / 8).truncatingRemainder(dividingBy: 1) + Capsule().fill(tint.opacity(0.8)).frame(width: 2, height: 5) + .offset(x: CGFloat(index % 4) * 18 - 27, y: CGFloat(phase) * 48 - 24) + .opacity(level * sin(phase * .pi)) + } + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .overlay(RoundedRectangle(cornerRadius: 4).stroke(.white.opacity(0.18), lineWidth: 0.5)) + .frame(width: 82, height: 58).position(x: 90, y: 82) + Capsule().fill(aluminum).frame(width: 56, height: 4).position(x: 90, y: 44) + RoundedRectangle(cornerRadius: 2).fill(darkGlass) + .frame(width: 92, height: 4).position(x: 90, y: 124) + } + .frame(width: 180, height: 136) + } + } +} + struct KettleArt: View { let boiling: Bool, tint: Color @Environment(\.accessibilityReduceMotion) private var reduceMotion diff --git a/Sources/MijiaBarApp/Snapshot.swift b/Sources/MijiaBarApp/Snapshot.swift index 8956588..d9b5789 100644 --- a/Sources/MijiaBarApp/Snapshot.swift +++ b/Sources/MijiaBarApp/Snapshot.swift @@ -43,6 +43,7 @@ extension AppModel { dev("d26", L("人体传感器", "Motion sensor"), "lumi.sensor_motion.demo", L("客厅", "Living room"), home: "h2"), dev("d27", L("打印机", "Printer"), "hannto.printer.demo", L("书房", "Study"), home: "h2"), dev("d28", L("电热水壶", "Electric kettle"), "yunmi.kettle.demo", L("厨房", "Kitchen"), home: "h2"), + dev("d29", L("洗碗机", "Dishwasher"), "viomi.dishwasher.demo", L("厨房", "Kitchen"), home: "h2"), ] prefs.devices["d1"] = { var p = DevicePrefs(); p.favorite = true; return p }() prefs.devices["d2"] = { var p = DevicePrefs(); p.favorite = true; return p }() @@ -177,6 +178,11 @@ extension AppModel { P(2, 2, "temperature", "uint8", ["read"], unit: "celsius"), P(2, 3, "target-temperature", "uint8", range: [40, 100, 1], unit: "celsius")])]) states["d28"] = DeviceState(values: ["2.1": .int(1), "2.2": .int(96), "2.3": .int(100)], spec: kettle, legacy: false, source: "cloud") + let dishwasher = DeviceSpec(type: "", description: "", services: [S(2, "dishwasher", [ + P(2, 1, "status", "uint8", ["read"], opts: [ValueOption(value: 1, description: "Washing"), ValueOption(value: 2, description: "Idle")]), + P(2, 2, "left-time", "uint16", ["read"], unit: "minutes"), + P(2, 3, "mode", "uint8", opts: [ValueOption(value: 0, description: "Auto"), ValueOption(value: 1, description: "Intense"), ValueOption(value: 2, description: "Eco")])])]) + states["d29"] = DeviceState(values: ["2.1": .int(1), "2.2": .int(42), "2.3": .int(0)], spec: dishwasher, legacy: false, source: "cloud") let purifier = DeviceSpec(type: "", description: "", services: [S(2, "water-purifier", [ P(2, 1, "tds-out", "uint16", ["read"], unit: "ppm"), P(2, 2, "tds-in", "uint16", ["read"], unit: "ppm"), @@ -207,6 +213,7 @@ struct ArtGallery: View { ("printer", AnyView(PrinterArt(busy: true, error: false, tint: .blue)), AnyView(PrinterArt(busy: false, error: false, tint: .blue))), ("cooker", AnyView(CookerArt(cooking: true, tint: .orange)), AnyView(CookerArt(cooking: false, tint: .orange))), ("kettle", AnyView(KettleArt(boiling: true, tint: .orange)), AnyView(KettleArt(boiling: false, tint: .orange))), + ("dishwasher", AnyView(DishwasherArt(washing: true, tint: .orange)), AnyView(DishwasherArt(washing: false, tint: .orange))), ("purifier", AnyView(PurifierArt(on: true, filter: 72, tint: .blue)), AnyView(PurifierArt(on: false, filter: 72, tint: .blue))), ("heater", AnyView(HeaterArt(on: true, level: 0.6, tint: Theme.heat)), AnyView(HeaterArt(on: false, level: 0.6, tint: Theme.heat))), ("dehumidifier", AnyView(DehumidifierArt(on: true, level: 0.4, tint: .cyan)), AnyView(DehumidifierArt(on: false, level: 0.4, tint: .cyan))), @@ -385,6 +392,9 @@ enum Snapshot { m.page = .detail("d28"); render(root, f("kettle")) m.states["d28"]?.values["2.1"] = .int(2); render(root, f("kettle-idle")) m.states["d28"]?.values["2.1"] = .int(1) + m.page = .detail("d29"); render(root, f("dishwasher")) + m.states["d29"]?.values["2.1"] = .int(2); render(root, f("dishwasher-idle")) + m.states["d29"]?.values["2.1"] = .int(1) m.page = .detail("d25"); render(root, f("purifier")) m.page = .detail("d26"); render(root, f("motion")) m.page = .detail("d27"); render(root, f("printer")) diff --git a/Sources/MijiaBarApp/Theme.swift b/Sources/MijiaBarApp/Theme.swift index daf33b7..594dd31 100644 --- a/Sources/MijiaBarApp/Theme.swift +++ b/Sources/MijiaBarApp/Theme.swift @@ -93,14 +93,16 @@ extension DeviceCategory { } /// 同一类别里外形不一样的设备:插画和列表图标按型号区分 -enum DeviceForm { case doorbell, motion, contact, leak, kettle } +enum DeviceForm { case doorbell, motion, contact, leak, kettle, dishwasher } extension Device { var form: DeviceForm? { let m = model.lowercased() switch category { case .camera: return m.contains(".cateye.") ? .doorbell : nil - case .kitchen: return m.contains("kettle") ? .kettle : nil + case .kitchen: + if m.contains("dishwasher") { return .dishwasher } + return m.contains("kettle") ? .kettle : nil case .sensor: if m.contains("magnet") { return .contact } if m.contains("flood") || m.contains("wleak") { return .leak } @@ -115,6 +117,7 @@ extension Device { case .contact: return "door.left.hand.closed" case .leak: return "drop.triangle" case .kettle: return "mug" + case .dishwasher: return "dishwasher" case nil: return category.icon } } diff --git a/Sources/MijiaBarApp/TypeDetails.swift b/Sources/MijiaBarApp/TypeDetails.swift index 9f3bdfc..3ee8f38 100644 --- a/Sources/MijiaBarApp/TypeDetails.swift +++ b/Sources/MijiaBarApp/TypeDetails.swift @@ -22,7 +22,10 @@ struct TypeDetailView: View { case .speaker: SpeakerDetail(dc: dc) case .camera: CameraDetail(dc: dc) case .printer: PrinterDetail(dc: dc) - case .kitchen: if device.form == .kettle { KettleDetail(dc: dc) } else { CookerDetail(dc: dc) } + case .kitchen: + if device.form == .kettle { KettleDetail(dc: dc) } + else if device.form == .dishwasher { DishwasherDetail(dc: dc) } + else { CookerDetail(dc: dc) } case .purifier: PurifierDetail(dc: dc) case .heater: HeaterDetail(dc: dc) case .dehumidifier: DehumidifierDetail(dc: dc) @@ -84,7 +87,10 @@ struct DeviceStatusCard: View { case .speaker: SpeakerArt(playing: false, volume: 0.5, tint: tint) case .camera: if d.form == .doorbell { DoorbellArt(on: false, tint: tint) } else { CameraArt(on: false, tint: tint) } case .printer: PrinterArt(busy: false, error: false, tint: tint) - case .kitchen: if d.form == .kettle { KettleArt(boiling: false, tint: tint) } else { CookerArt(cooking: false, tint: tint) } + case .kitchen: + if d.form == .kettle { KettleArt(boiling: false, tint: tint) } + else if d.form == .dishwasher { DishwasherArt(washing: false, tint: tint) } + else { CookerArt(cooking: false, tint: tint) } case .purifier: PurifierArt(on: false, filter: nil, tint: tint) case .heater: HeaterArt(on: false, level: 0.3, tint: tint) case .dehumidifier: DehumidifierArt(on: false, level: 0.3, tint: tint) @@ -341,6 +347,29 @@ struct CookerDetail: View { } } +struct DishwasherDetail: View { + let dc: DC + var body: some View { + let d = dc.device, tint = Color.orange + let status = dc.p("status") + let raw = dc.v(status).flatMap { value in status?.options?.first { Double($0.value) == value.doubleValue }?.description.lowercased() } ?? "" + let washing = ["washing", "cleaning", "drying", "working", "busy", "running", "default"].contains(raw) + let left = dc.num(dc.p("left-time")) + TypeShell(device: d, tint: tint, isOn: washing, title: washing ? L("清洗中", "Washing") : (dc.text(status) ?? (d.isOnline ? L("在线", "Online") : L("离线", "Offline"))), + subtitle: L("洗碗机", "Dishwasher")) { + DishwasherArt(washing: washing, tint: tint) + } content: { + if let left, left > 0 { BigValue(value: "\(Int(left))", unit: L("分钟", "min"), caption: L("剩余时间", "Remaining")) } + else { BigValue(value: dc.text(status), caption: L("当前状态", "Status")) } + ReadoutRow(items: [("thermometer.medium", dc.num(dc.p("temperature")).map { "\(Int($0))°C" }), + ("target", dc.num(dc.p("target-temperature")).map { L("目标 ", "Target ") + "\(Int($0))°C" })], tint: tint) + HStack(spacing: 8) { + PropTile(dc: dc, prop: dc.p("mode"), title: L("模式", "Mode"), icon: "dishwasher", tint: tint) + } + } + } +} + struct KettleDetail: View { let dc: DC var body: some View { diff --git a/Sources/MijiaCore/Translations.swift b/Sources/MijiaCore/Translations.swift index f2bc6ba..7e13ba7 100644 --- a/Sources/MijiaCore/Translations.swift +++ b/Sources/MijiaCore/Translations.swift @@ -742,4 +742,6 @@ public let hantTable: [String: String] = [ "回充": "回充", "电饭煲": "電子鍋", "电热水壶": "電熱水壺", + "洗碗机": "洗碗機", + "清洗中": "清洗中", ] diff --git a/Tests/MijiaBarAppTests/TypeDetailTests.swift b/Tests/MijiaBarAppTests/TypeDetailTests.swift index 79a1b8d..94fbc0f 100644 --- a/Tests/MijiaBarAppTests/TypeDetailTests.swift +++ b/Tests/MijiaBarAppTests/TypeDetailTests.swift @@ -9,13 +9,13 @@ import MijiaCore for (category, row) in [(DeviceCategory.humidifier, "humidifier"), (.vacuum, "vacuum"), (.fridge, "fridge"), (.washer, "washer"), (.waterHeater, "water-heater"), (.curtain, "curtain"), (.airPurifier, "air-purifier"), (.heater, "heater"), (.dehumidifier, "dehumidifier"), - (.speaker, "speaker"), (.kitchen, "cooker"), (.kitchen, "kettle"), (.purifier, "purifier")] { + (.speaker, "speaker"), (.kitchen, "cooker"), (.kitchen, "kettle"), (.kitchen, "dishwasher"), (.purifier, "purifier")] { let device = try #require(model.devices.first { $0.category == category }) #expect(device.homeId == (category == .dehumidifier ? "h1" : "h2")) #expect(model.states[device.did]?.spec != nil) #expect(ArtGallery.rows.filter { $0.0 == row }.count == 1) } - #expect(ArtGallery.rows.count == 26) + #expect(ArtGallery.rows.count == 27) #expect(ArtGallery.size.height == CGFloat(ArtGallery.rows.count * 138)) } @@ -270,6 +270,41 @@ import MijiaCore #expect(!contains(offArt(device, tint: device.category.color), type: CookerArt.self)) } + @Test func dishwasherDemoRoutingAndProps() throws { + let model = AppModel(mock: true) + let device = try #require(model.devices.first { $0.did == "d29" }) + #expect(device.category == .kitchen) + #expect(device.form == .dishwasher) + #expect(device.icon == "dishwasher") + #expect(device.homeId == "h2") + #expect(device.roomName == L("厨房", "Kitchen")) + #expect(device.isOnline) + let state = try #require(model.states[device.did]) + let context = DC(device: device, state: state) + let status = try #require(context.p("status")) + #expect(!status.writable) + #expect(status.options?.map(\.description) == ["Washing", "Idle"]) + let washingOption = try #require(status.options?.first { $0.description == "Washing" }) + #expect(context.num(status) == Double(washingOption.value)) + let left = try #require(context.p("left-time")) + #expect(!left.writable) + #expect(left.unit == "minutes") + #expect(context.num(left) == 42) + let mode = try #require(context.p("mode")) + #expect(mode.writable) + #expect(mode.options?.map(\.description) == ["Auto", "Intense", "Eco"]) + #expect(context.num(mode) == 0) + func contains(_ value: Any, type: Value.Type) -> Bool { + value is Value || Mirror(reflecting: value).children.contains { contains($0.value, type: type) } + } + #expect(contains(TypeDetailView(device: device, state: state).body, type: DishwasherDetail.self)) + #expect(!contains(TypeDetailView(device: device, state: state).body, type: CookerDetail.self)) + #expect(!contains(TypeDetailView(device: device, state: state).body, type: DeviceCardView.self)) + #expect(contains(offArt(device, tint: device.category.color), type: DishwasherArt.self)) + #expect(!contains(offArt(device, tint: device.category.color), type: CookerArt.self)) + #expect(!contains(offArt(device, tint: device.category.color), type: WasherArt.self)) + } + @Test func purifierDemoRoutingAndProps() throws { let model = AppModel(mock: true) let device = try #require(model.devices.first { $0.did == "d25" }) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 92d9119..501bdfc 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -85,11 +85,11 @@ MijiaBar 一天会被打开几十次。用得越频繁,动画就越要少、 - **不动的东西不加动画**:灯泡、摄像机没有循环动画(摄像机的状态灯常亮,不闪烁;隐私模式是镜头转出玻璃视野,而不是贴一个图标);状态(开关、档位、色温)要在静止画面里也能看出来。 - **应用图标**:和登录页同一个 `BrandMark`,`scripts/make-icon.sh` 按 macOS 图标模板(1024 画布、824 主体居中、自带投影)生成 `Resources/AppIcon.icns`。改标志时两处一起变。 - **登录页**:顶部用 `BrandMark`(天蓝单色渐变圆角方块 + 白色房子,门里透出一点暖光;不要用蓝橙直接混合的渐变,中间会发灰发浊);二维码放在白色卡片里(描边、投影同磁贴);地区选择和设置页一样放进分组卡片;底部操作用 `.pressable` + `.hoverHighlight()` 的次要文字按钮。只有依次淡入和扫描线两种动效,不加循环浮动。 -- **没有状态时的详情页**(读取中、离线、读取失败):同样用 `TypeShell`(`DeviceStatusCard`),插画是该设备关着的样子(`offArt`),读到状态后原地换成正常卡片;不要在页面中央单独放一个图标和一行字。空调、灯、风扇、电视、插座、摄像机、门铃、加湿器、扫地机、冰箱、洗衣机、热水器、窗帘、空气净化器、取暖器、除湿机、音箱、电饭煲、电热水壶、净水器、人体传感器和打印机已有专门的 `TypeShell` 页面和插画;未知类型仍用 `DeviceCardView` + `GenericArt`,同样包含 `BigValue`、`Readout` 和磁贴。插画要一眼认出是什么设备:正面的深色圆形部件会被认成摄像头,只有摄像机才用。 +- **没有状态时的详情页**(读取中、离线、读取失败):同样用 `TypeShell`(`DeviceStatusCard`),插画是该设备关着的样子(`offArt`),读到状态后原地换成正常卡片;不要在页面中央单独放一个图标和一行字。空调、灯、风扇、电视、插座、摄像机、门铃、加湿器、扫地机、冰箱、洗衣机、热水器、窗帘、空气净化器、取暖器、除湿机、音箱、电饭煲、电热水壶、洗碗机、净水器、人体传感器和打印机已有专门的 `TypeShell` 页面和插画;未知类型仍用 `DeviceCardView` + `GenericArt`,同样包含 `BigValue`、`Readout` 和磁贴。插画要一眼认出是什么设备:正面的深色圆形部件会被认成摄像头,只有摄像机才用。 - **设置页**:每组用 `SettingsGroup`(小标题 + 圆角 14 的 `Theme.card`)。行都是「文字在左、控件在右」:取值用 `row`,开关用 `SwitchRow`(系统开关,不用勾选框),操作用 `ActionRow`(整行可点、按压/悬停反馈;退出登录、完全退出这类危险操作用红色)。不要在分组之间放单独的大按钮。 - **语言**:界面只有简体中文、繁體中文(台湾用语,人工校对的 `i18n/zh-Hant.tsv`)和 English 三种。所有文案走 `L("简体", "English")`,繁体用台湾说法(裝置、設定、網路、預設),不用机器直转的大陆词;按小米服务器地区推荐默认语言(中国大陆→简体,台湾/香港→繁体,其余→英文)。README、Agent 说明和网站同样只做这三种。 - **网站**(`site/template.html`,由 `scripts/gen-site.py` 生成英文和简体中文页面):同一套原则搬到网页上。颜色是 `:root` 里的语义 token,浅色/深色各一套;动效只用无回弹的 `--ease` 曲线,按压缩放 0.97,悬停只变底色;只有首屏依次淡入和各区块第一次进入视野时淡入,没有循环浮动;`prefers-reduced-motion` 时全部去掉。图片只用 App 自己渲染的截图和插画(`--snapshot`、`--art`),跟随系统浅色/深色切换,不另外画。 -- **同类不同形**:同一类别里外形差别大的设备按型号区分(`Device.form`),插画和列表图标(`Device.icon`)都跟着换:可视门铃用 `DoorbellArt`,不用摄像机插画;厨房电热水壶用 `KettleArt`,不用电饭煲 `CookerArt`;门窗传感器用 `ContactSensorArt`(打开时两块分开、缝隙里透出设备色的光),水浸传感器用 `LeakSensorArt`(浸水时身下一摊水、状态灯和卡片变橙色),都不用温度计。标题同样说设备的状态(「已打开」「检测到浸水」)。**遇到还没有设计过的型号**(落到别的类型的插画或通用卡片上),照这一节的标准补上插画、标题和列表图标,再放进 `ArtGallery` 对照,不要借用外形不同的插画。 +- **同类不同形**:同一类别里外形差别大的设备按型号区分(`Device.form`),插画和列表图标(`Device.icon`)都跟着换:可视门铃用 `DoorbellArt`,不用摄像机插画;厨房电热水壶用 `KettleArt`,不用电饭煲 `CookerArt`;厨房洗碗机用 `DishwasherArt`,不用电饭煲 `CookerArt`;门窗传感器用 `ContactSensorArt`(打开时两块分开、缝隙里透出设备色的光),水浸传感器用 `LeakSensorArt`(浸水时身下一摊水、状态灯和卡片变橙色),都不用温度计。标题同样说设备的状态(「已打开」「检测到浸水」)。**遇到还没有设计过的型号**(落到别的类型的插画或通用卡片上),照这一节的标准补上插画、标题和列表图标,再放进 `ArtGallery` 对照,不要借用外形不同的插画。 - **插画总览**:`--snapshot` 会输出 `art-gallery-*.png`,所有插画开/关并排,改了一个插画后看看它和其他插画是否还是一家人。 - **大号数值**:统一用 `BigValue`(60pt 细体 + 说明文字),在 `TypeShell` 里自动带上色调渐变和光晕(`cardAccent`);传入 `number` 时数字按增减方向滚动。 - **图标**:磁贴和按钮用线框(outline)SF Symbols;只有播放、停止、上一首、下一首这类媒体控制用实心。 diff --git a/i18n/zh-Hant.tsv b/i18n/zh-Hant.tsv index f46aa5e..5a0832c 100644 --- a/i18n/zh-Hant.tsv +++ b/i18n/zh-Hant.tsv @@ -736,3 +736,5 @@ iCloud 云盘 / MijiaBar / house.json(多台 Mac 同步) iCloud 雲碟 / Mij 回充 回充 电饭煲 電子鍋 电热水壶 電熱水壺 +洗碗机 洗碗機 +清洗中 清洗中