connection_screen.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import 'dart:async';
  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 'home_shell.dart';
  8. import 'privacy_policy_screen.dart';
  9. class ConnectionScreen extends StatefulWidget {
  10. const ConnectionScreen({super.key, required this.settings, this.httpClient});
  11. final SettingsStore settings;
  12. final http.Client? httpClient;
  13. @override
  14. State<ConnectionScreen> createState() => _ConnectionScreenState();
  15. }
  16. class _ConnectionScreenState extends State<ConnectionScreen> {
  17. final _baseUrl = TextEditingController();
  18. final _apiKey = TextEditingController();
  19. late final http.Client _client = widget.httpClient ?? http.Client();
  20. Timer? _probeTimer;
  21. int _probeToken = 0;
  22. bool _busy = false;
  23. bool _probing = false;
  24. bool _showApiKey = false;
  25. bool _statusUnavailable = false;
  26. String? _error;
  27. String? _reachabilityError;
  28. ApiStatus? _serverStatus;
  29. @override
  30. void dispose() {
  31. _probeTimer?.cancel();
  32. _baseUrl.dispose();
  33. _apiKey.dispose();
  34. if (widget.httpClient == null) _client.close();
  35. super.dispose();
  36. }
  37. void _onAddressChanged(String value) {
  38. _probeTimer?.cancel();
  39. final token = ++_probeToken;
  40. final canProbe = serverUrlCandidates(value).isNotEmpty;
  41. setState(() {
  42. _probing = canProbe;
  43. _serverStatus = null;
  44. _statusUnavailable = false;
  45. _reachabilityError = null;
  46. _error = null;
  47. });
  48. if (!canProbe) return;
  49. _probeTimer = Timer(const Duration(milliseconds: 600), () {
  50. _checkAddress(value, token);
  51. });
  52. }
  53. Future<({String baseUrl, ApiStatus? status})> _probeAddress(
  54. String address,
  55. ) async {
  56. final candidates = serverUrlCandidates(address);
  57. if (candidates.isEmpty) {
  58. throw ApiException('Enter a server address, optionally with a port.');
  59. }
  60. ApiException? lastError;
  61. for (final candidate in candidates) {
  62. try {
  63. final status = await RackPeekApi(
  64. baseUrl: candidate,
  65. apiKey: '',
  66. client: _client,
  67. ).getStatus();
  68. return (baseUrl: candidate, status: status);
  69. } on ApiException catch (e) {
  70. if (e.statusCode == 404) {
  71. return (baseUrl: candidate, status: null);
  72. }
  73. lastError = e;
  74. }
  75. }
  76. throw lastError ?? ApiException('Could not reach a RackPeek server.');
  77. }
  78. Future<void> _checkAddress(String address, int token) async {
  79. try {
  80. final result = await _probeAddress(address);
  81. if (!mounted || token != _probeToken) return;
  82. setState(() {
  83. _serverStatus = result.status;
  84. _statusUnavailable = result.status == null;
  85. _reachabilityError = null;
  86. _probing = false;
  87. });
  88. } on ApiException catch (e) {
  89. if (!mounted || token != _probeToken) return;
  90. setState(() {
  91. _reachabilityError = e.message;
  92. _serverStatus = null;
  93. _statusUnavailable = false;
  94. _probing = false;
  95. });
  96. }
  97. }
  98. Future<void> _connect() async {
  99. final address = _baseUrl.text.trim();
  100. final apiKey = _apiKey.text.trim();
  101. if (serverUrlCandidates(address).isEmpty) {
  102. setState(
  103. () => _error = 'Enter a server address, optionally with a port.',
  104. );
  105. return;
  106. }
  107. if (apiKey.isEmpty) {
  108. setState(() => _error = 'Enter the API key (RPK_API_KEY on the server).');
  109. return;
  110. }
  111. _probeTimer?.cancel();
  112. final token = ++_probeToken;
  113. setState(() {
  114. _busy = true;
  115. _probing = true;
  116. _error = null;
  117. _reachabilityError = null;
  118. });
  119. try {
  120. final result = await _probeAddress(address);
  121. if (!mounted || token != _probeToken) return;
  122. setState(() {
  123. _serverStatus = result.status;
  124. _statusUnavailable = result.status == null;
  125. _probing = false;
  126. });
  127. final api = RackPeekApi(
  128. baseUrl: result.baseUrl,
  129. apiKey: apiKey,
  130. client: _client,
  131. );
  132. await api.getSummary();
  133. final shouldContinue = await _resolveLocalChanges(api, result.baseUrl);
  134. if (shouldContinue != true) return;
  135. await widget.settings.save(result.baseUrl, apiKey);
  136. if (!mounted) return;
  137. Navigator.of(context).pushAndRemoveUntil(
  138. MaterialPageRoute(
  139. builder: (_) => HomeShell(
  140. settings: widget.settings,
  141. baseUrl: result.baseUrl,
  142. apiKey: apiKey,
  143. httpClient: widget.httpClient,
  144. ),
  145. ),
  146. (route) => false,
  147. );
  148. } on ApiException catch (e) {
  149. if (!mounted) return;
  150. setState(() {
  151. _error = e.message;
  152. _probing = false;
  153. });
  154. } finally {
  155. if (mounted) setState(() => _busy = false);
  156. }
  157. }
  158. Future<bool?> _resolveLocalChanges(RackPeekApi api, String baseUrl) async {
  159. final state = await widget.settings.loadOfflineState();
  160. final pending = state?['pendingChanges'];
  161. final hasPending = pending is List && pending.isNotEmpty;
  162. var upload = false;
  163. if (hasPending) {
  164. if (!mounted) return false;
  165. final choice = await showDialog<bool>(
  166. context: context,
  167. barrierDismissible: false,
  168. builder: (dialogContext) => AlertDialog(
  169. title: const Text('Local changes found'),
  170. content: Text(
  171. '${pending.length} change(s) are saved on this phone. '
  172. 'Upload them to $baseUrl, or discard them and use the server inventory?',
  173. ),
  174. actions: [
  175. TextButton(
  176. onPressed: () => Navigator.pop(dialogContext, null),
  177. child: const Text('Cancel'),
  178. ),
  179. TextButton(
  180. onPressed: () => Navigator.pop(dialogContext, false),
  181. child: const Text('Discard local changes'),
  182. ),
  183. FilledButton(
  184. onPressed: () => Navigator.pop(dialogContext, true),
  185. child: const Text('Upload to server'),
  186. ),
  187. ],
  188. ),
  189. );
  190. if (choice == null) return null;
  191. upload = choice;
  192. if (upload) {
  193. final rawDocument = state?['document'];
  194. final version = rawDocument is Map
  195. ? rawDocument['version'] as int? ?? 5
  196. : 5;
  197. for (final change in pending.whereType<Map>()) {
  198. await api.upsert({
  199. 'version': version,
  200. 'resources': [Map<String, dynamic>.from(change)],
  201. });
  202. }
  203. }
  204. }
  205. final serverDocument = await api.getDocument();
  206. await widget.settings.saveOfflineState({
  207. 'document': serverDocument.toJson(),
  208. 'pendingChanges': <Map<String, dynamic>>[],
  209. });
  210. return true;
  211. }
  212. Widget _reachabilityIndicator() {
  213. if (_probing) {
  214. return const Row(
  215. children: [
  216. SizedBox(
  217. width: 16,
  218. height: 16,
  219. child: CircularProgressIndicator(strokeWidth: 2),
  220. ),
  221. SizedBox(width: 10),
  222. Expanded(child: Text('Checking server…')),
  223. ],
  224. );
  225. }
  226. if (_serverStatus case final status?) {
  227. return Row(
  228. children: [
  229. const Icon(Icons.check_circle, color: ScribeColors.success, size: 18),
  230. const SizedBox(width: 8),
  231. Expanded(
  232. child: Text(
  233. 'Server reachable · API v${status.apiVersion} · ${status.appVersion}',
  234. style: const TextStyle(color: ScribeColors.success),
  235. ),
  236. ),
  237. ],
  238. );
  239. }
  240. if (_statusUnavailable) {
  241. return const Row(
  242. children: [
  243. Icon(Icons.info_outline, color: ScribeColors.secondaryText, size: 18),
  244. SizedBox(width: 8),
  245. Expanded(child: Text('Server reachable · API version unavailable')),
  246. ],
  247. );
  248. }
  249. if (_reachabilityError case final message?) {
  250. return Row(
  251. children: [
  252. const Icon(Icons.error_outline, color: ScribeColors.danger, size: 18),
  253. const SizedBox(width: 8),
  254. Expanded(
  255. child: Text(
  256. message,
  257. style: const TextStyle(color: ScribeColors.danger),
  258. ),
  259. ),
  260. ],
  261. );
  262. }
  263. return const Text(
  264. 'HTTPS is tried first; HTTP is used if TLS is unavailable.',
  265. style: ScribeType.metadata,
  266. );
  267. }
  268. @override
  269. Widget build(BuildContext context) {
  270. return Scaffold(
  271. body: ScribeSheet(
  272. children: [
  273. const SizedBox(height: 32),
  274. const Center(child: Wordmark()),
  275. const Text(
  276. 'Your infrastructure, in your pocket. Connect to a RackPeek server '
  277. 'or skip to build and manage a local inventory.',
  278. textAlign: TextAlign.center,
  279. style: ScribeType.body,
  280. ),
  281. SurfacePanel(
  282. child: Column(
  283. crossAxisAlignment: CrossAxisAlignment.stretch,
  284. children: [
  285. const SectionLabel('Server'),
  286. const SizedBox(height: ScribeSpacing.relatedGap),
  287. TextField(
  288. controller: _baseUrl,
  289. enabled: !_busy,
  290. keyboardType: TextInputType.url,
  291. autocorrect: false,
  292. onChanged: _onAddressChanged,
  293. decoration: const InputDecoration(
  294. hintText: '192.168.1.10:8080 or server.example.com',
  295. labelText: 'Server address',
  296. ),
  297. ),
  298. const SizedBox(height: ScribeSpacing.relatedGap),
  299. _reachabilityIndicator(),
  300. const SizedBox(height: ScribeSpacing.majorSectionGap),
  301. const SectionLabel('API key'),
  302. const SizedBox(height: ScribeSpacing.relatedGap),
  303. TextField(
  304. controller: _apiKey,
  305. enabled: !_busy,
  306. keyboardType: TextInputType.visiblePassword,
  307. obscureText: !_showApiKey,
  308. autocorrect: false,
  309. enableSuggestions: false,
  310. decoration: InputDecoration(
  311. labelText: 'API key',
  312. helperText: 'Stored securely on this device.',
  313. suffixIcon: IconButton(
  314. tooltip: _showApiKey ? 'Hide API key' : 'Show API key',
  315. onPressed: () =>
  316. setState(() => _showApiKey = !_showApiKey),
  317. icon: Icon(
  318. _showApiKey ? Icons.visibility_off : Icons.visibility,
  319. ),
  320. ),
  321. ),
  322. ),
  323. const SizedBox(height: ScribeSpacing.majorSectionGap),
  324. ScribeAction(
  325. label: 'Connect',
  326. busy: _busy,
  327. onPressed: _connect,
  328. ),
  329. const SizedBox(height: ScribeSpacing.relatedGap),
  330. ScribeAction(
  331. label: 'Skip and use offline',
  332. secondary: true,
  333. busy: _busy,
  334. onPressed: _useOffline,
  335. ),
  336. ],
  337. ),
  338. ),
  339. if (_error != null) InlineNotice(message: _error!, isError: true),
  340. Center(
  341. child: TextButton(
  342. onPressed: () => Navigator.of(context).push(
  343. MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen()),
  344. ),
  345. child: const Text('Privacy policy'),
  346. ),
  347. ),
  348. ],
  349. ),
  350. );
  351. }
  352. Future<void> _useOffline() async {
  353. await widget.settings.useLocalMode();
  354. if (!mounted) return;
  355. Navigator.of(context).pushReplacement(
  356. MaterialPageRoute(builder: (_) => HomeShell(settings: widget.settings)),
  357. );
  358. }
  359. }