Files
observer/flutter_app/lib/camera/model_catalog_section.dart
T
2026-09-10 21:40:31 +08:00

464 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../models/model_manager.dart';
/// 档位展示名(档位码 s/n 仅内部记账,不对用户展示)
String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度';
/// 设置弹层「模型清单」区块:顶部「识别档位」分段控件(高性能/高精度,默认
/// 高精度,持久化本地)选择**目标档位**——只记录偏好,不直接切换运行中的
/// 模型,决定卡片主按钮面向哪一档(2026-09-11 回归单按钮制:卡内不再同列
/// 两档按钮)。多物种综合识别 App 端下线(2026-09-11):目录中 combined 条目
/// 直接忽略、不展示,服务端/管理端综合训练功能保持不变。下方 2 列封面网格,
/// 同一数据集合并一张卡(s/n 两档内部记账,各自下载独立进度),卡片只对
/// **目标档**给一个主按钮:
/// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载);
/// - 任一档失败 → 错误提示 + 重试(只补下未成功的档);
/// - 目标档已激活 →「使用中」点击取消使用;
/// - 目标档已下载未激活 →「使用」直接启用(同数据集另一档在运行会自动停用);
/// - 目标档未下载 →「下载」只取回目标档(双档分别下载,伴档不随下;另一档
/// 正在运行而缺目标档时,下载完自动切换过去)。
/// 卡片不展示版本号与档位状态行(2026-09-10 删「高精度/高性能 + 版本」灰字行,
/// 版本仅作内部记账),档位状态看主按钮。新版本由用户在卡片上手动重新
/// 下载(使用中重下会原地生效,2026-09-09 自动更新/待办横幅已退场)。
class ModelCatalogSection extends StatelessWidget {
final ModelManager manager;
const ModelCatalogSection({super.key, required this.manager});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: manager,
builder: (context, _) {
// 目录按数据集分组:同一数据集 s/n 合成一张卡;组内 s(高精度)前 n 后。
// 综合(combineddatasetId=0)条目 App 端忽略(2026-09-11 多物种下线)
final byDataset = <int, List<ModelCatalogItem>>{};
for (final c in manager.catalog) {
if (c.isCombined) continue;
byDataset.putIfAbsent(c.datasetId, () => []).add(c);
}
final groups = byDataset.values.toList()
..sort((a, b) => a.first.datasetId.compareTo(b.first.datasetId));
const order = {kVariantS: 0, kVariantN: 1};
for (final g in groups) {
g.sort((a, b) =>
(order[a.variant] ?? 9).compareTo(order[b.variant] ?? 9));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text(
'识别档位',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(),
_SegmentSwitch(
selected: manager.mode,
// 高性能在左、高精度在右(档位码仅内部记账)
options: const [
(value: kVariantN, label: '高性能'),
(value: kVariantS, label: '高精度'),
],
onSelect: (v) => manager.setMode(v),
),
],
),
const SizedBox(height: 6),
const Text(
'卡片操作面向所选档位;同一数据集一次只运行一档,切换会自动停用另一档',
style: TextStyle(color: Colors.white38, fontSize: 11),
),
const SizedBox(height: 12),
Row(
children: [
const Text(
'模型清单',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton.icon(
onPressed: () => manager.refresh(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('刷新'),
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
visualDensity: VisualDensity.compact,
),
),
],
),
const SizedBox(height: 4),
if (manager.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
manager.error!,
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
if (groups.isEmpty)
const Text(
'暂无已发布模型',
style: TextStyle(color: Colors.white54, fontSize: 13),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.72,
),
itemCount: groups.length,
itemBuilder: (context, i) =>
_SpeciesCard(items: groups[i], manager: manager),
),
],
);
},
);
}
}
/// 通用分段开关:白底圆角外框,选中段绿色高亮;[options] = (值, 展示名) 列表,
/// 点某段回调 [onSelect](识别档位行使用)
class _SegmentSwitch extends StatelessWidget {
final String selected;
final List<({String value, String label})> options;
final ValueChanged<String> onSelect;
const _SegmentSwitch({
required this.selected,
required this.options,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < options.length; i++) ...[
if (i > 0) const SizedBox(width: 2),
_seg(options[i]),
],
],
),
);
}
Widget _seg(({String value, String label}) o) {
final sel = selected == o.value;
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => onSelect(o.value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: sel ? Colors.greenAccent : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Text(
o.label,
style: TextStyle(
color: sel ? Colors.black : Colors.white70,
fontSize: 12,
fontWeight: sel ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
}
/// 单物种卡片:同数据集 s/n 两档合并展示但**只给目标档一个主按钮**;下载/激活
/// 状态按 (数据集, 档位) 独立记账,同物种同时至多一个档位被激活(目标档为准)
class _SpeciesCard extends StatelessWidget {
final List<ModelCatalogItem> items;
final ModelManager manager;
const _SpeciesCard({required this.items, required this.manager});
@override
Widget build(BuildContext context) {
// 目标条目:当前目标档优先;目录缺目标档(存量单档物种)时取 s 档
final target = items.firstWhere(
(i) => i.variant == manager.mode,
orElse: () => items.first,
);
final tActive = manager.isActive(target.datasetId, target.variant);
final tDownloaded = manager.isDownloaded(target.datasetId, target.variant);
// 同物种另一档正在使用的条目(至多一个——每数据集单档激活约束)
final activeOther = items
.where((i) =>
i.variant != target.variant &&
manager.isActive(i.datasetId, i.variant))
.toList();
// 任一档进行中/失败标志
final downloading =
items.any((i) => manager.progressOf(i.datasetId, i.variant) != null);
final hasError =
items.any((i) => manager.errorOf(i.datasetId, i.variant) != null);
final thumb = ClipRRect(
borderRadius: BorderRadius.circular(8),
child: AspectRatio(
aspectRatio: 4 / 3,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
'${manager.baseUrl}${target.coverUrl}',
fit: BoxFit.cover,
loadingBuilder: (context, child, chunk) => chunk == null
? child
: Container(
color: Colors.white12,
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorBuilder: (context, error, stack) => Container(
color: Colors.white12,
child: const Icon(
Icons.image_not_supported_outlined,
color: Colors.white38,
),
),
),
],
),
),
);
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Center(child: thumb)),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: Text(
target.datasetName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 6),
_actionArea(
items: items,
target: target,
downloading: downloading,
hasError: hasError,
tActive: tActive,
tDownloaded: tDownloaded,
activeOther: activeOther,
),
],
),
);
}
/// 动作区:进度/错误优先级最高;其次按目标档的下载/激活状态给唯一主按钮
Widget _actionArea({
required List<ModelCatalogItem> items,
required ModelCatalogItem target,
required bool downloading,
required bool hasError,
required bool tActive,
required bool tDownloaded,
required List<ModelCatalogItem> activeOther,
}) {
if (downloading) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final i in items)
if (manager.progressOf(i.datasetId, i.variant) != null)
_progressRow(item: i),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
for (final i in items) {
if (manager.progressOf(i.datasetId, i.variant) != null) {
manager.cancelDownload(i.datasetId, i.variant);
}
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('取消', style: TextStyle(fontSize: 11)),
),
),
],
);
}
if (hasError) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final i in items)
if (manager.errorOf(i.datasetId, i.variant) != null)
Text(
'${_variantLabel(i.variant)}下载失败',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.redAccent, fontSize: 10),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
for (final i in items) {
if (manager.errorOf(i.datasetId, i.variant) != null) {
manager.downloadModel(i);
}
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('重试', style: TextStyle(fontSize: 11)),
),
),
],
);
}
final Widget mainBtn;
if (tActive) {
// 目标档使用中:点击取消使用
mainBtn = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () =>
manager.setActive(target.datasetId, target.variant, false),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.greenAccent,
side: const BorderSide(color: Colors.greenAccent),
),
child: const Text('使用中', style: TextStyle(fontSize: 12)),
),
);
} else if (tDownloaded) {
// 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会
// 自动停用(每数据集至多一档运行)
mainBtn = SizedBox(
height: 30,
child: FilledButton(
onPressed: () =>
manager.setActive(target.datasetId, target.variant, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact,
),
child: const Text('使用', style: TextStyle(fontSize: 12)),
),
);
} else {
// 目标档未下载:只下载目标档(双档分别下载——伴档不随下,缺失档由
// 切档后按钮补下);另一档在使用时补下目标档后自动切换过去
final use = activeOther.isNotEmpty;
mainBtn = SizedBox(
height: 30,
child: FilledButton(
onPressed: () async {
if (!manager.isDownloaded(target.datasetId, target.variant)) {
if (use) {
final ok = await manager.downloadModel(target);
if (!ok) return; // 下载失败/取消:错误分支展示,保持现状
} else {
manager.downloadModel(target);
return;
}
}
await manager.setActive(target.datasetId, target.variant, true);
},
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact,
),
child: const Text('下载', style: TextStyle(fontSize: 12)),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [mainBtn],
);
}
Widget _progressRow({required ModelCatalogItem item}) {
final p = manager.progressOf(item.datasetId, item.variant) ?? 0.0;
final label = _variantLabel(item.variant);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
'$label ${(p * 100).toStringAsFixed(0)}%',
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
],
),
LinearProgressIndicator(
value: p,
backgroundColor: Colors.white12,
color: Colors.greenAccent,
),
],
);
}
}