From b55523c3b96a119bdf0ffe531dec70ba117bb38a Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 9 Aug 2026 21:46:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E4=BA=BA=E7=89=A9=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E7=B4=A2=E5=BC=95=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/people/person_list_page.dart | 218 ++++++++++++++++++++++++- lib/widgets/fade_in_local_image.dart | 6 +- pubspec.lock | 16 ++ pubspec.yaml | 2 + 4 files changed, 235 insertions(+), 7 deletions(-) diff --git a/lib/pages/people/person_list_page.dart b/lib/pages/people/person_list_page.dart index 24a5633..6cc9744 100644 --- a/lib/pages/people/person_list_page.dart +++ b/lib/pages/people/person_list_page.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:lpinyin/lpinyin.dart'; import 'package:provider/provider.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; -import '../../utils/responsive.dart'; import '../../utils/toast_util.dart'; import '../../widgets/person_avatar.dart'; import 'person_detail_page.dart'; @@ -21,12 +22,184 @@ class _PersonListPageState extends State { String? _occupationFilter; // 职业筛选 final TextEditingController _searchCtrl = TextEditingController(); + // 字母索引 + final _itemScrollController = ItemScrollController(); + final _itemPositionsListener = ItemPositionsListener.create(); + final _activeLetterNotifier = ValueNotifier(''); + final Map _letterFirstIndex = {}; + List<_FlatItem> _flatItems = []; + List _lastFiltered = const []; + + // 拼音缓存:人名 -> 首字母;人名 -> 拼音(用于组内排序) + static final Map _letterCache = {}; + static final Map _pinyinCache = {}; + static const List _allLetters = [ + 'A','B','C','D','E','F','G','H','I','J','K','L','M', + 'N','O','P','Q','R','S','T','U','V','W','X','Y','Z','#' + ]; + + @override + void initState() { + super.initState(); + _itemPositionsListener.itemPositions.addListener(_onPositionsChanged); + } + @override void dispose() { + _itemPositionsListener.itemPositions.removeListener(_onPositionsChanged); + _activeLetterNotifier.dispose(); _searchCtrl.dispose(); super.dispose(); } + /// 取人名首字母(A-Z),非字母开头返回 '#'。结果缓存。 + String _firstLetter(String name) { + return _letterCache.putIfAbsent(name, () => _firstLetterUncached(name)); + } + + /// 取人名拼音(用于组内排序)。结果缓存。 + String _pinyinOf(String name) { + return _pinyinCache.putIfAbsent(name, () => PinyinHelper.getFirstWordPinyin(name)); + } + + String _firstLetterUncached(String name) { + final s = name.trim(); + if (s.isEmpty) return '#'; + final first = s.substring(0, 1); + if (RegExp(r'[A-Za-z]').hasMatch(first)) return first.toUpperCase(); + final py = PinyinHelper.getFirstWordPinyin(first); + if (py.isEmpty) return '#'; + final c = py.substring(0, 1).toUpperCase(); + return RegExp(r'[A-Z]').hasMatch(c) ? c : '#'; + } + + /// 把 filtered 列表按首字母分组,扁平化为 header + person。 + /// 仅在 filtered 引用或长度变化时重算,避免每次 build 重复计算。 + void _buildFlatItems(List filtered) { + if (identical(filtered, _lastFiltered) && filtered.length == _lastFiltered.length) return; + _lastFiltered = filtered; + _flatItems = []; + _letterFirstIndex.clear(); + if (filtered.isEmpty) { + _activeLetterNotifier.value = ''; + return; + } + + final groups = >{}; + for (final p in filtered) { + final letter = _firstLetter(p.name); + groups.putIfAbsent(letter, () => []).add(p); + } + for (final g in groups.values) { + g.sort((a, b) => _pinyinOf(a.name).compareTo(_pinyinOf(b.name))); + } + + final keys = groups.keys.toList() + ..sort((a, b) { + if (a == '#') return 1; + if (b == '#') return -1; + return a.compareTo(b); + }); + + for (final k in keys) { + _letterFirstIndex[k] = _flatItems.length; + _flatItems.add(_FlatItem.letter(k)); + for (final p in groups[k]!) { + _flatItems.add(_FlatItem.person(p)); + } + } + + if (_activeLetterNotifier.value.isEmpty || !_letterFirstIndex.containsKey(_activeLetterNotifier.value)) { + _activeLetterNotifier.value = keys.first; + } + } + + void _onPositionsChanged() { + final positions = _itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + // 取屏幕顶部第一个可见 item 的 index,反查它属于哪个字母 + final firstVisible = positions.reduce((a, b) => a.itemLeadingEdge < b.itemLeadingEdge ? a : b); + final idx = firstVisible.index; + String current = ''; + for (final entry in _letterFirstIndex.entries) { + if (entry.value <= idx) { + current = entry.key; + } else { + break; + } + } + if (current.isNotEmpty && current != _activeLetterNotifier.value) { + _activeLetterNotifier.value = current; + } + } + + void _jumpToLetter(String letter) { + final index = _letterFirstIndex[letter]; + if (index == null) return; + if (!_itemScrollController.isAttached) return; + _itemScrollController.scrollTo( + index: index, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + ); + } + + Widget _buildIndexBar(ColorScheme colors) { + final present = _letterFirstIndex.keys.toSet(); + return Positioned( + right: 2, + top: 0, + bottom: 0, + child: LayoutBuilder( + builder: (context, constraints) { + final maxH = constraints.maxHeight; + const barPadding = 4.0; + final available = maxH - barPadding * 2; + final itemH = (available / _allLetters.length).clamp(8.0, 16.0); + return Padding( + padding: const EdgeInsets.symmetric(vertical: barPadding), + child: ValueListenableBuilder( + valueListenable: _activeLetterNotifier, + builder: (context, activeLetter, _) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final letter in _allLetters) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (!present.contains(letter)) return; + _activeLetterNotifier.value = letter; + _jumpToLetter(letter); + }, + child: SizedBox( + height: itemH, + width: itemH + 8, + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + style: TextStyle( + fontSize: activeLetter == letter ? itemH * 1.1 : itemH * 0.65, + fontWeight: activeLetter == letter ? FontWeight.w800 : FontWeight.w500, + color: present.contains(letter) + ? (activeLetter == letter ? colors.primary : colors.onSurface.withValues(alpha: 0.7)) + : colors.onSurface.withValues(alpha: 0.25), + ), + child: Text(letter, textAlign: TextAlign.center), + ), + ), + ), + ], + ); + }, + ), + ); + }, + ), + ); + } + List _filterPeople(List people) { var result = people; if (_occupationFilter != null) { @@ -48,6 +221,7 @@ class _PersonListPageState extends State { final colors = Theme.of(context).colorScheme; final people = context.watch().people; final filtered = _filterPeople(people); + _buildFlatItems(filtered); return Scaffold( backgroundColor: colors.surface, @@ -76,10 +250,35 @@ class _PersonListPageState extends State { Expanded( child: filtered.isEmpty ? _buildEmptyState(colors) - : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - itemCount: filtered.length, - itemBuilder: (context, index) => _buildPersonItem(filtered[index], colors), + : Stack( + children: [ + ScrollablePositionedList.builder( + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + padding: const EdgeInsets.only(left: 16, right: 28, top: 8, bottom: 8), + itemCount: _flatItems.length, + itemBuilder: (context, i) { + final item = _flatItems[i]; + if (item.isHeader) { + return Container( + height: 32, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Text( + item.letter!, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: colors.onSurface.withValues(alpha: 0.4), + ), + ), + ); + } + return _buildPersonItem(item.person!, colors); + }, + ), + _buildIndexBar(colors), + ], ), ), ], @@ -356,3 +555,12 @@ class _PersonListPageState extends State { }); } } + +class _FlatItem { + final String? letter; + final Person? person; + bool get isHeader => letter != null; + + _FlatItem.letter(this.letter) : person = null; + _FlatItem.person(this.person) : letter = null; +} diff --git a/lib/widgets/fade_in_local_image.dart b/lib/widgets/fade_in_local_image.dart index 8435304..13ed765 100644 --- a/lib/widgets/fade_in_local_image.dart +++ b/lib/widgets/fade_in_local_image.dart @@ -58,13 +58,15 @@ class _FadeInLocalImageState extends State } final file = File(widget.path!); - if (file.existsSync()) { + final exists = await file.exists(); + if (!mounted) return; + if (exists) { setState(() => _loaded = true); _controller.forward(); return; } - setState(() => _error = true); + if (mounted) setState(() => _error = true); } @override diff --git a/pubspec.lock b/pubspec.lock index de865e9..1089439 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -525,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lpinyin: + dependency: "direct main" + description: + name: lpinyin + sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a" + url: "https://pub.dev" + source: hosted + version: "2.0.3" markdown: dependency: transitive description: @@ -813,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0de9111..dcf477a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: crypto: ^3.0.6 window_manager: ^0.4.3 flutter_svg: ^2.3.0 + lpinyin: ^2.0.3 + scrollable_positioned_list: ^0.3.8 dev_dependencies: flutter_test: