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 options; final String? selectedId; @override State createState() => _OrgPickerSheetState(); } class _OrgPickerSheetState extends State { 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 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 options; final ValueChanged onChanged; final String? Function(String?)? validator; final bool autoOpen; final String? searchHint; final String? emptyHint; @override State createState() => _OrgPickerFormFieldState(); } class _OrgPickerFormFieldState extends State { bool _autoOpened = false; FormFieldState? _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 _openPicker() async { if (!mounted) return; final selected = await showModalBottomSheet( 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( 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, ), ), ), ); }, ); } }