diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index eed1ef64..81995a9d 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -1,12 +1,17 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Query output: grid, loading, error, or placeholder. +/// +/// Mode changes (idle / loading / error / status / grid) morph via +/// [QueryaFadeSlide]. Keys are per **mode**, not per row — so grid data updates +/// and scroll rebuilds do not re-trigger the transition. class ResultsTab extends StatelessWidget { const ResultsTab({ super.key, @@ -29,24 +34,37 @@ class ResultsTab extends StatelessWidget { @override Widget build(BuildContext context) { + return QueryaFadeSlide( + alignment: material.Alignment.center, + offset: const material.Offset(0, 0.015), + child: _buildBody(context), + ); + } + + material.Widget _buildBody(material.BuildContext context) { if (isLoading) { return const material.Center( + key: material.ValueKey('results_mode_loading'), child: material.CircularProgressIndicator(), ); } if (errorMessage != null && errorMessage!.isNotEmpty) { - return VirtualSelectableTextView( - text: errorMessage!, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: Theme.of(context).colorScheme.destructive, + return material.KeyedSubtree( + key: const material.ValueKey('results_mode_error'), + child: VirtualSelectableTextView( + text: errorMessage!, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), ), ); } if (columns.isEmpty && rows.isEmpty) { if (statusLine != null) { return material.Padding( + key: const material.ValueKey('results_mode_status'), padding: const material.EdgeInsets.all(16), child: Align( alignment: material.Alignment.topLeft, @@ -56,15 +74,18 @@ class ResultsTab extends StatelessWidget { } if (affectedRows != null) { return material.Center( + key: const material.ValueKey('results_mode_affected'), child: Text('Rows affected: $affectedRows').muted(), ); } return material.Center( + key: const material.ValueKey('results_mode_idle'), child: const Text('Run a query to see results here.').muted(), ); } return material.Column( + key: const material.ValueKey('results_mode_grid'), crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ if (showExportToolbar && columns.isNotEmpty) diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index 2f858805..a3b6ce9e 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -1,11 +1,25 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import '../../support/querya_theme_test_shell.dart'; void main() { + material.Widget resultsShell({ + required material.Widget child, + QueryaMotionLevel level = QueryaMotionLevel.full, + }) { + return queryaThemeTestShell( + child: QueryaMotionScope( + level: level, + child: child, + ), + ); + } + group('computeResultGridColumnWidths', () { test('returns empty list for no columns', () { expect( @@ -55,7 +69,7 @@ void main() { ); await tester.pumpWidget( - queryaThemeTestShell( + resultsShell( child: material.Scaffold( body: ResultsTab( columns: const ['id', 'name'], @@ -69,6 +83,11 @@ void main() { expect(find.byType(material.ListView), findsOneWidget); expect(find.byType(material.Table), findsNothing); expect(find.byType(VirtualResultGrid), findsOneWidget); + expect(find.byType(QueryaFadeSlide), findsOneWidget); + expect( + find.byKey(const material.ValueKey('results_mode_grid')), + findsOneWidget, + ); }); testWidgets('virtualizes rows — does not build all row widgets at once', @@ -79,7 +98,7 @@ void main() { ); await tester.pumpWidget( - queryaThemeTestShell( + resultsShell( child: material.SizedBox( height: 400, width: 600, @@ -102,7 +121,7 @@ void main() { 'recalculates column widths when updated with different columns without throwing RangeError', (tester) async { await tester.pumpWidget( - queryaThemeTestShell( + resultsShell( child: const material.SizedBox( height: 400, width: 600, @@ -119,7 +138,7 @@ void main() { // Now update the grid with 5 columns instead of 2 await tester.pumpWidget( - queryaThemeTestShell( + resultsShell( child: const material.SizedBox( height: 400, width: 600, @@ -138,5 +157,222 @@ void main() { expect(find.text('email'), findsOneWidget); expect(find.text('created_at'), findsOneWidget); }); + + testWidgets('shows idle / loading / error / grid mode keys', (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(), + ), + ), + ); + await tester.pump(); + expect(find.text('Run a query to see results here.'), findsOneWidget); + expect( + find.byKey(const material.ValueKey('results_mode_idle')), + findsOneWidget, + ); + + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(isLoading: true), + ), + ), + ); + await tester.pump(); + expect( + find.byKey(const material.ValueKey('results_mode_loading')), + findsOneWidget, + ); + expect(find.byType(material.CircularProgressIndicator), findsOneWidget); + + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(errorMessage: 'syntax error near SELECT'), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const material.ValueKey('results_mode_error')), + findsOneWidget, + ); + expect(find.textContaining('syntax error'), findsOneWidget); + + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab( + columns: ['id'], + rows: [ + ['1'], + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const material.ValueKey('results_mode_grid')), + findsOneWidget, + ); + }); + + testWidgets('morphs idle → loading → grid through FadeSlide', (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(), + ), + ), + ); + await tester.pump(); + expect( + find.byKey(const material.ValueKey('results_mode_idle')), + findsOneWidget, + ); + + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(isLoading: true), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 40)); + expect( + find.byKey(const material.ValueKey('results_mode_loading')), + findsOneWidget, + ); + + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab( + columns: ['id', 'name'], + rows: [ + ['1', 'alpha'], + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(VirtualResultGrid), findsOneWidget); + expect(find.text('alpha'), findsOneWidget); + expect( + find.byKey(const material.ValueKey('results_mode_idle')), + findsNothing, + ); + expect( + find.byKey(const material.ValueKey('results_mode_loading')), + findsNothing, + ); + }); + + testWidgets('motion off snaps modes without lingering previous key', + (tester) async { + await tester.pumpWidget( + resultsShell( + level: QueryaMotionLevel.off, + child: const material.Scaffold( + body: ResultsTab(isLoading: true), + ), + ), + ); + await tester.pump(); + + await tester.pumpWidget( + resultsShell( + level: QueryaMotionLevel.off, + child: const material.Scaffold( + body: ResultsTab(errorMessage: 'boom'), + ), + ), + ); + await tester.pump(); + expect( + find.byKey(const material.ValueKey('results_mode_loading')), + findsNothing, + ); + expect( + find.byKey(const material.ValueKey('results_mode_error')), + findsOneWidget, + ); + }); + + testWidgets('grid row updates do not change mode key', (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab( + columns: ['id'], + rows: [ + ['1'], + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const material.ValueKey('results_mode_grid')), + findsOneWidget, + ); + + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab( + columns: ['id'], + rows: [ + ['1'], + ['2'], + ['3'], + ], + ), + ), + ), + ); + await tester.pump(); + // Same mode key — no second grid body from a mode switch. + expect( + find.byKey(const material.ValueKey('results_mode_grid')), + findsOneWidget, + ); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('shows status and affected empty modes', (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(statusLine: 'Connected to db'), + ), + ), + ); + await tester.pump(); + expect( + find.byKey(const material.ValueKey('results_mode_status')), + findsOneWidget, + ); + expect(find.text('Connected to db'), findsOneWidget); + + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(affectedRows: 4), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const material.ValueKey('results_mode_affected')), + findsOneWidget, + ); + expect(find.text('Rows affected: 4'), findsOneWidget); + }); }); }