- pubspec: bump all maintained deps to null-safe latest; flutter pub get resolves
- Remove 12 packages (0-ref or no null-safe): splashscreen, flappy_search_bar,
searchable_dropdown, share, platform_detect, flutter_inappwebview, countdown,
gender_selection, flutter_dash, smooth_star_rating, hovering, ffi
- Add flutter_rating_bar
- Keep discontinued-but-null-safe for now (Phase 5): pull_to_refresh, hive, catcher
- New null-safe custom components (API-compatible, callers change only imports):
utils/countdown.dart, widgets/general/{hover_widget,gender_selection,
smooth_star_rating,search_bar}.dart
- main.dart: drop dead SplashScreen _buildBody + _to field
- 23 consuming files: swap deprecated imports to local components
Null-safety migration + upgraded-API adaptation = Phase 3.
186 lines
5.2 KiB
Dart
186 lines
5.2 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
/// 轻量级异步搜索控制器,配合 [SearchBar] 使用。
|
||
///
|
||
/// 替代已停维的 `flappy_search_bar` 的 `SearchBarController`。
|
||
/// 保留泛型 `<T>` 以兼容调用方 `SearchBarController<Product>()` 写法。
|
||
/// [replayLastSearch] 用于“加载更多 / 分页”:外部 page++ 后触发重跑上一次搜索。
|
||
class SearchBarController<T> {
|
||
String lastQuery = '';
|
||
VoidCallback? _replay;
|
||
|
||
void _register(VoidCallback replay) {
|
||
_replay = replay;
|
||
}
|
||
|
||
/// 重新执行上一次搜索(分页加载更多时调用)。
|
||
void replayLastSearch() => _replay?.call();
|
||
}
|
||
|
||
/// 轻量级 Web 异步搜索栏,替代已停维的 `flappy_search_bar` 的 `SearchBar`。
|
||
///
|
||
/// 基本流程:输入达到 [minimumChars] 个字符后,调用 [onSearch] 取结果,
|
||
/// 用 [onItemFound] 渲染每一条;无结果时显示 [emptyWidget];出错显示 [onError]。
|
||
///
|
||
/// 注意:`searchBarController` 参数刻意使用裸类型(`SearchBarController?`),
|
||
/// 以兼容调用方 `SearchBarController _controller = SearchBarController<Product>();`
|
||
/// 的写法(Dart 3 sound 类型下避免泛型不变性报错)。
|
||
class SearchBar<T> extends StatefulWidget {
|
||
final SearchBarController? searchBarController;
|
||
final int minimumChars;
|
||
final String? hintText;
|
||
final Widget? cancellationWidget;
|
||
final Future<List<T>> Function(String text) onSearch;
|
||
final Widget Function(T item, int index) onItemFound;
|
||
final Widget Function(dynamic error)? onError;
|
||
final Widget? emptyWidget;
|
||
final Duration debounceDuration;
|
||
|
||
const SearchBar({
|
||
super.key,
|
||
this.searchBarController,
|
||
this.minimumChars = 2,
|
||
this.hintText,
|
||
this.cancellationWidget,
|
||
required this.onSearch,
|
||
required this.onItemFound,
|
||
this.onError,
|
||
this.emptyWidget,
|
||
this.debounceDuration = const Duration(milliseconds: 350),
|
||
});
|
||
|
||
@override
|
||
State<SearchBar<T>> createState() => _SearchBarState<T>();
|
||
}
|
||
|
||
class _SearchBarState<T> extends State<SearchBar<T>> {
|
||
final TextEditingController _textController = TextEditingController();
|
||
final FocusNode _focusNode = FocusNode();
|
||
Timer? _debounce;
|
||
List<T> _results = <T>[];
|
||
bool _loading = false;
|
||
dynamic _error;
|
||
bool _hasSearched = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
widget.searchBarController?._register(_runSearch);
|
||
_textController.addListener(_onTextChanged);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_debounce?.cancel();
|
||
_textController.dispose();
|
||
_focusNode.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onTextChanged() {
|
||
final text = _textController.text;
|
||
_debounce?.cancel();
|
||
if (text.length >= widget.minimumChars) {
|
||
_debounce = Timer(widget.debounceDuration, _runSearch);
|
||
} else if (text.isEmpty) {
|
||
setState(() {
|
||
_results = <T>[];
|
||
_hasSearched = false;
|
||
_error = null;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _runSearch() async {
|
||
final query = _textController.text;
|
||
if (query.length < widget.minimumChars) return;
|
||
if (widget.searchBarController != null) {
|
||
widget.searchBarController!.lastQuery = query;
|
||
}
|
||
setState(() {
|
||
_loading = true;
|
||
_error = null;
|
||
_hasSearched = true;
|
||
});
|
||
try {
|
||
final results = await widget.onSearch(query);
|
||
if (mounted) {
|
||
setState(() {
|
||
_results = results;
|
||
_loading = false;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_error = e;
|
||
_loading = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
void _cancel() {
|
||
_textController.clear();
|
||
_focusNode.unfocus();
|
||
setState(() {
|
||
_results = <T>[];
|
||
_hasSearched = false;
|
||
_error = null;
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
children: <Widget>[
|
||
TextField(
|
||
controller: _textController,
|
||
focusNode: _focusNode,
|
||
decoration: InputDecoration(
|
||
hintText: widget.hintText,
|
||
prefixIcon: const Icon(Icons.search),
|
||
suffixIcon: _textController.text.isNotEmpty
|
||
? GestureDetector(
|
||
onTap: _cancel,
|
||
child: widget.cancellationWidget ??
|
||
const Icon(Icons.close, size: 20),
|
||
)
|
||
: null,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Expanded(child: _buildBody()),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildBody() {
|
||
if (_loading) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (_error != null) {
|
||
return widget.onError != null
|
||
? widget.onError!(_error)
|
||
: Center(child: Text('$_error'));
|
||
}
|
||
if (!_hasSearched) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
if (_results.isEmpty) {
|
||
return widget.emptyWidget ?? const SizedBox.shrink();
|
||
}
|
||
return ListView.builder(
|
||
itemCount: _results.length,
|
||
itemBuilder: (BuildContext context, int index) =>
|
||
widget.onItemFound(_results[index], index),
|
||
);
|
||
}
|
||
}
|