diff --git a/CHANGELOG.md b/CHANGELOG.md index 1659556..4a6bde0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## 2026-11-03 +- New users now get a welcome dialog on first launch (web and desktop) explaining how to connect and what to try; reopen it from Help ▸ Welcome… (2026-11-03) - File ▸ Show crash log opens the folder with dbscope.log for bug reports (desktop) (2026-11-03) - dbscope_pg_proxy can be pinned to one database with --allow-db-host, and caps each result at --max-rows rows (default 100000, flagged truncated) (2026-11-03) - Releases and web deploys run analyze and the tests on exactly the commit they ship (2026-11-03) diff --git a/lib/app.dart b/lib/app.dart index 0e5c33d..eb1ddaa 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -24,11 +24,16 @@ class DBScopeApp extends StatefulWidget { required this.backend, this.windowId = 0, this.launchPayload = '', + this.showWelcome = false, }); final BackendClient backend; final int windowId; final String launchPayload; + /// Open the welcome dialog once the first frame is up. `main.dart` + /// decides this for the host window with `shouldShowWelcome`. + final bool showWelcome; + @override State createState() => _DBScopeAppState(); } @@ -110,7 +115,7 @@ class _DBScopeAppState extends State { theme: _theme, darkTheme: _theme, themeMode: _theme.brightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light, - home: const _Shell(), + home: widget.showWelcome ? const _WelcomeOnStartup(child: _Shell()) : const _Shell(), builder: (context, child) { return Stack( children: [ @@ -127,6 +132,29 @@ class _DBScopeAppState extends State { } } +/// Opens the welcome dialog over [child] after the first frame, once. +/// It sits under `MaterialApp.home` because the dialog needs a Navigator. +class _WelcomeOnStartup extends StatefulWidget { + const _WelcomeOnStartup({required this.child}); + final Widget child; + + @override + State<_WelcomeOnStartup> createState() => _WelcomeOnStartupState(); +} + +class _WelcomeOnStartupState extends State<_WelcomeOnStartup> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(showWelcome(context)); + }); + } + + @override + Widget build(BuildContext context) => widget.child; +} + class _Shell extends StatelessWidget { const _Shell(); diff --git a/lib/main.dart b/lib/main.dart index 15aefad..64a8321 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,6 +28,7 @@ import 'rpc/isolate_transport.dart'; import 'server/host.dart'; import 'shell/app_state.dart'; import 'shell/ui_prefs.dart'; +import 'shell/welcome_dialog.dart'; import 'update/update_service.dart'; import 'util/error_reporting.dart'; import 'util/shell_env.dart'; @@ -177,6 +178,11 @@ Future main(List args) async { runApp( DBScopeApp( backend: host.localClient, + showWelcome: shouldShowWelcome( + seen: UiPrefs.instance.welcomeSeen, + hasSavedProfiles: ConnectionRegistry.instance.profiles.isNotEmpty, + autoConnecting: cliProfile != null, + ), ), ); } diff --git a/lib/shell/menu_bar.dart b/lib/shell/menu_bar.dart index 5d0451e..0dfc4aa 100644 --- a/lib/shell/menu_bar.dart +++ b/lib/shell/menu_bar.dart @@ -19,6 +19,8 @@ import 'env_dialog.dart'; import 'menu_spec.dart'; import 'shell_listenable.dart'; import 'theme_registry.dart'; +import 'ui_prefs.dart'; +import 'welcome_dialog.dart'; import 'window_actions.dart'; // One AppMenuBar widget; two renderers. The data lives in @@ -312,6 +314,16 @@ class _MenuActions { ..._contributionsFor('AI'), ], ), + MenuSpec( + label: 'Help', + items: [ + MenuItemSpec( + label: 'Welcome…', + onSelected: () => showWelcome(context), + ), + ..._contributionsFor('Help'), + ], + ), ]; // Plugin-defined top-level menus appear after the built-ins. for (final label in MenuContributionRegistry.instance.extraMenuLabels) { @@ -490,6 +502,17 @@ class _MenuButton extends StatelessWidget { } } +/// Shows the welcome dialog, remembers that it was seen so it stops +/// opening at startup, and starts the new-connection flow if the user +/// asked for it. Used at first launch and by Help ▸ Welcome…. +Future showWelcome(BuildContext context) async { + final choice = await showWelcomeDialog(context); + UiPrefs.instance.setWelcomeSeen(); + if (choice != WelcomeChoice.addConnection) return; + if (!context.mounted) return; + await _addConnection(context); +} + Future _addConnection(BuildContext context) async { final profile = await showAddConnectionDialog(context); if (profile == null) return; diff --git a/lib/shell/ui_prefs.dart b/lib/shell/ui_prefs.dart index 08b24bf..f173dfc 100644 --- a/lib/shell/ui_prefs.dart +++ b/lib/shell/ui_prefs.dart @@ -5,8 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Persists per-machine preferences across launches: sidebar width and /// side, inspector width and collapsed state, theme, SQL editor height, -/// row height, the last connection, the open tabs, and the shell env -/// file path. +/// row height, the last connection, the open tabs, the shell env file +/// path, and whether the welcome dialog was seen. /// /// Each setter is fire-and-forget — callers don't await. SharedPreferences /// is process-global, so a subwindow's read still sees the host's write @@ -27,6 +27,7 @@ class UiPrefs { static const _kLastConnectionId = 'ui.lastConnectionId'; static const _kOpenTabs = 'ui.openTabs'; static const _kEnvFilePath = 'env.filePath'; + static const _kWelcomeSeen = 'ui.welcomeSeen'; static const _kMetaTrustPrefix = 'meta.trust.'; SharedPreferences? _prefs; @@ -83,6 +84,12 @@ class UiPrefs { void setEnvFilePath(String? v) => _setOrRemove(_kEnvFilePath, v); + /// Whether the welcome dialog was closed once. Null when preferences + /// can't be read, which `shouldShowWelcome` treats as "not seen". + bool? get welcomeSeen => _prefs?.getBool(_kWelcomeSeen); + + void setWelcomeSeen() => unawaited(_prefs?.setBool(_kWelcomeSeen, true)); + /// Pinned action-set hashes for a profile's metadata actions (JSON /// `{"read": "", "exec": ""}`). Machine-local on purpose: /// trust never travels with a shared profile or connection link. Null in diff --git a/lib/shell/welcome_dialog.dart b/lib/shell/welcome_dialog.dart new file mode 100644 index 0000000..c9e7d7a --- /dev/null +++ b/lib/shell/welcome_dialog.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../update/update_source.dart'; + +/// Whether the welcome dialog opens by itself at startup. +/// +/// It is for someone who has never used dbscope, so it stays away from +/// anyone who clearly has: it was dismissed before, connections are +/// already saved, or a command-line launch is connecting on its own. +/// [seen] is null when preferences can't be read (a private browser +/// window); that counts as "not seen", because the only cost is showing +/// a dismissible dialog again to someone with no saved connections. +bool shouldShowWelcome({ + required bool? seen, + required bool hasSavedProfiles, + required bool autoConnecting, +}) { + if (seen == true) return false; + if (hasSavedProfiles) return false; + if (autoConnecting) return false; + return true; +} + +/// What the user picked to leave the welcome dialog. +enum WelcomeChoice { + /// Open the new-connection flow. + addConnection, + + /// Just close the dialog. + close, +} + +/// The first thing a new user sees: what dbscope is, how to connect, +/// and what to try once connected. +/// +/// Returns null when dismissed with Escape or the barrier, which the +/// caller treats the same as [WelcomeChoice.close]. +Future showWelcomeDialog(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const WelcomeDialog(), + ); +} + +class WelcomeDialog extends StatelessWidget { + const WelcomeDialog({super.key}); + + static final Uri _proxyGuide = Uri.parse( + 'https://github.com/${ReleaseSource.owner}/${ReleaseSource.repo}' + '/tree/main/packages/dbscope_pg_proxy#readme', + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final muted = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75); + return AlertDialog( + title: Column( + children: [ + ClipRRect( + // Matches the rounded corners baked into the icon (rx 230/1024). + borderRadius: BorderRadius.circular(16.0), + child: Image.asset( + 'assets/icon/dbscope_icon.png', + width: 72.0, + height: 72.0, + cacheWidth: 216, + semanticLabel: 'dbscope logo', + ), + ), + const SizedBox(height: 14.0), + const Text('Welcome to dbscope', textAlign: TextAlign.center), + ], + ), + content: SizedBox( + width: 500.0, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Browse, edit and query your databases, with an AI ' + 'assistant that writes the SQL for you. Nothing is open ' + 'yet, so here is how to get started.', + ), + const SizedBox(height: 18.0), + const _Step( + number: 1, + title: 'Connect to a database', + body: kIsWeb + ? 'A browser can’t talk to Postgres directly, so the web ' + 'version connects through dbscope_pg_proxy, a small ' + 'proxy you run next to your database. Start it, then ' + 'choose Add a connection and enter its address ' + '(for example ws://127.0.0.1:8765).' + : 'Choose Add a connection for Postgres, Cloud SQL, or ' + 'SQLite inside an app on a USB-connected phone. To ' + 'open a local SQLite file, use File ▸ Open .db file….', + ), + const _Step( + number: 2, + title: 'Explore your tables', + body: + 'Tables appear in the sidebar. Click one to open it in ' + 'a tab, and double-click a cell to edit it. View ▸ ' + 'Schema draws every table and how they relate.', + ), + const _Step( + number: 3, + title: 'Ask questions', + body: + 'File ▸ New query opens a SQL editor. AI ▸ New AI query ' + 'lets you describe what you want in plain English; add ' + 'an API key under AI ▸ Settings… first.', + ), + if (kIsWeb) ...[ + const SizedBox(height: 4.0), + Wrap( + spacing: 8.0, + children: [ + TextButton.icon( + icon: const Icon(Icons.menu_book_outlined, size: 16.0), + label: const Text('How to run the proxy'), + onPressed: () => unawaited(launchUrl(_proxyGuide)), + ), + TextButton.icon( + icon: const Icon(Icons.laptop_mac_outlined, size: 16.0), + label: const Text('Get the Mac app'), + onPressed: () => unawaited(launchUrl(ReleaseSource.releasesPage)), + ), + ], + ), + ], + const SizedBox(height: 8.0), + Text( + 'You can open this again from Help ▸ Welcome….', + style: TextStyle(fontSize: 12.0, color: muted), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(WelcomeChoice.close), + child: const Text('Not now'), + ), + FilledButton( + autofocus: true, + onPressed: () => Navigator.of(context).pop(WelcomeChoice.addConnection), + child: const Text('Add a connection…'), + ), + ], + ); + } +} + +class _Step extends StatelessWidget { + const _Step({required this.number, required this.title, required this.body}); + + final int number; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 24.0, + height: 24.0, + alignment: Alignment.center, + decoration: BoxDecoration(color: scheme.primary, shape: BoxShape.circle), + child: Text( + '$number', + style: TextStyle( + color: scheme.onPrimary, + fontSize: 12.0, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 2.0), + Text(body), + ], + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fe7ee66..f0ef949 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,3 +67,6 @@ dev_dependencies: flutter: uses-material-design: true + assets: + # The app icon, shown at the top of the welcome dialog. + - assets/icon/dbscope_icon.png diff --git a/test/welcome_dialog_test.dart b/test/welcome_dialog_test.dart new file mode 100644 index 0000000..efa23fd --- /dev/null +++ b/test/welcome_dialog_test.dart @@ -0,0 +1,78 @@ +// Tests for the first-run welcome dialog. The contract it pins down: the +// dialog opens by itself only for someone new to dbscope (never closed it +// before, no saved connections, not launched to auto-connect), and its +// buttons report which way the user left it so the caller can start the +// new-connection flow. + +import 'package:dbscope/shell/welcome_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('shouldShowWelcome', () { + test('shows for a brand-new user', () { + expect( + shouldShowWelcome(seen: null, hasSavedProfiles: false, autoConnecting: false), + isTrue, + ); + }); + + test('stays closed once it was seen', () { + expect( + shouldShowWelcome(seen: true, hasSavedProfiles: false, autoConnecting: false), + isFalse, + ); + }); + + test('stays closed when connections are already saved', () { + expect( + shouldShowWelcome(seen: false, hasSavedProfiles: true, autoConnecting: false), + isFalse, + ); + }); + + test('stays closed when a command-line launch is connecting', () { + expect( + shouldShowWelcome(seen: false, hasSavedProfiles: false, autoConnecting: true), + isFalse, + ); + }); + }); + + group('showWelcomeDialog', () { + // Opens the dialog and hands back its result, wrapped so the caller + // can tap first and await after. + Future<({Future choice})> open(WidgetTester tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ); + final choice = showWelcomeDialog(context); + await tester.pumpAndSettle(); + return (choice: choice); + } + + testWidgets('Add a connection reports addConnection', (tester) async { + final opened = await open(tester); + expect(find.text('Welcome to dbscope'), findsOneWidget); + await tester.tap(find.text('Add a connection…')); + await tester.pumpAndSettle(); + expect(await opened.choice, WelcomeChoice.addConnection); + expect(find.text('Welcome to dbscope'), findsNothing); + }); + + testWidgets('Not now reports close', (tester) async { + final opened = await open(tester); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + expect(await opened.choice, WelcomeChoice.close); + }); + }); +}