home_shell.dart 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import 'package:flutter/cupertino.dart';
  2. import 'package:flutter/material.dart';
  3. import 'package:http/http.dart' as http;
  4. import '../data/api_client.dart';
  5. import '../data/settings_store.dart';
  6. import '../design.dart';
  7. import '../state/inventory_store.dart';
  8. import 'connection_screen.dart';
  9. import 'privacy_policy_screen.dart';
  10. import 'resources_tab.dart';
  11. import 'summary_tab.dart';
  12. import 'tree_tab.dart';
  13. class HomeShell extends StatefulWidget {
  14. const HomeShell({
  15. super.key,
  16. required this.settings,
  17. this.baseUrl,
  18. this.apiKey,
  19. this.httpClient,
  20. });
  21. final SettingsStore settings;
  22. final String? baseUrl;
  23. final String? apiKey;
  24. final http.Client? httpClient;
  25. @override
  26. State<HomeShell> createState() => _HomeShellState();
  27. }
  28. class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
  29. late final InventoryStore _store;
  30. int _index = 0;
  31. ResourceFilter _resourceFilter = const ResourceFilter();
  32. int _filterRevision = 0;
  33. void _openResources(ResourceFilter filter) {
  34. setState(() {
  35. _resourceFilter = filter;
  36. _filterRevision++;
  37. _index = 1;
  38. });
  39. }
  40. @override
  41. void initState() {
  42. super.initState();
  43. WidgetsBinding.instance.addObserver(this);
  44. _store = InventoryStore(
  45. widget.baseUrl != null && widget.apiKey != null
  46. ? RackPeekApi(
  47. baseUrl: widget.baseUrl!,
  48. apiKey: widget.apiKey!,
  49. client: widget.httpClient,
  50. )
  51. : null,
  52. settings: widget.settings,
  53. );
  54. _store.refresh();
  55. }
  56. @override
  57. void dispose() {
  58. WidgetsBinding.instance.removeObserver(this);
  59. _store.dispose();
  60. super.dispose();
  61. }
  62. @override
  63. void didChangeAppLifecycleState(AppLifecycleState state) {
  64. if (state == AppLifecycleState.resumed && widget.baseUrl != null) {
  65. _store.refresh();
  66. }
  67. }
  68. Future<void> _openSettings() async {
  69. await showModalBottomSheet<void>(
  70. context: context,
  71. showDragHandle: true,
  72. builder: (sheetContext) => SafeArea(
  73. child: Column(
  74. mainAxisSize: MainAxisSize.min,
  75. crossAxisAlignment: CrossAxisAlignment.stretch,
  76. children: [
  77. Padding(
  78. padding: const EdgeInsets.symmetric(
  79. horizontal: ScribeSpacing.panelPadding,
  80. ),
  81. child: Column(
  82. crossAxisAlignment: CrossAxisAlignment.start,
  83. children: [
  84. Text(
  85. widget.baseUrl == null ? 'Offline mode' : 'Connected to',
  86. style: ScribeType.sectionLabel,
  87. ),
  88. const SizedBox(height: 4),
  89. Text(
  90. widget.baseUrl ?? 'Inventory stored on this device',
  91. style: ScribeType.body,
  92. ),
  93. if (widget.baseUrl != null && !_store.isOnline) ...[
  94. const SizedBox(height: 6),
  95. const Text(
  96. 'Server unavailable. Edits will be saved here and synced when it returns.',
  97. style: ScribeType.metadata,
  98. ),
  99. ],
  100. if (_store.hasPendingChanges) ...[
  101. const SizedBox(height: 6),
  102. Text(
  103. '${_store.pendingChangeCount} local change(s) waiting to sync',
  104. style: ScribeType.metadata,
  105. ),
  106. ],
  107. const SizedBox(height: ScribeSpacing.majorSectionGap),
  108. ScribeAction(
  109. label: 'Privacy policy',
  110. secondary: true,
  111. onPressed: () {
  112. Navigator.of(sheetContext).pop();
  113. Navigator.of(context).push(
  114. MaterialPageRoute(
  115. builder: (_) => const PrivacyPolicyScreen(),
  116. ),
  117. );
  118. },
  119. ),
  120. const SizedBox(height: ScribeSpacing.relatedGap),
  121. ScribeAction(
  122. label: 'Refresh inventory',
  123. secondary: true,
  124. onPressed: () {
  125. _store.refresh();
  126. Navigator.of(sheetContext).pop();
  127. },
  128. ),
  129. const SizedBox(height: ScribeSpacing.relatedGap),
  130. if (_store.hasPendingChanges && widget.baseUrl != null)
  131. ScribeAction(
  132. label: 'Sync changes now',
  133. secondary: true,
  134. onPressed: () {
  135. _store.refresh();
  136. Navigator.of(sheetContext).pop();
  137. },
  138. ),
  139. if (widget.baseUrl == null)
  140. ScribeAction(
  141. label: 'Connect to a server',
  142. onPressed: () {
  143. Navigator.of(sheetContext).pop();
  144. Navigator.of(context).push(
  145. MaterialPageRoute(
  146. builder: (_) =>
  147. ConnectionScreen(settings: widget.settings),
  148. ),
  149. );
  150. },
  151. )
  152. else
  153. ScribeAction(
  154. label: 'Use offline mode',
  155. danger: true,
  156. onPressed: () async {
  157. await widget.settings.useLocalMode();
  158. if (!sheetContext.mounted) return;
  159. Navigator.of(sheetContext).pop();
  160. if (!mounted) return;
  161. Navigator.of(context).pushAndRemoveUntil(
  162. MaterialPageRoute(
  163. builder: (_) =>
  164. HomeShell(settings: widget.settings),
  165. ),
  166. (route) => false,
  167. );
  168. },
  169. ),
  170. ],
  171. ),
  172. ),
  173. const SizedBox(height: ScribeSpacing.majorSectionGap),
  174. ],
  175. ),
  176. ),
  177. );
  178. }
  179. @override
  180. Widget build(BuildContext context) {
  181. return AnimatedBuilder(
  182. animation: _store,
  183. builder: (context, _) {
  184. return Scaffold(
  185. appBar: AppBar(
  186. backgroundColor: ScribeColors.canvas,
  187. elevation: 0,
  188. scrolledUnderElevation: 0,
  189. title: const Wordmark(),
  190. actions: [
  191. IconButton(
  192. tooltip: 'Settings',
  193. icon: const Icon(CupertinoIcons.gear),
  194. onPressed: _openSettings,
  195. ),
  196. ],
  197. ),
  198. body: Column(
  199. children: [
  200. if (_store.hasPendingChanges ||
  201. (widget.baseUrl != null && !_store.isOnline))
  202. _syncBanner(),
  203. Expanded(
  204. child: IndexedStack(
  205. index: _index,
  206. children: [
  207. SummaryTab(store: _store, onOpenResources: _openResources),
  208. ResourcesTab(
  209. store: _store,
  210. filter: _resourceFilter,
  211. filterRevision: _filterRevision,
  212. ),
  213. TreeTab(store: _store),
  214. ],
  215. ),
  216. ),
  217. ],
  218. ),
  219. bottomNavigationBar: NavigationBar(
  220. backgroundColor: ScribeColors.surface,
  221. selectedIndex: _index,
  222. onDestinationSelected: (i) => setState(() => _index = i),
  223. destinations: const [
  224. NavigationDestination(
  225. icon: Icon(CupertinoIcons.chart_bar),
  226. label: 'Summary',
  227. ),
  228. NavigationDestination(
  229. icon: Icon(CupertinoIcons.list_bullet),
  230. label: 'Resources',
  231. ),
  232. NavigationDestination(
  233. icon: Icon(CupertinoIcons.tree),
  234. label: 'Tree',
  235. ),
  236. ],
  237. ),
  238. );
  239. },
  240. );
  241. }
  242. Widget _syncBanner() {
  243. final message = widget.baseUrl == null
  244. ? 'Offline · ${_store.pendingChangeCount} change(s) saved on this device'
  245. : !_store.isOnline
  246. ? 'Server unavailable · changes save here and sync when it returns'
  247. : '${_store.pendingChangeCount} change(s) syncing to server';
  248. return Container(
  249. width: double.infinity,
  250. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  251. color: ScribeColors.surface,
  252. child: Text(message, style: ScribeType.metadata),
  253. );
  254. }
  255. }