resources_tab.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import 'package:flutter/material.dart';
  2. import '../design.dart';
  3. import '../models/inventory.dart';
  4. import '../state/inventory_store.dart';
  5. import 'add_resource_screen.dart';
  6. import 'resource_detail_screen.dart';
  7. class ResourceFilter {
  8. const ResourceFilter({
  9. this.kind,
  10. this.hardwareOnly = false,
  11. this.systemType,
  12. this.systemOs,
  13. this.tag,
  14. this.labelKey,
  15. });
  16. final String? kind;
  17. final bool hardwareOnly;
  18. final String? systemType;
  19. final String? systemOs;
  20. final String? tag;
  21. final String? labelKey;
  22. }
  23. const _resourceKinds = [
  24. 'Server',
  25. 'Switch',
  26. 'Router',
  27. 'Firewall',
  28. 'AccessPoint',
  29. 'Desktop',
  30. 'Laptop',
  31. 'Ups',
  32. 'Other',
  33. 'System',
  34. 'Service',
  35. ];
  36. class ResourcesTab extends StatefulWidget {
  37. const ResourcesTab({
  38. super.key,
  39. required this.store,
  40. required this.filter,
  41. required this.filterRevision,
  42. });
  43. final InventoryStore store;
  44. final ResourceFilter filter;
  45. final int filterRevision;
  46. @override
  47. State<ResourcesTab> createState() => _ResourcesTabState();
  48. }
  49. class _ResourcesTabState extends State<ResourcesTab> {
  50. String _query = '';
  51. String? _kind;
  52. bool _hardwareOnly = false;
  53. String? _systemType;
  54. String? _systemOs;
  55. String? _tag;
  56. String? _labelKey;
  57. late final TextEditingController _searchController;
  58. static const _kinds = _resourceKinds;
  59. @override
  60. void initState() {
  61. super.initState();
  62. _searchController = TextEditingController();
  63. _applyFilter(widget.filter);
  64. }
  65. @override
  66. void didUpdateWidget(covariant ResourcesTab oldWidget) {
  67. super.didUpdateWidget(oldWidget);
  68. if (oldWidget.filterRevision != widget.filterRevision) {
  69. _applyFilter(widget.filter);
  70. }
  71. }
  72. @override
  73. void dispose() {
  74. _searchController.dispose();
  75. super.dispose();
  76. }
  77. void _applyFilter(ResourceFilter filter) {
  78. _kind = filter.kind;
  79. _hardwareOnly = filter.hardwareOnly;
  80. _systemType = filter.systemType;
  81. _systemOs = filter.systemOs;
  82. _tag = filter.tag;
  83. _labelKey = filter.labelKey;
  84. _query = '';
  85. _searchController.clear();
  86. }
  87. bool get _hasFilters =>
  88. _kind != null ||
  89. _hardwareOnly ||
  90. _systemType != null ||
  91. _systemOs != null ||
  92. _tag != null ||
  93. _labelKey != null ||
  94. _query.isNotEmpty;
  95. List<Resource> get _visible {
  96. final all = widget.store.document?.resources ?? [];
  97. final query = _query.trim().toLowerCase();
  98. return all.where((r) {
  99. if (_kind != null && r.kind != _kind) return false;
  100. if (_hardwareOnly && !r.isHardware) return false;
  101. if (_systemType != null && r.type != _systemType) return false;
  102. if (_systemOs != null && r.os != _systemOs) return false;
  103. if (_tag != null && !r.tags.contains(_tag)) return false;
  104. if (_labelKey != null && !r.labels.containsKey(_labelKey)) return false;
  105. if (query.isEmpty) return true;
  106. if (r.name.toLowerCase().contains(query)) return true;
  107. if (r.tags.any((t) => t.toLowerCase().contains(query))) return true;
  108. if (r.labels.entries.any(
  109. (e) => '${e.key}: ${e.value}'.toLowerCase().contains(query),
  110. )) {
  111. return true;
  112. }
  113. final ip = r.ip ?? r.network?.ip;
  114. return ip != null && ip.toLowerCase().contains(query);
  115. }).toList()..sort((a, b) {
  116. final byKind = a.kind.compareTo(b.kind);
  117. return byKind != 0 ? byKind : a.name.compareTo(b.name);
  118. });
  119. }
  120. List<String> get _systemOperatingSystems {
  121. final values =
  122. (widget.store.document?.resources ?? [])
  123. .where((resource) => resource.kind == 'System')
  124. .map((resource) => resource.os)
  125. .whereType<String>()
  126. .where((os) => os.trim().isNotEmpty)
  127. .toSet()
  128. .toList()
  129. ..sort();
  130. return values;
  131. }
  132. List<String> get _systemTypes {
  133. final values =
  134. (widget.store.document?.resources ?? [])
  135. .where((resource) => resource.kind == 'System')
  136. .map((resource) => resource.type)
  137. .whereType<String>()
  138. .where((type) => type.trim().isNotEmpty)
  139. .toSet()
  140. .toList()
  141. ..sort();
  142. return values;
  143. }
  144. @override
  145. Widget build(BuildContext context) {
  146. if (widget.store.loadError != null) {
  147. return ScribeSheet(
  148. children: [
  149. InlineNotice(
  150. message: widget.store.loadError.toString(),
  151. isError: true,
  152. onRetry: widget.store.refresh,
  153. ),
  154. ],
  155. );
  156. }
  157. if (!widget.store.loaded) return const ScribeSpinner();
  158. return Scaffold(
  159. floatingActionButton: FloatingActionButton.extended(
  160. heroTag: 'add-resource',
  161. backgroundColor: ScribeColors.accent,
  162. onPressed: () => Navigator.of(context).push(
  163. MaterialPageRoute(
  164. builder: (_) => AddResourceScreen(store: widget.store),
  165. ),
  166. ),
  167. icon: const Icon(Icons.add),
  168. label: const Text('Add'),
  169. ),
  170. body: RefreshIndicator(
  171. onRefresh: widget.store.refresh,
  172. child: ScribeSheet(
  173. horizontalPadding: 12,
  174. sectionGap: 10,
  175. children: [
  176. TextField(
  177. controller: _searchController,
  178. decoration: const InputDecoration(
  179. hintText: 'Search names, IPs, tags, labels',
  180. prefixIcon: Icon(Icons.search),
  181. isDense: true,
  182. contentPadding: EdgeInsets.symmetric(vertical: 10),
  183. ),
  184. onChanged: (value) => setState(() => _query = value),
  185. ),
  186. SizedBox(
  187. height: ScribeSpacing.minTarget,
  188. child: ListView(
  189. scrollDirection: Axis.horizontal,
  190. padding: EdgeInsets.zero,
  191. children: [
  192. FilterPill(
  193. label: 'All',
  194. selected: !_hasFilters,
  195. onTap: () => setState(() {
  196. _applyFilter(const ResourceFilter());
  197. }),
  198. ),
  199. const SizedBox(width: 8),
  200. FilterPill(
  201. label: 'Hardware',
  202. selected: _hardwareOnly,
  203. onTap: () => setState(() {
  204. _applyFilter(const ResourceFilter(hardwareOnly: true));
  205. }),
  206. ),
  207. for (final kind in _kinds) ...[
  208. const SizedBox(width: 8),
  209. FilterPill(
  210. label: _plurals[kind] ?? kind,
  211. selected: _kind == kind,
  212. onTap: () => setState(() {
  213. _kind = kind;
  214. _hardwareOnly = false;
  215. _systemType = null;
  216. _systemOs = null;
  217. }),
  218. ),
  219. ],
  220. ],
  221. ),
  222. ),
  223. if (_kind == 'System') ...[
  224. const SizedBox(height: ScribeSpacing.relatedGap),
  225. SizedBox(
  226. height: ScribeSpacing.minTarget,
  227. child: ListView(
  228. scrollDirection: Axis.horizontal,
  229. padding: EdgeInsets.zero,
  230. children: [
  231. for (final type in _systemTypes) ...[
  232. FilterPill(
  233. label: 'Type: $type',
  234. selected: _systemType == type,
  235. onTap: () => setState(() {
  236. _systemType = _systemType == type ? null : type;
  237. }),
  238. ),
  239. const SizedBox(width: 8),
  240. ],
  241. for (final os in _systemOperatingSystems) ...[
  242. FilterPill(
  243. label: 'OS: $os',
  244. selected: _systemOs == os,
  245. onTap: () => setState(() {
  246. _systemOs = _systemOs == os ? null : os;
  247. }),
  248. ),
  249. const SizedBox(width: 8),
  250. ],
  251. ],
  252. ),
  253. ),
  254. ],
  255. const SizedBox(height: ScribeSpacing.relatedGap),
  256. if (_tag != null || _labelKey != null)
  257. Wrap(
  258. spacing: 8,
  259. runSpacing: 6,
  260. children: [
  261. if (_tag != null)
  262. FilterPill(
  263. label: 'Tag: $_tag',
  264. selected: true,
  265. onTap: () => setState(() => _tag = null),
  266. ),
  267. if (_labelKey != null)
  268. FilterPill(
  269. label: 'Label: $_labelKey',
  270. selected: true,
  271. onTap: () => setState(() => _labelKey = null),
  272. ),
  273. ],
  274. ),
  275. SurfacePanel(padding: EdgeInsets.zero, child: _list(context)),
  276. ],
  277. ),
  278. ),
  279. );
  280. }
  281. Widget _list(BuildContext context) {
  282. final visible = _visible;
  283. if (visible.isEmpty) {
  284. return const Padding(
  285. padding: EdgeInsets.all(ScribeSpacing.panelPadding),
  286. child: Text('Nothing matches.', style: ScribeType.metadata),
  287. );
  288. }
  289. return Column(
  290. children: [
  291. for (var i = 0; i < visible.length; i++) ...[
  292. if (i > 0)
  293. const Divider(
  294. height: 1,
  295. thickness: 1,
  296. color: ScribeColors.separator,
  297. ),
  298. ListTile(
  299. contentPadding: const EdgeInsets.symmetric(
  300. horizontal: ScribeSpacing.panelPadding,
  301. vertical: 4,
  302. ),
  303. leading: CategoryMark(icon: visible[i].glyph),
  304. title: Text(visible[i].name, style: ScribeType.title),
  305. subtitle: Text(visible[i].subtitle, style: ScribeType.metadata),
  306. trailing: const Icon(
  307. Icons.chevron_right,
  308. color: ScribeColors.tertiaryText,
  309. ),
  310. onTap: () => Navigator.of(context).push(
  311. MaterialPageRoute(
  312. builder: (_) => ResourceDetailScreen(
  313. store: widget.store,
  314. name: visible[i].name,
  315. ),
  316. ),
  317. ),
  318. ),
  319. ],
  320. ],
  321. );
  322. }
  323. }
  324. const _plurals = {
  325. 'Server': 'Servers',
  326. 'Switch': 'Switches',
  327. 'Router': 'Routers',
  328. 'Firewall': 'Firewalls',
  329. 'AccessPoint': 'APs',
  330. 'Desktop': 'Desktops',
  331. 'Laptop': 'Laptops',
  332. 'Ups': 'UPS',
  333. 'Other': 'Other',
  334. 'System': 'Systems',
  335. 'Service': 'Services',
  336. };
  337. String resourceKindForSummary(String kind) => _resourceKinds.firstWhere(
  338. (candidate) => candidate.toLowerCase() == kind.toLowerCase(),
  339. orElse: () => kind,
  340. );
  341. String resourcePluralForSummary(String kind) =>
  342. _plurals[resourceKindForSummary(kind)] ?? kind;