Files
CloudHome-Ops/lib/widgets/org_picker_sheet.dart

338 lines
11 KiB
Dart
Raw Normal View History

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