338 lines
11 KiB
Dart
338 lines
11 KiB
Dart
|
|
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,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|