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

338 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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