diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index e020216..a15536d 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -32,6 +32,75 @@ Future showWelcomeTourDialog( ); } +/// Visual keyboard keycap badge adapting to OS conventions (Cmd vs Ctrl). +class KbdBadge extends StatelessWidget { + const KbdBadge( + this.keys, { + super.key, + this.fontSize = 11, + }); + + final List keys; + final double fontSize; + + @override + Widget build(BuildContext context) { + final wb = context.workbench; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + for (var i = 0; i < keys.length; i++) ...[ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: isDark + ? const material.Color(0xFF1E2024) + : const material.Color(0xFFF1F5F9), + borderRadius: material.BorderRadius.circular(4), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.8), + ), + boxShadow: [ + material.BoxShadow( + color: material.Colors.black.withValues(alpha: isDark ? 0.3 : 0.08), + offset: const material.Offset(0, 1), + blurRadius: 1, + ), + ], + ), + child: Text( + keys[i], + style: TextStyle( + fontSize: fontSize, + fontWeight: material.FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.foreground, + ), + ), + ), + if (i < keys.length - 1) + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 3), + child: Text( + '+', + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: wb.mutedForeground, + ), + ), + ), + ], + ], + ); + } +} + class WelcomeTourDialog extends StatefulWidget { const WelcomeTourDialog({ super.key, @@ -60,7 +129,7 @@ class WelcomeTourDialog extends StatefulWidget { class _WelcomeTourDialogState extends State { int _currentStep = 0; - static const int _totalSteps = 4; + static const int _totalSteps = 6; @override void initState() { @@ -148,8 +217,8 @@ class _WelcomeTourDialogState extends State { child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, - maxWidth: 580, - minWidth: 420, + maxWidth: 640, + minWidth: 460, ), child: material.AnimatedSize( duration: const Duration(milliseconds: 240), @@ -162,154 +231,154 @@ class _WelcomeTourDialogState extends State { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ // Header row - material.Row( - children: [ - material.Container( - padding: const material.EdgeInsets.all(8), - decoration: material.BoxDecoration( - color: wb.accent.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(10), - border: material.Border.all( - color: wb.accent.withValues(alpha: 0.3), + material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: wb.accent.withValues(alpha: 0.3), + ), + ), + child: material.Icon( + material.Icons.auto_awesome_rounded, + size: 20, + color: wb.accent, ), ), - child: material.Icon( - material.Icons.auto_awesome_rounded, - size: 20, - color: wb.accent, - ), - ), - const material.SizedBox(width: 12), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Welcome to Querya').semiBold().large(), - Text('Step ${_currentStep + 1} of $_totalSteps') - .xSmall() - .muted(), - ], + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Welcome to Querya').semiBold().large(), + Text('Step ${_currentStep + 1} of $_totalSteps') + .xSmall() + .muted(), + ], + ), ), - ), - if (widget.onGoHome != null) ...[ - GhostButton( - key: const Key('welcome_tour_home_button'), - density: ButtonDensity.compact, - onPressed: _goHomeAndClose, - leading: const material.Icon( - material.Icons.home_rounded, - size: 16, + if (widget.onGoHome != null) ...[ + GhostButton( + key: const Key('welcome_tour_home_button'), + density: ButtonDensity.compact, + onPressed: _goHomeAndClose, + leading: const material.Icon( + material.Icons.home_rounded, + size: 16, + ), + child: const Text('Home'), ), - child: const Text('Home'), + const material.SizedBox(width: 4), + ], + material.IconButton( + icon: const material.Icon( + material.Icons.close_rounded, + size: 18, + ), + tooltip: 'Close', + splashRadius: 16, + onPressed: _finish, ), - const material.SizedBox(width: 4), ], - material.IconButton( - icon: const material.Icon( - material.Icons.close_rounded, - size: 18, - ), - tooltip: 'Close', - splashRadius: 16, - onPressed: _finish, + ), + const material.SizedBox(height: 18), + + // Animated Slide body + material.AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return material.FadeTransition( + opacity: animation, + child: material.SlideTransition( + position: material.Tween( + begin: const material.Offset(0.04, 0), + end: material.Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: _buildSlideContent( + step: _currentStep, + cmdCtrl: cmdCtrl, + cs: cs, + wb: wb, ), - ], - ), - const material.SizedBox(height: 18), - - // Animated Slide body - material.AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - return material.FadeTransition( - opacity: animation, - child: material.SlideTransition( - position: material.Tween( - begin: const material.Offset(0.04, 0), - end: material.Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: _buildSlideContent( - step: _currentStep, - cmdCtrl: cmdCtrl, - cs: cs, - wb: wb, ), - ), - const material.SizedBox(height: 20), - - // Footer controls & Step dots - material.Row( - children: [ - // Step indicator dots (clickable) - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: List.generate(_totalSteps, (index) { - final active = index == _currentStep; - return material.GestureDetector( - onTap: () => _goToStep(index), - behavior: material.HitTestBehavior.opaque, - child: material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 3, - vertical: 6, - ), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - width: active ? 22 : 6, - height: 6, - decoration: material.BoxDecoration( - color: active - ? wb.accent - : wb.mutedForeground.withValues(alpha: 0.3), - borderRadius: material.BorderRadius.circular(3), + const material.SizedBox(height: 20), + + // Footer controls & Step dots + material.Row( + children: [ + // Step indicator dots (clickable) + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: List.generate(_totalSteps, (index) { + final active = index == _currentStep; + return material.GestureDetector( + onTap: () => _goToStep(index), + behavior: material.HitTestBehavior.opaque, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 3, + vertical: 6, + ), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + width: active ? 22 : 6, + height: 6, + decoration: material.BoxDecoration( + color: active + ? wb.accent + : wb.mutedForeground.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(3), + ), ), ), - ), - ); - }), - ), - const material.Spacer(), - - // Action buttons - if (_currentStep > 0) - OutlineButton( - key: const Key('welcome_tour_prev_button'), - onPressed: _previous, - child: const Text('Back'), - ) - else - GhostButton( - key: const Key('welcome_tour_skip_button'), - onPressed: _finish, - child: const Text('Skip'), + ); + }), ), - const material.SizedBox(width: 8), - - PrimaryButton( - key: const Key('welcome_tour_next_button'), - onPressed: _next, - child: Text( - _currentStep == _totalSteps - 1 - ? 'Get Started' - : 'Next', + const material.Spacer(), + + // Action buttons + if (_currentStep > 0) + OutlineButton( + key: const Key('welcome_tour_prev_button'), + onPressed: _previous, + child: const Text('Back'), + ) + else + GhostButton( + key: const Key('welcome_tour_skip_button'), + onPressed: _finish, + child: const Text('Skip'), + ), + const material.SizedBox(width: 8), + + PrimaryButton( + key: const Key('welcome_tour_next_button'), + onPressed: _next, + child: Text( + _currentStep == _totalSteps - 1 + ? 'Get Started' + : 'Next', + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), - ), - ); -} + ); + } Widget _buildSlideContent({ required int step, @@ -327,7 +396,7 @@ class _WelcomeTourDialogState extends State { description: 'Connect directly to PostgreSQL, MySQL, SQLite, Redis, MongoDB or custom extension drivers. Paste a connection URL or use our visual form with real-time field validation.', tipText: 'Press $cmdCtrl+N anytime to create a new connection.', - shortcutBadge: '$cmdCtrl+N', + shortcutBadge: KbdBadge([cmdCtrl, 'N']), wb: wb, ); case 1: @@ -340,59 +409,56 @@ class _WelcomeTourDialogState extends State { 'Browse database schemas, tables, and views with full keyboard navigation. Right-click any object for instant SELECT queries and DDL copy.', tipText: 'Collapse the sidebar with $cmdCtrl+B for an expansive full-screen workspace.', - shortcutBadge: '$cmdCtrl+B', + shortcutBadge: KbdBadge([cmdCtrl, 'B']), wb: wb, ); case 2: return _TourSlideView( key: const ValueKey(2), + icon: material.Icons.code_rounded, + iconColor: const material.Color(0xFF3B82F6), // Blue + title: 'Pro SQL Editor & Workspaces', + description: + 'Execute scripts with $cmdCtrl+Enter or F5. Open multiple query tabs, format SQL with $cmdCtrl+Shift+F, and access persistent query history.', + tipText: + 'Use $cmdCtrl+T or $cmdCtrl+Shift+N to spawn a fresh SQL editor tab.', + shortcutBadge: KbdBadge(['F5', 'or', '$cmdCtrl+Enter']), + wb: wb, + ); + case 3: + return _TourSlideView( + key: const ValueKey(3), icon: material.Icons.table_chart_rounded, iconColor: const material.Color(0xFF10B981), // Emerald - title: 'Interactive SQL & 2D Grid', + title: 'Interactive 2D Grid & DML Staging', description: - 'Run queries instantly with $cmdCtrl+Enter. Sort results by clicking headers, drag column borders to resize, and Shift+Click to copy cell ranges in TSV format.', + 'Edit cells in-place, select ranges with Shift+Click, and copy TSV data with $cmdCtrl+C. Staged changes are safely previewed with DML inspection before committing.', tipText: 'Copied cell ranges paste directly into Google Sheets and Excel.', - shortcutBadge: '$cmdCtrl+Enter', + shortcutBadge: KbdBadge([cmdCtrl, 'C']), wb: wb, ); - case 3: - default: + case 4: return _TourSlideView( - key: const ValueKey(3), - icon: material.Icons.shield_rounded, + key: const ValueKey(4), + icon: material.Icons.filter_alt_rounded, iconColor: const material.Color(0xFFF59E0B), // Amber - title: 'Zero-Trust Security & Playground', + title: 'Compound Filter Bar & Quick Calc', description: - 'Database credentials stay protected inside your operating system secure keychain. Try our 1-click Demo Playground to explore Querya instantly without server setup.', + 'Filter grid results with live autocomplete expressions. Toggle Groupings & Pivot panels with $cmdCtrl+G and inspect instant stats (Sum, Avg, Median) in the status bar.', tipText: - 'Toggle Read-Only mode in the title bar for risk-free production exploration.', - shortcutBadge: 'Protected', - actionButtons: [ - if (widget.onLaunchDemo != null) - OutlineButton( - key: const Key('welcome_tour_demo_button'), - onPressed: _launchDemoAndClose, - leading: const material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - ), - child: const Text('Try Demo Playground Now'), - ), - if (widget.onGoHome != null) ...[ - const material.SizedBox(height: 8), - GhostButton( - key: const Key('welcome_tour_slide_home_button'), - onPressed: _goHomeAndClose, - leading: const material.Icon( - material.Icons.home_rounded, - size: 16, - ), - child: const Text('Return to Start Screen'), - ), - ], - ], + 'Press $cmdCtrl+F to focus the compound filter bar on any active grid.', + shortcutBadge: KbdBadge([cmdCtrl, 'F']), + wb: wb, + ); + case 5: + default: + return _HotkeysMatrixSlideView( + key: const ValueKey(5), + cmdCtrl: cmdCtrl, wb: wb, + onLaunchDemo: widget.onLaunchDemo != null ? _launchDemoAndClose : null, + onGoHome: widget.onGoHome != null ? _goHomeAndClose : null, ); } } @@ -407,7 +473,6 @@ class _TourSlideView extends StatelessWidget { required this.description, required this.tipText, required this.shortcutBadge, - this.actionButtons, required this.wb, }); @@ -416,8 +481,7 @@ class _TourSlideView extends StatelessWidget { final String title; final String description; final String tipText; - final String shortcutBadge; - final List? actionButtons; + final Widget shortcutBadge; final QueryaWorkbenchTheme wb; @override @@ -468,27 +532,7 @@ class _TourSlideView extends StatelessWidget { children: [ Text(title).semiBold().base(), const material.SizedBox(height: 4), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: material.BoxDecoration( - color: wb.surface, - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: wb.borderSubtle.withValues(alpha: 0.6), - ), - ), - child: Text( - shortcutBadge, - style: TextStyle( - fontSize: 11, - fontWeight: material.FontWeight.w600, - color: wb.accent, - ), - ), - ), + shortcutBadge, ], ), ), @@ -552,11 +596,153 @@ class _TourSlideView extends StatelessWidget { ], ), ), + ], + ); + } +} - if (actionButtons != null && actionButtons!.isNotEmpty) ...[ - const material.SizedBox(height: 14), - ...actionButtons!, - ], +class _HotkeysMatrixSlideView extends StatelessWidget { + const _HotkeysMatrixSlideView({ + super.key, + required this.cmdCtrl, + required this.wb, + this.onLaunchDemo, + this.onGoHome, + }); + + final String cmdCtrl; + final QueryaWorkbenchTheme wb; + final material.VoidCallback? onLaunchDemo; + final material.VoidCallback? onGoHome; + + @override + Widget build(BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + // Header banner + material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: wb.canvas, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.5), + ), + ), + child: material.Row( + children: [ + material.Container( + width: 36, + height: 36, + decoration: material.BoxDecoration( + color: const material.Color(0xFFEC4899).withValues(alpha: 0.15), + shape: material.BoxShape.circle, + ), + child: const material.Icon( + material.Icons.keyboard_rounded, + size: 20, + color: material.Color(0xFFEC4899), + ), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Keyboard Shortcuts Cheat Sheet').semiBold().base(), + const Text('Master Querya like a pro with instant key bindings.') + .muted() + .xSmall(), + ], + ), + ), + ], + ), + ), + const material.SizedBox(height: 12), + + // Hotkeys Matrix Table + material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.6), + ), + ), + child: material.Column( + children: [ + _hotkeyRow(context, 'Run Query / Script', [cmdCtrl, 'Enter'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'New Connection', [cmdCtrl, 'N'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'Toggle Sidebar', [cmdCtrl, 'B'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'Compound Filter Bar', [cmdCtrl, 'F'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'Copy Selection (TSV)', [cmdCtrl, 'C'], wb), + const material.Divider(height: 10), + _hotkeyRow(context, 'Open Guide / Tour', ['F1'], wb), + ], + ), + ), + const material.SizedBox(height: 14), + + // Playground and Home buttons + material.Row( + children: [ + if (onLaunchDemo != null) + material.Expanded( + child: OutlineButton( + key: const Key('welcome_tour_demo_button'), + onPressed: onLaunchDemo, + leading: const material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + ), + child: const Text('Try Demo Playground'), + ), + ), + if (onLaunchDemo != null && onGoHome != null) + const material.SizedBox(width: 8), + if (onGoHome != null) + material.Expanded( + child: GhostButton( + key: const Key('welcome_tour_slide_home_button'), + onPressed: onGoHome, + leading: const material.Icon( + material.Icons.home_rounded, + size: 16, + ), + child: const Text('Start Screen'), + ), + ), + ], + ), + ], + ); + } + + material.Widget _hotkeyRow( + material.BuildContext context, + String label, + List keys, + QueryaWorkbenchTheme wb, + ) { + return material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.foreground, + fontWeight: material.FontWeight.w500, + ), + ), + KbdBadge(keys, fontSize: 10.5), ], ); } diff --git a/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart index 761c24d..74ae788 100644 --- a/test/features/onboarding/welcome_tour_dialog_test.dart +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -44,7 +44,7 @@ void main() { } }); - testWidgets('WelcomeTourDialog displays steps and allows navigation through all 4 slides', + testWidgets('WelcomeTourDialog displays steps and allows navigation through all 6 slides', (tester) async { await tester.pumpWidget( queryaThemeTestShell( @@ -57,7 +57,7 @@ void main() { // Slide 1: Connect in Seconds expect(find.text('Welcome to Querya'), findsOneWidget); - expect(find.text('Step 1 of 4'), findsOneWidget); + expect(find.text('Step 1 of 6'), findsOneWidget); expect(find.text('Connect in Seconds'), findsOneWidget); expect(find.byKey(const Key('welcome_tour_skip_button')), findsOneWidget); expect(find.byKey(const Key('welcome_tour_next_button')), findsOneWidget); @@ -66,7 +66,7 @@ void main() { await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); await tester.pumpAndSettle(); - expect(find.text('Step 2 of 4'), findsOneWidget); + expect(find.text('Step 2 of 6'), findsOneWidget); expect(find.text('Fluid Sidebar & Navigation'), findsOneWidget); expect(find.byKey(const Key('welcome_tour_prev_button')), findsOneWidget); @@ -74,23 +74,37 @@ void main() { await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); await tester.pumpAndSettle(); - expect(find.text('Step 3 of 4'), findsOneWidget); - expect(find.text('Interactive SQL & 2D Grid'), findsOneWidget); + expect(find.text('Step 3 of 6'), findsOneWidget); + expect(find.text('Pro SQL Editor & Workspaces'), findsOneWidget); // Click Next -> Slide 4 await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); await tester.pumpAndSettle(); - expect(find.text('Step 4 of 4'), findsOneWidget); - expect(find.text('Zero-Trust Security & Playground'), findsOneWidget); + expect(find.text('Step 4 of 6'), findsOneWidget); + expect(find.text('Interactive 2D Grid & DML Staging'), findsOneWidget); + + // Click Next -> Slide 5 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 5 of 6'), findsOneWidget); + expect(find.text('Compound Filter Bar & Quick Calc'), findsOneWidget); + + // Click Next -> Slide 6 (Hotkeys Matrix) + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 6 of 6'), findsOneWidget); + expect(find.text('Keyboard Shortcuts Cheat Sheet'), findsOneWidget); expect(find.text('Get Started'), findsOneWidget); - // Click Back -> Slide 3 + // Click Back -> Slide 5 await tester.tap(find.byKey(const Key('welcome_tour_prev_button'))); await tester.pumpAndSettle(); - expect(find.text('Step 3 of 4'), findsOneWidget); - expect(find.text('Interactive SQL & 2D Grid'), findsOneWidget); + expect(find.text('Step 5 of 6'), findsOneWidget); + expect(find.text('Compound Filter Bar & Quick Calc'), findsOneWidget); }); testWidgets('WelcomeTourDialog launches demo playground on action button tap', @@ -108,15 +122,13 @@ void main() { ); await tester.pumpAndSettle(); - // Advance to slide 4 - await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); - await tester.pumpAndSettle(); + // Advance to slide 6 (Playground & Hotkeys) + for (var i = 0; i < 5; i++) { + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + } - expect(find.text('Step 4 of 4'), findsOneWidget); + expect(find.text('Step 6 of 6'), findsOneWidget); expect(find.byKey(const Key('welcome_tour_demo_button')), findsOneWidget); await tester.tap(find.byKey(const Key('welcome_tour_demo_button')));