From 521a8baab802c8a95c6f732c803ce7f9473269ea Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 5 May 2026 22:12:47 +0200 Subject: [PATCH] feat(ui): hub-endpoint settings dialog with persistence Studio is no longer hardcoded to localhost:50051. A gear icon in the sidebar footer opens a small settings dialog where the operator picks host / port / TLS-on-or-off. Saved values persist via shared_preferences and Studio reconnects to the new endpoint immediately. - data/hub.dart: HubService.loadPersistedEndpoint() called once before the first frame; reconnect() persists on every change. - widgets/fai_settings_dialog.dart: typed form with port range validation, live URL preview pill, error surfacing if the reconnect fails. - main.dart: gear icon at the sidebar bottom, replaces the static "platform v0.10.42" footer. shared_preferences added as dependency. flutter analyze clean, flutter test 2/2, macOS debug build succeeds. Bumps fai_studio 0.4.0 -> 0.5.0. Signed-off-by: flemming-it --- lib/data/hub.dart | 32 ++- lib/main.dart | 42 +++- lib/widgets/fai_settings_dialog.dart | 212 ++++++++++++++++++ lib/widgets/widgets.dart | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 7 + macos/Runner.xcodeproj/project.pbxproj | 18 ++ pubspec.lock | 61 +++++ pubspec.yaml | 1 + 9 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 lib/widgets/fai_settings_dialog.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 0a4c7e4..6d5d51c 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -6,6 +6,7 @@ // protobuf imports. import 'package:fai_dart_sdk/fai_dart_sdk.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class HubService { HubService._(); @@ -17,11 +18,38 @@ class HubService { /// targets. Shown in the navigation rail. String get endpointLabel => _client.endpoint.toString(); - /// Reconnect to a new endpoint. Useful if the user changes - /// the hub URL at runtime (Phase 1+). + HubEndpoint get currentEndpoint => _client.endpoint; + + static const _kHostKey = 'hub.host'; + static const _kPortKey = 'hub.port'; + static const _kSecureKey = 'hub.secure'; + + /// Read persisted endpoint and reconnect if it differs from + /// the default. Called once at app start. + Future loadPersistedEndpoint() async { + final prefs = await SharedPreferences.getInstance(); + final host = prefs.getString(_kHostKey); + final port = prefs.getInt(_kPortKey); + final secure = prefs.getBool(_kSecureKey); + if (host == null) return; + final endpoint = HubEndpoint( + host: host, + port: port ?? 50051, + secure: secure ?? false, + ); + if (endpoint.toString() != _client.endpoint.toString()) { + await reconnect(endpoint); + } + } + + /// Reconnect to a new endpoint and persist for next launch. Future reconnect(HubEndpoint endpoint) async { await _client.close(); _client = HubClient(endpoint: endpoint); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kHostKey, endpoint.host); + await prefs.setInt(_kPortKey, endpoint.port); + await prefs.setBool(_kSecureKey, endpoint.secure); } Future healthy() => _client.healthy(); diff --git a/lib/main.dart b/lib/main.dart index 4a7529b..33e0f7b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,7 +16,12 @@ import 'theme/theme.dart'; import 'theme/tokens.dart'; import 'widgets/widgets.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + // Restore the persisted endpoint before the first frame so the + // sidebar pill doesn't briefly say "127.0.0.1:50051" before + // switching to the operator's actual hub. + await HubService.instance.loadPersistedEndpoint(); runApp(const StudioApp()); } @@ -199,15 +204,34 @@ class _Sidebar extends StatelessWidget { ], ), ), - // Footer hint. + // Footer: settings + version. Padding( - padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg), - child: Text( - 'platform v0.10.42', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 10, - ), + padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), + child: Row( + children: [ + Expanded( + child: Text( + 'platform target', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ), + IconButton( + icon: const Icon(Icons.settings_outlined, size: 16), + tooltip: 'Hub endpoint…', + visualDensity: VisualDensity.compact, + onPressed: () async { + final saved = await FaiSettingsDialog.show(context); + if (saved && context.mounted) { + // Force a sidebar rebuild so the new endpoint + // shows in the connection pill. + (context as Element).markNeedsBuild(); + } + }, + ), + ], ), ), ], diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart new file mode 100644 index 0000000..5ca6fb5 --- /dev/null +++ b/lib/widgets/fai_settings_dialog.dart @@ -0,0 +1,212 @@ +// FaiSettingsDialog — modal for changing the hub endpoint. +// Reads current values from HubService, persists on save via +// HubService.reconnect. + +import 'package:fai_dart_sdk/fai_dart_sdk.dart'; +import 'package:flutter/material.dart'; + +import '../data/hub.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; + +class FaiSettingsDialog extends StatefulWidget { + const FaiSettingsDialog({super.key}); + + /// Convenience launcher used from the sidebar gear icon. + static Future show(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (_) => const FaiSettingsDialog(), + ); + return ok ?? false; + } + + @override + State createState() => _FaiSettingsDialogState(); +} + +class _FaiSettingsDialogState extends State { + late final TextEditingController _host; + late final TextEditingController _port; + bool _secure = false; + bool _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + final ep = HubService.instance.currentEndpoint; + _host = TextEditingController(text: ep.host); + _port = TextEditingController(text: ep.port.toString()); + _secure = ep.secure; + } + + @override + void dispose() { + _host.dispose(); + _port.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + final port = int.tryParse(_port.text.trim()); + if (port == null || port <= 0 || port > 65535) { + setState(() { + _saving = false; + _error = 'port must be 1–65535'; + }); + return; + } + final endpoint = HubEndpoint( + host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(), + port: port, + secure: _secure, + ); + try { + await HubService.instance.reconnect(endpoint); + if (!mounted) return; + Navigator.pop(context, true); + } catch (e) { + setState(() { + _saving = false; + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('Hub endpoint'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.md), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: FaiSpace.xl, + vertical: FaiSpace.lg, + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.lg), + TextField( + controller: _host, + decoration: const InputDecoration( + labelText: 'Host', + border: OutlineInputBorder(), + isDense: true, + ), + autofocus: true, + ), + const SizedBox(height: FaiSpace.md), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: _port, + decoration: const InputDecoration( + labelText: 'Port', + border: OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: FaiSpace.md), + Expanded( + flex: 3, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('TLS'), + subtitle: Text( + 'https/grpc-secure', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + value: _secure, + onChanged: (v) => setState(() => _secure = v), + ), + ), + ], + ), + if (_error != null) ...[ + const SizedBox(height: FaiSpace.md), + Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: FaiSpace.md), + Container( + padding: const EdgeInsets.all(FaiSpace.sm), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + ), + child: Row( + children: [ + Icon( + Icons.link, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: FaiSpace.sm), + Text( + _previewUrl(), + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save & connect'), + ), + ], + ); + } + + String _previewUrl() { + final scheme = _secure ? 'https' : 'http'; + final host = + _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(); + final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim(); + return '$scheme://$host:$port'; + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index d684642..99c641d 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -9,4 +9,5 @@ export 'fai_data_row.dart'; export 'fai_delta_mark.dart'; export 'fai_empty_state.dart'; export 'fai_pill.dart'; +export 'fai_settings_dialog.dart'; export 'fai_status_dot.dart'; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..724bb2a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 0a5fc2c..1385d0f 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,15 +1,22 @@ PODS: - FlutterMacOS (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 110392b..41422cb 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -240,6 +240,7 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + E6FA6F388488A5B3F7608099 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -404,6 +405,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + E6FA6F388488A5B3F7608099 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/pubspec.lock b/pubspec.lock index 35ee2f5..d840040 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -130,6 +130,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -418,6 +423,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ce1d0fb..29a623f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: fai_dart_sdk: path: ../fai_dart_sdk google_fonts: ^8.1.0 + shared_preferences: ^2.5.5 dev_dependencies: flutter_test: