478 lines
17 KiB
Dart
478 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../models/model_manager.dart';
|
||
|
||
/// 档位展示名(档位码 s/n 仅内部记账,不对用户展示)
|
||
String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度';
|
||
|
||
/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(高性能 / 高精度,
|
||
/// 默认高精度,持久化本地;2026-09-03 高性能移左位)选择**目标档位**——
|
||
/// 只记录偏好,不直接切换运行中的模型,而是决定卡片按钮面向哪个档;下方
|
||
/// 2 列封面缩略图网格,同一物种(datasetId)合并一张卡(s/n 两档内部记账,
|
||
/// 各自下载独立进度)。
|
||
///
|
||
/// 同一物种一次只运行一个档位:启用某档会自动停用同物种另一档(不同物种可用
|
||
/// 不同档位并行识别)。物种卡片主按钮(面向目标档,2026-09-03 简化文案——
|
||
/// 一律「使用/使用中/下载」,不再叫「改用X」,不显示当前运行档提示):
|
||
/// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载);
|
||
/// - 任一档失败 → 错误提示 + 重试(只补下未成功的档);
|
||
/// - 目标档已激活 →「使用中」点击取消使用;
|
||
/// - 目标档已下载未激活 →「使用」直接启用(另一档在运行会被自动停用);
|
||
/// - 目标档未下载 →「下载」取回缺失档(目标档落地自动启用、伴档备好;
|
||
/// 另一档在使用时补下目标档后自动切换过去)。
|
||
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 后
|
||
final byDataset = <int, List<ModelCatalogItem>>{};
|
||
for (final c in manager.catalog) {
|
||
byDataset.putIfAbsent(c.datasetId, () => []).add(c);
|
||
}
|
||
final groups = byDataset.values.toList()
|
||
..sort((a, b) => a.first.datasetId.compareTo(b.first.datasetId));
|
||
final 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(),
|
||
_ModeToggle(manager: manager),
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
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),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 目标档位分段控件:高性能 / 高精度(2026-09-03 调换两档显示位置,
|
||
/// 高性能在左、高精度在右;档位码 s/n 仅内部使用,不对用户展示)
|
||
class _ModeToggle extends StatelessWidget {
|
||
final ModelManager manager;
|
||
|
||
const _ModeToggle({required this.manager});
|
||
|
||
/// 展示顺序(与内部档位常量解耦)
|
||
static const List<String> _displayOrder = [kVariantN, kVariantS];
|
||
|
||
@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 < _displayOrder.length; i++) ...[
|
||
if (i > 0) const SizedBox(width: 2),
|
||
_seg(_displayOrder[i]),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _seg(String v) {
|
||
final selected = manager.mode == v;
|
||
return InkWell(
|
||
borderRadius: BorderRadius.circular(6),
|
||
onTap: () => manager.setMode(v),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: selected ? Colors.greenAccent : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
_variantLabel(v),
|
||
style: TextStyle(
|
||
color: selected ? Colors.black : Colors.white70,
|
||
fontSize: 12,
|
||
fontWeight: selected ? 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: 2),
|
||
// 双档行:高精度 / 高性能各自版本号;使用中绿色高亮、已下载次之、缺失置灰
|
||
Text.rich(
|
||
TextSpan(
|
||
children: [
|
||
for (final i in items) ...[
|
||
if (i != items.first) const TextSpan(text: ' · '),
|
||
TextSpan(
|
||
text: '${_variantLabel(i.variant)} ${i.version}',
|
||
style: TextStyle(
|
||
fontSize: 9,
|
||
color: manager.isActive(i.datasetId, i.variant)
|
||
? Colors.greenAccent
|
||
: manager.isDownloaded(i.datasetId, i.variant)
|
||
? Colors.white70
|
||
: Colors.white38,
|
||
fontWeight: manager.isActive(i.datasetId, i.variant)
|
||
? FontWeight.bold
|
||
: FontWeight.normal,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
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) {
|
||
// 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会被
|
||
// 自动停用(每数据集至多一档运行),统一文案不再叫「改用X」(2026-09-03)
|
||
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 (!use) {
|
||
for (final i in items) {
|
||
if (!manager.isDownloaded(i.datasetId, i.variant)) {
|
||
manager.downloadModel(i);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (!manager.isDownloaded(target.datasetId, target.variant)) {
|
||
final ok = await manager.downloadModel(target);
|
||
if (!ok) 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,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|