import 'package:flutter/services.dart'; import '../../core/utils/logger.dart'; /// 按设备名解析产品图与产品小图标。 /// /// 约定:`assets/images/devices/<设备名>.png` 是主图, /// `assets/images/devices/<设备名>_icon.png` 是同一产品的小图标。 /// 加新产品只要把两张图按这个命名丢进目录即可 —— pubspec 里 /// `assets/images/devices/` 是**按目录**声明的,新文件会自动打包,不用改配置。 /// /// ⚠️ 图片是打进安装包的,所以**新产品必须发版才能看到**。 /// 服务端 `DBProduct.productimage` 那条网络图的路子(后台传图、老包也能看到) /// 是刻意没走的,见 2026-09-08 的决定。 class DeviceImages { DeviceImages._(); static const String _tag = 'DeviceImages'; /// ⚠️ 目录里的图**必须是去背的透明 PNG**,`default.png` / `default_icon.png` /// 也不例外。设备页的展示区是横宽的(约 1.14:1),而出图习惯是竖版海报 /// (892×1152,0.77:1)——带不透明底色的竖图用 `BoxFit.contain` 塞进去, /// 会变成中间一条窄的色块卡片、左右大片留白,且底色与页面背景(尤其深色模式) /// 硬碰硬。2026-09-14 之前的默认图正是这样,表现为「图片和显示框对不上」。 /// 去背之后主体自己铺满框、周围就是页面背景,不需要任何代码适配。 static const String dir = 'assets/images/devices/'; static const String defaultImage = '${dir}default.png'; static const String defaultIcon = '${dir}default_icon.png'; static const String _iconSuffix = '_icon'; /// 目录下所有产品主图的名字(不含 `.png`,不含 `_icon` 那些) static final Set _mainNames = {}; /// 目录下实际存在的全部条目(含 `_icon`),用来判断小图标在不在 static final Set _allNames = {}; static bool _loaded = false; /// 读一次 AssetManifest,把目录里实际有哪些图记下来。 /// /// 为什么要这一步:Flutter 没法同步判断某个 asset 存不存在。不预先知道 /// 有哪些图,就只能靠 `Image.asset` 的 errorBuilder 在渲染失败时兜底—— /// 那是**静默**的,美术漏出一张图或者命名对不上,界面直接变成通用图, /// 没有任何地方能看出来。有了清单就能在解析时打一条 warn。 static Future load() async { try { final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); for (final key in manifest.listAssets()) { if (!key.startsWith(dir) || !key.endsWith('.png')) continue; final base = key.substring(dir.length, key.length - 4); if (base.isEmpty) continue; _allNames.add(base); if (!base.endsWith(_iconSuffix)) _mainNames.add(base); } _loaded = true; Logger.i(_tag, '产品图清单已载入:${_mainNames.length} 个产品 ${_allNames.length} 个文件'); } catch (e) { Logger.w(_tag, '读取产品图清单失败,全部退回默认图: $e'); } } /// 设备主图。匹配不上返回 [defaultImage]。 static String imageFor(String? deviceName) { final match = _match(deviceName); return match == null ? defaultImage : '$dir$match.png'; } /// 设备小图标。 /// /// 顺序:`<名字>_icon.png` → `<名字>.png` → `default_icon.png`。 /// 中间那一档是刻意留的:产品有主图但美术没出小图时,用主图缩着显示 /// 也比掉成通用图强 —— 掉通用图会让用户以为「App 不认识我的设备」。 /// 走到这一档会打 warn,提醒把 `_icon` 补上。 static String iconFor(String? deviceName) { final match = _match(deviceName); if (match == null) return defaultIcon; final icon = '$match$_iconSuffix'; if (_allNames.contains(icon)) return '$dir$icon.png'; Logger.w(_tag, '产品 $match 缺小图标 $icon.png,暂用主图顶替'); return '$dir$match.png'; } /// 把设备名匹配到目录里的某个产品名;匹配不上返回 null。 /// /// 三级,从严到宽: /// 1. 完全相同(`Echo-one` → `Echo-one.png`) /// 2. 忽略大小写(防美术出图时大小写和固件广播名不一致) /// 3. 最长前缀(`Echo-one_L`、`Echo-one-3F2A` 这类带后缀的广播名) /// /// ⚠️ 第 3 级必须取**最长**匹配。按任意顺序取第一个命中的话, /// 目录里若同时有 `Echo` 和 `Echo-one`,`Echo-one` 的设备可能被 `Echo` 抢走。 static String? _match(String? deviceName) { final name = (deviceName ?? '').trim(); if (name.isEmpty) return null; if (!_loaded) { // 清单还没载入(理论上不会发生,load() 在 runApp 之前 await) Logger.w(_tag, '产品图清单未载入就来取图,先退默认:$name'); return null; } if (_mainNames.contains(name)) return name; final lower = name.toLowerCase(); for (final n in _mainNames) { if (n.toLowerCase() == lower) return n; } String? best; for (final n in _mainNames) { if (n.isEmpty || !lower.startsWith(n.toLowerCase())) continue; if (best == null || n.length > best.length) best = n; } if (best == null) { Logger.w(_tag, '没有 $name 对应的产品图,退回默认图'); } return best; } }