feat: Phase 2 — upgrade deps to null-safe, replace deprecated packages
- 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.
This commit is contained in:
185
lib/widgets/general/search_bar.dart
Normal file
185
lib/widgets/general/search_bar.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user