summary_tab.dart 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import 'package:flutter/material.dart';
  2. import '../design.dart';
  3. import '../state/inventory_store.dart';
  4. import 'resources_tab.dart';
  5. class SummaryTab extends StatelessWidget {
  6. const SummaryTab({
  7. super.key,
  8. required this.store,
  9. required this.onOpenResources,
  10. });
  11. final InventoryStore store;
  12. final ValueChanged<ResourceFilter> onOpenResources;
  13. @override
  14. Widget build(BuildContext context) {
  15. if (store.loadError != null) {
  16. return ScribeSheet(
  17. children: [
  18. InlineNotice(
  19. message: store.loadError.toString(),
  20. isError: true,
  21. onRetry: store.refresh,
  22. ),
  23. ],
  24. );
  25. }
  26. final summary = store.summary;
  27. if (summary == null) return const ScribeSpinner();
  28. final hardware = summary['hardware'] as Map<String, dynamic>;
  29. final systems = summary['systems'] as Map<String, dynamic>;
  30. final services = summary['services'] as Map<String, dynamic>;
  31. return RefreshIndicator(
  32. onRefresh: store.refresh,
  33. child: ScribeSheet(
  34. horizontalPadding: 14,
  35. sectionGap: 12,
  36. children: [
  37. const Text('Infrastructure', style: ScribeType.hero),
  38. _countPanel(
  39. 'Hardware',
  40. hardware,
  41. 'totalHardware',
  42. 'hardwareByKind',
  43. totalFilter: const ResourceFilter(hardwareOnly: true),
  44. itemFilter: (kind) =>
  45. ResourceFilter(kind: resourceKindForSummary(kind)),
  46. ),
  47. _countPanel(
  48. 'Systems',
  49. systems,
  50. 'totalSystems',
  51. 'systemsByType',
  52. totalFilter: const ResourceFilter(kind: 'System'),
  53. itemFilter: (type) =>
  54. ResourceFilter(kind: 'System', systemType: type),
  55. ),
  56. _breakdownPanel(
  57. 'Operating systems',
  58. systems['systemsByOs'] as Map<String, dynamic>? ?? {},
  59. (os) => ResourceFilter(kind: 'System', systemOs: os),
  60. ),
  61. _servicesPanel(services),
  62. _tagPanel(
  63. 'Tags',
  64. summary['tags'] as Map<String, dynamic>? ?? {},
  65. (tag) => ResourceFilter(tag: tag),
  66. ),
  67. _tagPanel(
  68. 'Labels',
  69. summary['labels'] as Map<String, dynamic>? ?? {},
  70. (key) => ResourceFilter(labelKey: key),
  71. ),
  72. ],
  73. ),
  74. );
  75. }
  76. Widget _countPanel(
  77. String title,
  78. Map<String, dynamic> section,
  79. String totalKey,
  80. String byKey, {
  81. required ResourceFilter totalFilter,
  82. required ResourceFilter Function(String key) itemFilter,
  83. }) {
  84. final total = section[totalKey] as int? ?? 0;
  85. final breakdown = (section[byKey] as Map<String, dynamic>? ?? {}).map(
  86. (k, v) => MapEntry(k, (v as num).toInt()),
  87. );
  88. return SurfacePanel(
  89. padding: const EdgeInsets.all(12),
  90. child: Column(
  91. crossAxisAlignment: CrossAxisAlignment.start,
  92. children: [
  93. SectionLabel(title),
  94. const SizedBox(height: ScribeSpacing.relatedGap),
  95. InkWell(
  96. borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
  97. onTap: () => onOpenResources(totalFilter),
  98. child: Padding(
  99. padding: const EdgeInsets.symmetric(vertical: 4),
  100. child: Row(
  101. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  102. children: [
  103. Text('$total', style: ScribeType.section),
  104. Row(
  105. children: [
  106. Text('$total total', style: ScribeType.metadata),
  107. const SizedBox(width: 4),
  108. const Icon(Icons.chevron_right, size: 18),
  109. ],
  110. ),
  111. ],
  112. ),
  113. ),
  114. ),
  115. if (breakdown.isNotEmpty) ...[
  116. const SizedBox(height: ScribeSpacing.relatedGap),
  117. Wrap(
  118. spacing: 8,
  119. runSpacing: 8,
  120. children: [
  121. for (final entry in breakdown.entries)
  122. FilterPill(
  123. label:
  124. '${entry.value} ${resourcePluralForSummary(entry.key)}',
  125. selected: false,
  126. onTap: () => onOpenResources(itemFilter(entry.key)),
  127. ),
  128. ],
  129. ),
  130. ],
  131. ],
  132. ),
  133. );
  134. }
  135. Widget _breakdownPanel(
  136. String title,
  137. Map<String, dynamic> counts,
  138. ResourceFilter Function(String key) filterFor,
  139. ) {
  140. if (counts.isEmpty) return const SizedBox.shrink();
  141. return SurfacePanel(
  142. padding: const EdgeInsets.all(12),
  143. child: Column(
  144. crossAxisAlignment: CrossAxisAlignment.start,
  145. children: [
  146. SectionLabel(title),
  147. const SizedBox(height: ScribeSpacing.relatedGap),
  148. Wrap(
  149. spacing: 8,
  150. runSpacing: 8,
  151. children: [
  152. for (final entry in counts.entries)
  153. FilterPill(
  154. label: '${entry.value} ${entry.key}',
  155. selected: false,
  156. onTap: () => onOpenResources(filterFor(entry.key)),
  157. ),
  158. ],
  159. ),
  160. ],
  161. ),
  162. );
  163. }
  164. Widget _servicesPanel(Map<String, dynamic> services) {
  165. final total = services['totalServices'] as int? ?? 0;
  166. final totalIpAddresses = services['totalIpAddresses'] as int? ?? 0;
  167. return SurfacePanel(
  168. padding: const EdgeInsets.all(12),
  169. child: Row(
  170. children: [
  171. Expanded(
  172. child: _serviceCount(
  173. total,
  174. 'services',
  175. const ResourceFilter(kind: 'Service'),
  176. ),
  177. ),
  178. Expanded(child: _serviceCount(totalIpAddresses, 'IP addresses')),
  179. ],
  180. ),
  181. );
  182. }
  183. Widget _serviceCount(int count, String label, [ResourceFilter? filter]) {
  184. return InkWell(
  185. borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
  186. onTap: filter == null ? null : () => onOpenResources(filter),
  187. child: Padding(
  188. padding: const EdgeInsets.symmetric(vertical: 4),
  189. child: Row(
  190. children: [
  191. Column(
  192. crossAxisAlignment: CrossAxisAlignment.start,
  193. children: [
  194. Text('$count', style: ScribeType.section),
  195. Text(label, style: ScribeType.metadata),
  196. ],
  197. ),
  198. const SizedBox(width: 4),
  199. if (filter != null) const Icon(Icons.chevron_right, size: 18),
  200. ],
  201. ),
  202. ),
  203. );
  204. }
  205. Widget _tagPanel(
  206. String title,
  207. Map<String, dynamic> counts,
  208. ResourceFilter Function(String key) filterFor,
  209. ) {
  210. if (counts.isEmpty) return const SizedBox.shrink();
  211. return SurfacePanel(
  212. padding: const EdgeInsets.all(12),
  213. child: Column(
  214. crossAxisAlignment: CrossAxisAlignment.start,
  215. children: [
  216. SectionLabel(title),
  217. const SizedBox(height: ScribeSpacing.relatedGap),
  218. Wrap(
  219. spacing: 8,
  220. runSpacing: 8,
  221. children: [
  222. for (final entry in counts.entries)
  223. FilterPill(
  224. label: '${entry.key} (${entry.value})',
  225. selected: false,
  226. onTap: () => onOpenResources(filterFor(entry.key)),
  227. ),
  228. ],
  229. ),
  230. ],
  231. ),
  232. );
  233. }
  234. }