Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Sources/MijiaBarApp/Art.swift
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,58 @@ struct DishwasherArt: View {
}
}

struct JuicerArt: View {
let blending: Bool, tint: Color
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
PowerTimeline(on: blending, running: true) { time, level in
ZStack {
Ambient(tint, level * 0.6, width: 150, height: 120).position(x: 90, y: 74)
Path { path in
path.move(to: CGPoint(x: 117, y: 33))
path.addCurve(to: CGPoint(x: 113, y: 79), control1: CGPoint(x: 151, y: 28), control2: CGPoint(x: 149, y: 78))
}
.stroke(aluminum, style: StrokeStyle(lineWidth: 7, lineCap: .round))
Path { path in
path.move(to: CGPoint(x: 48, y: 23))
path.addLine(to: CGPoint(x: 123, y: 23))
path.addLine(to: CGPoint(x: 111, y: 91))
path.addQuadCurve(to: CGPoint(x: 69, y: 91), control: CGPoint(x: 90, y: 96))
path.addLine(to: CGPoint(x: 60, y: 34))
path.closeSubpath()
}
.fill(LinearGradient(colors: [Color(white: 0.92).opacity(0.85), Color(white: 0.65).opacity(0.5)], startPoint: .leading, endPoint: .trailing))
Canvas { context, _ in swirl(&context, time: time, level: level) }
ForEach(0..<4) { index in
Capsule().fill(.white.opacity(0.6)).frame(width: 7, height: 1)
.position(x: 104, y: CGFloat(39 + index * 12))
}
Capsule().fill(darkGlass).frame(width: 70, height: 8).position(x: 90, y: 22)
Capsule().fill(aluminum).frame(width: 24, height: 6).position(x: 90, y: 16)
RoundedRectangle(cornerRadius: 10, style: .continuous).appliance(tint, level)
.frame(width: 76, height: 34).position(x: 90, y: 111)
Circle().fill(darkGlass).frame(width: 18, height: 18).position(x: 90, y: 111)
Capsule().fill(tint).frame(width: 2, height: 6).opacity(0.2 + level * 0.8).position(x: 90, y: 107)
Capsule().fill(darkGlass).frame(width: 68, height: 4).position(x: 90, y: 130)
}
.frame(width: 180, height: 136)
}
}
func swirl(_ context: inout GraphicsContext, time: Double, level: Double) {
guard blending, level > 0.01 else { return }
let phase = reduceMotion ? 0 : time * 4
for index in 0..<4 {
let centerY = Double(49 + index * 11)
var path = Path()
path.move(to: CGPoint(x: 69, y: centerY))
path.addCurve(to: CGPoint(x: 109, y: centerY),
control1: CGPoint(x: 79, y: centerY - 8 + sin(phase + Double(index)) * 3),
control2: CGPoint(x: 99, y: centerY + 8))
context.stroke(path, with: .color(tint.opacity(level * 0.7)), style: StrokeStyle(lineWidth: 3, lineCap: .round))
}
}
}

