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 = >{}; 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 _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 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 items, required ModelCatalogItem target, required bool downloading, required bool hasError, required bool tActive, required bool tDownloaded, required List 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, ), ], ); } }