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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|