You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

119 lines
5.3 KiB

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<String> _mainNames = <String>{};
/// 目录下实际存在的全部条目(含 `_icon`),用来判断小图标在不在
static final Set<String> _allNames = <String>{};
static bool _loaded = false;
/// 读一次 AssetManifest,把目录里实际有哪些图记下来。
///
/// 为什么要这一步:Flutter 没法同步判断某个 asset 存不存在。不预先知道
/// 有哪些图,就只能靠 `Image.asset` 的 errorBuilder 在渲染失败时兜底——
/// 那是**静默**的,美术漏出一张图或者命名对不上,界面直接变成通用图,
/// 没有任何地方能看出来。有了清单就能在解析时打一条 warn。
static Future<void> 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;
}
}