struct KettleArt: View {
let boiling: Bool, tint: Color
@Environment(\.accessibilityReduceMotion) private var reduceMotion
Expand Down
9 changes: 9 additions & 0 deletions Sources/MijiaBarApp/Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ extension AppModel {
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"),
dev("d30", L("榨汁机", "Juicer"), "xiaomi.juicer.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 }()
Expand Down Expand Up @@ -183,6 +184,10 @@ extension AppModel {
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 juicer = DeviceSpec(type: "", description: "", services: [S(2, "juicer", [
P(2, 1, "status", "uint8", ["read"], opts: [ValueOption(value: 1, description: "Blending"), ValueOption(value: 2, description: "Idle")]),
P(2, 2, "mode", "uint8", opts: [ValueOption(value: 0, description: "Juice"), ValueOption(value: 1, description: "Smoothie"), ValueOption(value: 2, description: "Pulse")])])])
states["d30"] = DeviceState(values: ["2.1": .int(1), "2.2": .int(0)], spec: juicer, 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"),
Expand Down Expand Up @@ -214,6 +219,7 @@ struct ArtGallery: View {
("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))),
("juicer", AnyView(JuicerArt(blending: true, tint: .orange)), AnyView(JuicerArt(blending: 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))),
Expand Down Expand Up @@ -395,6 +401,9 @@ enum Snapshot {
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("d30"); render(root, f("juicer"))
m.states["d30"]?.values["2.1"] = .int(2); render(root, f("juicer-idle"))
m.states["d30"]?.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"))
Expand Down
6 changes: 4 additions & 2 deletions Sources/MijiaBarApp/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ extension DeviceCategory {
}

/// 同一类别里外形不一样的设备:插画和列表图标按型号区分
enum DeviceForm { case doorbell, motion, contact, leak, kettle, dishwasher }
enum DeviceForm { case doorbell, motion, contact, leak, kettle, dishwasher, juicer }

extension Device {
var form: DeviceForm? {
Expand All @@ -102,7 +102,8 @@ extension Device {
case .camera: return m.contains(".cateye.") ? .doorbell : nil
case .kitchen:
if m.contains("dishwasher") { return .dishwasher }
return m.contains("kettle") ? .kettle : nil
if m.contains("kettle") { return .kettle }
return m.contains("juicer") ? .juicer : nil
case .sensor:
if m.contains("magnet") { return .contact }
if m.contains("flood") || m.contains("wleak") { return .leak }
Expand All @@ -118,6 +119,7 @@ extension Device {
case .leak: return "drop.triangle"
case .kettle: return "mug"
case .dishwasher: return "dishwasher"
case .juicer: return "cup.and.saucer"
case nil: return category.icon
}
}
Expand Down
21 changes: 21 additions & 0 deletions Sources/MijiaBarApp/TypeDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct TypeDetailView: View {
case .kitchen:
if device.form == .kettle { KettleDetail(dc: dc) }
else if device.form == .dishwasher { DishwasherDetail(dc: dc) }
else if device.form == .juicer { JuicerDetail(dc: dc) }
else { CookerDetail(dc: dc) }
case .purifier: PurifierDetail(dc: dc)
case .heater: HeaterDetail(dc: dc)
Expand Down Expand Up @@ -90,6 +91,7 @@ struct DeviceStatusCard: View {
case .kitchen:
if d.form == .kettle { KettleArt(boiling: false, tint: tint) }
else if d.form == .dishwasher { DishwasherArt(washing: false, tint: tint) }
else if d.form == .juicer { JuicerArt(blending: 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)
Expand Down Expand Up @@ -370,6 +372,25 @@ struct DishwasherDetail: View {
}
}

struct JuicerDetail: View {
let dc: DC
var body: some View {
let device = 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 blending = ["blending", "juicing", "working", "busy", "running", "default"].contains(raw)
TypeShell(device: device, tint: tint, isOn: blending, title: blending ? L("榨汁中", "Blending") : (dc.text(status) ?? (device.isOnline ? L("在线", "Online") : L("离线", "Offline"))),
subtitle: L("榨汁机", "Juicer")) {
JuicerArt(blending: blending, tint: tint)
} content: {
BigValue(value: dc.text(status), caption: L("当前状态", "Status"))
HStack(spacing: 8) {
PropTile(dc: dc, prop: dc.p("mode"), title: L("模式", "Mode"), icon: device.icon, tint: tint)
}
}
}
}

struct KettleDetail: View {
let dc: DC
var body: some View {
Expand Down
2 changes: 2 additions & 0 deletions Sources/MijiaCore/Translations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -744,4 +744,6 @@ public let hantTable: [String: String] = [
"电热水壶": "電熱水壺",
"洗碗机": "洗碗機",
"清洗中": "清洗中",
"榨汁机": "果汁機",
"榨汁中": "榨汁中",
]
35 changes: 33 additions & 2 deletions Tests/MijiaBarAppTests/TypeDetailTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"), (.kitchen, "dishwasher"), (.purifier, "purifier")] {
(.speaker, "speaker"), (.kitchen, "cooker"), (.kitchen, "kettle"), (.kitchen, "dishwasher"), (.kitchen, "juicer"), (.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 == 27)
#expect(ArtGallery.rows.count == 28)
#expect(ArtGallery.size.height == CGFloat(ArtGallery.rows.count * 138))
}

Expand Down Expand Up @@ -305,6 +305,37 @@ import MijiaCore
#expect(!contains(offArt(device, tint: device.category.color), type: WasherArt.self))
}

@Test func juicerDemoRoutingAndProps() throws {
let model = AppModel(mock: true)
let device = try #require(model.devices.first { $0.did == "d30" })
#expect(device.category == .kitchen)
#expect(device.form == .juicer)
#expect(device.icon == "cup.and.saucer")
#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) == ["Blending", "Idle"])
let blendingOption = try #require(status.options?.first { $0.description == "Blending" })
#expect(context.num(status) == Double(blendingOption.value))
let mode = try #require(context.p("mode"))
#expect(mode.writable)
#expect(mode.options?.map(\.description) == ["Juice", "Smoothie", "Pulse"])
#expect(context.num(mode) == 0)
func contains<Value>(_ 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: JuicerDetail.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: JuicerArt.self))
#expect(!contains(offArt(device, tint: device.category.color), type: CookerArt.self))
#expect(!contains(offArt(device, tint: device.category.color), type: DishwasherArt.self))
}

@Test func purifierDemoRoutingAndProps() throws {
let model = AppModel(mock: true)
let device = try #require(model.devices.first { $0.did == "d25" })
Expand Down
4 changes: 2 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;厨房洗碗机用 `DishwasherArt`,不用电饭煲 `CookerArt`;门窗传感器用 `ContactSensorArt`(打开时两块分开、缝隙里透出设备色的光),水浸传感器用 `LeakSensorArt`(浸水时身下一摊水、状态灯和卡片变橙色),都不用温度计。标题同样说设备的状态(「已打开」「检测到浸水」)。**遇到还没有设计过的型号**(落到别的类型的插画或通用卡片上),照这一节的标准补上插画、标题和列表图标,再放进 `ArtGallery` 对照,不要借用外形不同的插画。
- **同类不同形**:同一类别里外形差别大的设备按型号区分(`Device.form`),插画和列表图标(`Device.icon`)都跟着换:可视门铃用 `DoorbellArt`,不用摄像机插画;厨房电热水壶用 `KettleArt`,不用电饭煲 `CookerArt`;厨房洗碗机用 `DishwasherArt`,不用电饭煲 `CookerArt`;厨房榨汁机用 `JuicerArt`(高杯体与底座),不用电饭煲 `CookerArt`;门窗传感器用 `ContactSensorArt`(打开时两块分开、缝隙里透出设备色的光),水浸传感器用 `LeakSensorArt`(浸水时身下一摊水、状态灯和卡片变橙色),都不用温度计。标题同样说设备的状态(「已打开」「检测到浸水」)。**遇到还没有设计过的型号**(落到别的类型的插画或通用卡片上),照这一节的标准补上插画、标题和列表图标,再放进 `ArtGallery` 对照,不要借用外形不同的插画。
- **插画总览**:`--snapshot` 会输出 `art-gallery-*.png`,所有插画开/关并排,改了一个插画后看看它和其他插画是否还是一家人。
- **大号数值**:统一用 `BigValue`(60pt 细体 + 说明文字),在 `TypeShell` 里自动带上色调渐变和光晕(`cardAccent`);传入 `number` 时数字按增减方向滚动。
- **图标**:磁贴和按钮用线框(outline)SF Symbols;只有播放、停止、上一首、下一首这类媒体控制用实心。
Expand Down
2 changes: 2 additions & 0 deletions i18n/zh-Hant.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -738,3 +738,5 @@ iCloud 云盘 / MijiaBar / house.json(多台 Mac 同步) iCloud 雲碟 / Mij
电热水壶 電熱水壺
洗碗机 洗碗機
清洗中 清洗中
榨汁机 果汁機
榨汁中 榨汁中
Loading