ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
337
lib/widgets/org_picker_sheet.dart
Normal file
337
lib/widgets/org_picker_sheet.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/org_node.dart';
|
||||
|
||||
/// 组织选择项:携带层级深度与是否可选,用于按组织树自上而下排序并缩进展示。
|
||||
class OrgPickerOption {
|
||||
const OrgPickerOption({
|
||||
required this.node,
|
||||
this.depth = 0,
|
||||
this.selectable = true,
|
||||
});
|
||||
|
||||
final OrgNode node;
|
||||
final int depth;
|
||||
final bool selectable;
|
||||
}
|
||||
|
||||
/// 组织选择器:以底部弹窗形式展示层级组织列表,支持关键字实时搜索,
|
||||
/// 选中后通过 Navigator.pop 返回对应的 [OrgPickerOption]。
|
||||
class OrgPickerSheet extends StatefulWidget {
|
||||
const OrgPickerSheet({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.searchHint,
|
||||
required this.emptyHint,
|
||||
required this.options,
|
||||
this.selectedId,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String searchHint;
|
||||
final String emptyHint;
|
||||
final List<OrgPickerOption> options;
|
||||
final String? selectedId;
|
||||
|
||||
@override
|
||||
State<OrgPickerSheet> createState() => _OrgPickerSheetState();
|
||||
}
|
||||
|
||||
class _OrgPickerSheetState extends State<OrgPickerSheet> {
|
||||
final _searchController = TextEditingController();
|
||||
String _keyword = '';
|
||||
String? _selectedId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedId = widget.selectedId;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSelect(OrgPickerOption option) {
|
||||
if (_selectedId == option.node.id) {
|
||||
Navigator.pop(context, option);
|
||||
return;
|
||||
}
|
||||
setState(() => _selectedId = option.node.id);
|
||||
Future.delayed(const Duration(milliseconds: 120), () {
|
||||
if (mounted) Navigator.pop(context, option);
|
||||
});
|
||||
}
|
||||
|
||||
List<OrgPickerOption> get _filteredOptions {
|
||||
final keyword = _keyword.trim().toLowerCase();
|
||||
final list = widget.options.where((option) {
|
||||
if (!option.selectable) return false;
|
||||
if (keyword.isEmpty) return true;
|
||||
return option.node.name.toLowerCase().contains(keyword);
|
||||
}).toList();
|
||||
// 将当前已选中的组织置顶展示,方便快速找到并确认当前选择。
|
||||
if (_selectedId?.isNotEmpty == true) {
|
||||
final selectedIndex = list.indexWhere((o) => o.node.id == _selectedId);
|
||||
if (selectedIndex > 0) {
|
||||
final selected = list.removeAt(selectedIndex);
|
||||
list.insert(0, selected);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final filtered = _filteredOptions;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 标题栏
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 搜索框
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _keyword = '');
|
||||
},
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
onChanged: (v) => setState(() => _keyword = v),
|
||||
),
|
||||
),
|
||||
// 结果列表
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
child: filtered.isEmpty
|
||||
? SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.emptyHint,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final option = filtered[index];
|
||||
final selected = _selectedId == option.node.id;
|
||||
final baseStyle = theme.textTheme.bodyMedium;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: 16 + option.depth * 16.0,
|
||||
right: 16,
|
||||
),
|
||||
title: Text(
|
||||
option.node.name,
|
||||
style: option.selectable
|
||||
? baseStyle?.copyWith(
|
||||
color: selected
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
fontWeight: selected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
)
|
||||
: baseStyle?.copyWith(
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
),
|
||||
trailing: selected
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: theme.colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
selected: selected,
|
||||
selectedTileColor: theme.colorScheme.primaryContainer
|
||||
.withOpacity(0.38),
|
||||
onTap: option.selectable
|
||||
? () => _onSelect(option)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// 底部安全区
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单中的「组织选择」输入框:点击后弹出 [OrgPickerSheet],
|
||||
/// 进入页面时自动展开选择器并高亮已选项。
|
||||
class OrgPickerFormField extends StatefulWidget {
|
||||
const OrgPickerFormField({
|
||||
super.key,
|
||||
this.value,
|
||||
required this.labelText,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
this.validator,
|
||||
this.autoOpen = true,
|
||||
this.searchHint,
|
||||
this.emptyHint,
|
||||
});
|
||||
|
||||
final String? value;
|
||||
final String labelText;
|
||||
final List<OrgPickerOption> options;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final String? Function(String?)? validator;
|
||||
final bool autoOpen;
|
||||
final String? searchHint;
|
||||
final String? emptyHint;
|
||||
|
||||
@override
|
||||
State<OrgPickerFormField> createState() => _OrgPickerFormFieldState();
|
||||
}
|
||||
|
||||
class _OrgPickerFormFieldState extends State<OrgPickerFormField> {
|
||||
bool _autoOpened = false;
|
||||
FormFieldState<String>? _field;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scheduleAutoOpen();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant OrgPickerFormField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 外部 value 变更时同步到 FormField 状态。
|
||||
if (oldWidget.value != widget.value) {
|
||||
_field?.didChange(widget.value);
|
||||
}
|
||||
// 选项从空变为有值时(异步加载完成),立即自动展开选择器。
|
||||
if (oldWidget.options.isEmpty && widget.options.isNotEmpty) {
|
||||
_scheduleAutoOpen();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleAutoOpen() {
|
||||
if (!_shouldAutoOpen) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _shouldAutoOpen) {
|
||||
_autoOpened = true;
|
||||
_openPicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool get _shouldAutoOpen =>
|
||||
widget.autoOpen && widget.options.isNotEmpty && !_autoOpened;
|
||||
|
||||
Future<void> _openPicker() async {
|
||||
if (!mounted) return;
|
||||
final selected = await showModalBottomSheet<OrgPickerOption>(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => OrgPickerSheet(
|
||||
title: widget.labelText,
|
||||
searchHint: widget.searchHint ?? '搜索组织名称',
|
||||
emptyHint: widget.emptyHint ?? '未找到匹配组织',
|
||||
options: widget.options,
|
||||
selectedId: widget.value,
|
||||
),
|
||||
);
|
||||
if (selected != null && mounted) {
|
||||
_field?.didChange(selected.node.id);
|
||||
widget.onChanged(selected.node.id);
|
||||
}
|
||||
}
|
||||
|
||||
String? _displayName(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
for (final option in widget.options) {
|
||||
if (option.node.id == value) return option.node.name;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormField<String>(
|
||||
initialValue: widget.value,
|
||||
validator: widget.validator,
|
||||
builder: (field) {
|
||||
_field = field;
|
||||
final displayName = _displayName(field.value);
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: _openPicker,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.account_tree),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||
errorText: field.errorText,
|
||||
),
|
||||
child: Text(
|
||||
displayName ?? widget.labelText,
|
||||
style: TextStyle(
|
||||
color: displayName == null ? theme.hintColor : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user