From 26ec55dc9b45ae4464c20ac20f0627c316359262 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 9 Aug 2026 13:32:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BD=B1=E8=A7=86=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E4=BC=98=E5=8C=96=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/database_helper.dart | 10 +- lib/models/data_models.dart | 6 + lib/pages/book/book_form_page.dart | 59 +++--- lib/pages/movies/movie_detail_page.dart | 23 +++ lib/pages/movies/movie_form_page.dart | 35 ++++ lib/widgets/duration_picker.dart | 181 ++++++++++++++++++ lib/widgets/genre_selector_page.dart | 239 ++++++++++++------------ 7 files changed, 403 insertions(+), 150 deletions(-) create mode 100644 lib/widgets/duration_picker.dart diff --git a/lib/data/database_helper.dart b/lib/data/database_helper.dart index fd1649a..ef2d280 100644 --- a/lib/data/database_helper.dart +++ b/lib/data/database_helper.dart @@ -81,7 +81,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 40, + version: 41, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -413,6 +413,13 @@ class DatabaseHelper { await _ensureCharacterColumns(db, 'book_characters'); await _ensureCharacterColumns(db, 'game_characters'); } + if (oldVersion < 41) { + // 添加影视总时长字段 + final movieCols = await db.rawQuery('PRAGMA table_info(movies)'); + if (!movieCols.any((col) => col['name'] == 'duration')) { + await db.execute('ALTER TABLE movies ADD COLUMN duration INTEGER DEFAULT 0'); + } + } } Future _upgradeBooksTableV26(Database db) async { final columns = await db.rawQuery('PRAGMA table_info(books)'); @@ -800,6 +807,7 @@ class DatabaseHelper { category TEXT NOT NULL DEFAULT 'movie', watch_date TEXT, watch_count INTEGER DEFAULT 0, + duration INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_deleted INTEGER DEFAULT 0, diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 9633cb6..9bae02c 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -64,6 +64,7 @@ class Movie { final String category; // 影视分类: movie/tv/anime/variety/documentary/short/other final DateTime? watchDate; // 观看日期 final int watchCount; // 观看次数 + final int duration; // 影视总时长(分钟) final DateTime createdAt; final DateTime updatedAt; final bool isDeleted; @@ -85,6 +86,7 @@ class Movie { this.category = 'movie', this.watchDate, this.watchCount = 0, + this.duration = 0, required this.createdAt, required this.updatedAt, this.isDeleted = false, @@ -108,6 +110,7 @@ class Movie { category: json['category'] ?? 'movie', watchDate: _safeParseDate(json['watch_date']), watchCount: json['watch_count'] ?? 0, + duration: json['duration'] ?? 0, createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, @@ -132,6 +135,7 @@ class Movie { 'category': category, 'watch_date': watchDate?.toUtc().toIso8601String(), 'watch_count': watchCount, + 'duration': duration, 'created_at': createdAt.toUtc().toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(), 'is_deleted': isDeleted ? 1 : 0, @@ -168,6 +172,7 @@ class Movie { String? category, DateTime? watchDate, int? watchCount, + int? duration, DateTime? createdAt, DateTime? updatedAt, bool? isDeleted, @@ -189,6 +194,7 @@ class Movie { category: category ?? this.category, watchDate: watchDate ?? this.watchDate, watchCount: watchCount ?? this.watchCount, + duration: duration ?? this.duration, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, isDeleted: isDeleted ?? this.isDeleted, diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 8933b1e..6446df3 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -156,7 +156,7 @@ class _BookFormPageState extends State { spacing: 12, runSpacing: 12, children: [ - // 书名 + 别名 + // 第一行:书名 + 别名 _halfCard('书名', _titleController.text, Icons.book_outlined, required: true, onTap: () async { final r = await TextInputPanel.show(context: context, title: '书名', initialValue: _titleController.text, hint: '请输入书名'); @@ -172,7 +172,7 @@ class _BookFormPageState extends State { }, ), - // 作者 + 译者 + // 第二行:作者 + 译者 _halfCard('作者', _authors.isEmpty ? '' : '${_authors.length}人:${_authors.join('、')}', Icons.person_outline, onTap: () async { final provider = context.read(); @@ -191,15 +191,8 @@ class _BookFormPageState extends State { if (r != null) setState(() => _translators = r); }, ), - _halfCard('出版社', _publisherController.text, Icons.business_outlined, - onTap: () async { - final r = await TextInputPanel.show(context: context, title: '出版社', initialValue: _publisherController.text, hint: '请输入出版社'); - if (!mounted) return; - if (r != null) setState(() => _publisherController.text = r); - }, - ), - // 类型 + ISBN + // 第三行:类型 + 阅读次数 _halfCard('类型', _genres.isEmpty ? '' : '${_genres.length}个:${_genres.join('、')}', Icons.category_outlined, onTap: () async { final provider = context.read(); @@ -210,29 +203,23 @@ class _BookFormPageState extends State { if (r != null) setState(() => _genres = r); }, ), - _halfCard('ISBN', _isbnController.text, Icons.qr_code_outlined, + _halfCard('阅读次数', _readCount > 0 ? '$_readCount 次' : '', Icons.repeat_outlined, + onTap: () => _editReadCount(), + ), + + // 第四行:出版社 + 出版时间 + _halfCard('出版社', _publisherController.text, Icons.business_outlined, onTap: () async { - final r = await TextInputPanel.show(context: context, title: 'ISBN', initialValue: _isbnController.text, hint: '请输入ISBN编号'); + final r = await TextInputPanel.show(context: context, title: '出版社', initialValue: _publisherController.text, hint: '请输入出版社'); if (!mounted) return; - if (r != null) setState(() => _isbnController.text = r); + if (r != null) setState(() => _publisherController.text = r); }, ), - - // 出版时间 - SizedBox( - width: double.infinity, height: 90, - child: _buildInfoCard( - label: '出版时间', - value: _publishDate != null ? '${_publishDate!.year}.${_publishDate!.month.toString().padLeft(2, '0')}.${_publishDate!.day.toString().padLeft(2, '0')}' : '', - icon: Icons.date_range_outlined, - trailing: _publishDate != null - ? GestureDetector(onTap: () => setState(() => _publishDate = null), child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35))) - : null, - onTap: () => _selectPublishDate(), - ), + _halfCard('出版时间', _publishDate != null ? '${_publishDate!.year}.${_publishDate!.month.toString().padLeft(2, '0')}.${_publishDate!.day.toString().padLeft(2, '0')}' : '', Icons.date_range_outlined, + onTap: () => _selectPublishDate(), ), - // 开始阅读日期 + 读完日期(同一行) + // 第五行:开始阅读 + 读完日期 _halfCard('开始阅读', _startDate != null ? '${_startDate!.year}.${_startDate!.month.toString().padLeft(2, '0')}.${_startDate!.day.toString().padLeft(2, '0')}' : '', Icons.play_circle_outlined, onTap: () => _selectStartDate(), ), @@ -240,12 +227,22 @@ class _BookFormPageState extends State { onTap: () => _selectFinishDate(), ), - // 阅读次数 - _halfCard('阅读次数', _readCount > 0 ? '$_readCount 次' : '', Icons.repeat_outlined, - onTap: () => _editReadCount(), + // ISBN(独占一行) + SizedBox( + width: double.infinity, height: 90, + child: _buildInfoCard( + label: 'ISBN', + value: _isbnController.text, + icon: Icons.qr_code_outlined, + onTap: () async { + final r = await TextInputPanel.show(context: context, title: 'ISBN', initialValue: _isbnController.text, hint: '请输入ISBN编号'); + if (!mounted) return; + if (r != null) setState(() => _isbnController.text = r); + }, + ), ), - // 书籍简介 + // 书籍简介(独占一行) SizedBox( width: double.infinity, child: _buildInfoCard(label: '书籍简介', value: _summaryController.text, icon: Icons.description_outlined, height: 160, scrollable: true, onTap: () => _editSummary()), diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index e8268dc..b86bb29 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -263,6 +263,11 @@ class _MovieDetailPageState extends State { Text('已观看 ${movie.watchCount} 次', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), ], + if (movie.duration > 0) ...[ + const SizedBox(height: 4), + Text(_formatDuration(movie.duration), + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), + ], Divider(height: 32, thickness: 0.5, color: colors.outline), // 详细信息 if (movie.directors.isNotEmpty) _buildDesktopInfoRow('导演', movie.directors.join(','), colors), @@ -1523,6 +1528,14 @@ class _MovieDetailPageState extends State { color: colors.onSurface.withValues(alpha: 0.4), ), ), + if (movie.duration > 0) + Text( + _formatDuration(movie.duration), + style: TextStyle( + fontSize: 14, + color: colors.onSurface.withValues(alpha: 0.4), + ), + ), ], ), ); @@ -2070,6 +2083,16 @@ class _MovieDetailPageState extends State { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } + /// 格式化时长:120 -> "2小时0分",0 -> "" + String _formatDuration(int minutes) { + if (minutes <= 0) return ''; + final h = minutes ~/ 60; + final m = minutes % 60; + if (h > 0 && m > 0) return '时长 $h小时$m分'; + if (h > 0) return '时长 $h小时'; + return '时长 $m分'; + } + void _navigateToEdit(BuildContext context) { final provider = context.read(); Navigator.pushNamed(context, '/movie-form', arguments: widget.movie).then((_) { diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index 6151bba..a086438 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -14,6 +14,7 @@ import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; import '../../widgets/genre_selector_page.dart'; import '../../widgets/text_input_panel.dart'; +import '../../widgets/duration_picker.dart'; /// 从多值字段列表中提取去重排序的唯一值(供 compute 使用) List _collectUnique(List> lists) { @@ -54,6 +55,7 @@ class _MovieFormPageState extends State { DateTime? _releaseDate; DateTime? _watchDate; int _watchCount = 0; + int _duration = 0; bool _isDownloading = false; @override @@ -91,6 +93,7 @@ class _MovieFormPageState extends State { _releaseDate = movie.releaseDate; _watchDate = movie.watchDate; _watchCount = movie.watchCount; + _duration = movie.duration; } else if (widget.initialStatus != null) { // 添加模式:使用传入的默认状态 _status = widget.initialStatus!; @@ -615,6 +618,18 @@ class _MovieFormPageState extends State { ), ), + // 影视总时长 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '影视总时长', + value: _formatDuration(_duration), + icon: Icons.schedule_outlined, + onTap: () => _editDuration(), + ), + ), + // 第五行:剧情简介(独占一行) SizedBox( width: double.infinity, @@ -773,6 +788,24 @@ class _MovieFormPageState extends State { } } + /// 编辑影视总时长 + Future _editDuration() async { + final result = await DurationPicker.show(context: context, initialMinutes: _duration); + if (result != null) { + setState(() => _duration = result); + } + } + + /// 格式化时长:120 -> "2小时0分",0 -> "" + String _formatDuration(int minutes) { + if (minutes <= 0) return ''; + final h = minutes ~/ 60; + final m = minutes % 60; + if (h > 0 && m > 0) return '$h小时$m分'; + if (h > 0) return '$h小时'; + return '$m分'; + } + /// 全屏编辑剧情简介 Future _editSummary() async { final result = await Navigator.push( @@ -1397,6 +1430,7 @@ class _MovieFormPageState extends State { category: _category, watchDate: _watchDate, watchCount: _watchCount, + duration: _duration, createdAt: now, updatedAt: now, ); @@ -1419,6 +1453,7 @@ class _MovieFormPageState extends State { category: _category, watchDate: _watchDate, watchCount: _watchCount, + duration: _duration, updatedAt: now, ); diff --git a/lib/widgets/duration_picker.dart b/lib/widgets/duration_picker.dart new file mode 100644 index 0000000..c7abb0a --- /dev/null +++ b/lib/widgets/duration_picker.dart @@ -0,0 +1,181 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// 时长选择器(时:分两个滚轮的底部弹窗) +/// 返回总分钟数,取消返回 null +class DurationPicker { + /// 显示时长选择器 + /// [initialMinutes] 初始总分钟数 + static Future show({ + required BuildContext context, + int initialMinutes = 0, + String title = '影视总时长', + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => _DurationPickerSheet( + initialMinutes: initialMinutes, + title: title, + ), + ); + } +} + +class _DurationPickerSheet extends StatefulWidget { + final int initialMinutes; + final String title; + + const _DurationPickerSheet({ + required this.initialMinutes, + required this.title, + }); + + @override + State<_DurationPickerSheet> createState() => _DurationPickerSheetState(); +} + +class _DurationPickerSheetState extends State<_DurationPickerSheet> { + late int _hours; + late int _minutes; + late FixedExtentScrollController _hourCtrl; + late FixedExtentScrollController _minuteCtrl; + + static const _maxHours = 100; // 0-99 + + @override + void initState() { + super.initState(); + _hours = widget.initialMinutes ~/ 60; + _minutes = widget.initialMinutes % 60; + _hourCtrl = FixedExtentScrollController(initialItem: _hours); + _minuteCtrl = FixedExtentScrollController(initialItem: _minutes); + } + + @override + void dispose() { + _hourCtrl.dispose(); + _minuteCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 拖拽条 + Center( + child: Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 12), + // 标题 + 操作按钮 + Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: Text('取消', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + ), + Expanded( + child: Center( + child: Text(widget.title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, _hours * 60 + _minutes), + child: Text('确定', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.primary)), + ), + ], + ), + const SizedBox(height: 8), + // 时长显示 + Text( + _formatDisplay(), + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: colors.onSurface), + ), + const SizedBox(height: 12), + // 滚轮 + SizedBox( + height: 200, + child: Row( + children: [ + // 小时 + Expanded( + child: _buildWheel( + controller: _hourCtrl, + itemCount: _maxHours, + suffix: '时', + onChanged: (i) => setState(() => _hours = i), + ), + ), + // 分隔 + Padding( + padding: const EdgeInsets.only(bottom: 28), + child: Text(':', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4))), + ), + // 分钟 + Expanded( + child: _buildWheel( + controller: _minuteCtrl, + itemCount: 60, + suffix: '分', + onChanged: (i) => setState(() => _minutes = i), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildWheel({ + required FixedExtentScrollController controller, + required int itemCount, + required String suffix, + required ValueChanged onChanged, + }) { + final colors = Theme.of(context).colorScheme; + return CupertinoPicker( + scrollController: controller, + itemExtent: 36, + onSelectedItemChanged: onChanged, + children: List.generate(itemCount, (i) { + return Center( + child: Text( + '$i $suffix', + style: TextStyle( + fontSize: 16, + color: colors.onSurface, + ), + ), + ); + }), + ); + } + + String _formatDisplay() { + if (_hours == 0 && _minutes == 0) return '未设置'; + final parts = []; + if (_hours > 0) parts.add('$_hours 小时'); + if (_minutes > 0) parts.add('$_minutes 分'); + return parts.join(' '); + } +} diff --git a/lib/widgets/genre_selector_page.dart b/lib/widgets/genre_selector_page.dart index 354fd6c..51b394a 100644 --- a/lib/widgets/genre_selector_page.dart +++ b/lib/widgets/genre_selector_page.dart @@ -58,6 +58,7 @@ class GenreSelectorPage extends StatefulWidget { class _GenreSelectorPageState extends State { late List _selected; final _controller = TextEditingController(); + final _scrollController = ScrollController(); String _query = ''; List? _loadedTags; bool _loading = true; @@ -69,6 +70,13 @@ class _GenreSelectorPageState extends State { _loadTags(); } + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + Future _loadTags() async { if (widget.existingTags != null) { _loadedTags = widget.existingTags; @@ -78,12 +86,6 @@ class _GenreSelectorPageState extends State { if (mounted) setState(() => _loading = false); } - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - void _toggle(String tag) { setState(() { if (_selected.contains(tag)) { @@ -168,86 +170,28 @@ class _GenreSelectorPageState extends State { children: [ // Header Container( - padding: EdgeInsets.fromLTRB(16, MediaQuery.of(context).padding.top + 12, 8, 12), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), - ), + padding: EdgeInsets.fromLTRB(20, MediaQuery.of(context).padding.top + 14, 8, 14), child: Row( children: [ Text(widget.title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), const Spacer(), TextButton( - onPressed: () => Navigator.pop(context, _selected), + onPressed: () { + // 输入框非空时,完成即添加并关闭 + if (_query.trim().isNotEmpty && !_selected.contains(_query.trim())) { + _selected.add(_query.trim()); + } + Navigator.pop(context, _selected); + }, child: Text('完成', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.primary)), ), ], ), ), - // 已选择(一行一个,最新在上) - if (_selected.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Align( - alignment: Alignment.centerLeft, - child: Text('已选择', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), - ), - ), - ConstrainedBox( - constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.25), - child: Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), - ), - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: _selected.length, - itemBuilder: (_, i) { - final idx = _selected.length - 1 - i; - final tag = _selected[idx]; - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Container( - decoration: BoxDecoration( - color: colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - dense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - leading: Icon(Icons.check_circle, size: 20, color: colors.primary), - title: GestureDetector( - onTap: () => _editItem(idx, tag), - child: Text(tag, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: () => _editItem(idx, tag), - child: Icon(Icons.edit, size: 16, color: colors.onSurface.withValues(alpha: 0.3)), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () => _toggle(tag), - child: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.35)), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ), - ], - // 搜索/输入框 Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: TextField( controller: _controller, style: TextStyle(fontSize: 14, color: colors.onSurface), @@ -272,56 +216,115 @@ class _GenreSelectorPageState extends State { ), ), - // 已有类型/搜索结果 - if (allTags.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - _loading ? '加载中...' : (query.isEmpty ? '已有类型' : '匹配结果'), - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)), - ), + // 已选择区(紧凑 chip 流,最新在上) + if (_selected.isNotEmpty) ...[ + Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(10), + ), + child: _buildSelectedChips(colors), + ), + const SizedBox(height: 4), + ], + + // 可选区标题 + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _loading ? '加载中...' : (query.isEmpty ? '可选 (${available.length})' : '匹配结果 (${available.length})'), + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)), ), ), - Expanded( - child: _loading - ? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) - : available.isEmpty - ? Center(child: Text( - query.isEmpty ? '暂无已有选项' : '无匹配结果,回车添加', - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)), - )) - : SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: available.map((tag) { - return GestureDetector( - onTap: () => _toggle(tag), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colors.outline, width: 0.5), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - const SizedBox(width: 4), - Text(tag, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7))), - ], - ), - ), - ); - }).toList(), + ), + + // 可选列表(虚拟化) + Expanded( + child: _loading + ? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) + : available.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Text( + query.isEmpty ? '暂无可选' : '无匹配结果,回车添加', + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)), ), ), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 24), + itemCount: available.length, + itemExtent: 44, + itemBuilder: (_, i) { + final tag = available[i]; + return _buildAvailableItem(tag, colors); + }, + ), + ), + ], + ), + ), + ); + } + + /// 已选 chip 流:每个 chip 显示「标签名 ×」,点 × 移除,长按编辑 + Widget _buildSelectedChips(ColorScheme colors) { + // 反序展示,最新在上 + final reversed = _selected.reversed.toList(); + return Wrap( + spacing: 6, + runSpacing: 6, + children: List.generate(reversed.length, (i) { + final actualIdx = _selected.length - 1 - i; + final tag = reversed[i]; + return GestureDetector( + onTap: () => _toggle(tag), + onLongPress: () => _editItem(actualIdx, tag), + child: Container( + padding: const EdgeInsets.fromLTRB(10, 5, 6, 5), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(tag, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onPrimary)), + const SizedBox(width: 4), + Icon(Icons.close, size: 14, color: colors.onPrimary.withValues(alpha: 0.85)), + ], + ), + ), + ); + }), + ); + } + + /// 可选列表项:左标签 + 右添加图标 + Widget _buildAvailableItem(String tag, ColorScheme colors) { + return InkWell( + onTap: () => _toggle(tag), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Expanded( + child: Text( + tag, + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.8)), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], + ), + Icon(Icons.add_circle_outline, size: 18, color: colors.onSurface.withValues(alpha: 0.3)), ], ), ),