diff --git a/lib/core/widgets/glass_container.dart b/lib/core/widgets/glass_container.dart index 69631ffb..1a6a9715 100644 --- a/lib/core/widgets/glass_container.dart +++ b/lib/core/widgets/glass_container.dart @@ -45,12 +45,33 @@ class GlassContainer extends StatelessWidget { final resolvedBorderColor = borderColor ?? defaultBorder; final resolvedBg = customBackgroundColor ?? defaultBg; - Widget content = Container( + Widget innerContent = Container( padding: padding, decoration: BoxDecoration( color: resolvedBg, borderRadius: BorderRadius.circular(borderRadius), border: Border.all(color: resolvedBorderColor, width: 1.2), + ), + child: child, + ); + + Widget content = blur > 0 + ? ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: innerContent, + ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: innerContent, + ); + + content = Container( + margin: margin, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), boxShadow: [ if (glowColor != null) BoxShadow( @@ -67,28 +88,9 @@ class GlassContainer extends StatelessWidget { ), ], ), - child: child, + child: content, ); - if (blur > 0) { - content = ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), - child: content, - ), - ); - } else { - content = ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: content, - ); - } - - if (margin != EdgeInsets.zero) { - content = Padding(padding: margin, child: content); - } - if (onTap != null || onLongPress != null) { return Material( color: Colors.transparent, diff --git a/lib/features/tutorial/widgets/tutorial_overlay.dart b/lib/features/tutorial/widgets/tutorial_overlay.dart index 31b47959..6033a422 100644 --- a/lib/features/tutorial/widgets/tutorial_overlay.dart +++ b/lib/features/tutorial/widgets/tutorial_overlay.dart @@ -37,7 +37,7 @@ class _TutorialOverlayState extends State int _index = 0; Rect? _spotRect; - bool _ready = false; + late bool _ready = widget.steps.isNotEmpty && widget.steps[0].targetKey == null; TutorialStep get _step => widget.steps[_index]; bool get _isLast => _index == widget.steps.length - 1; @@ -63,12 +63,17 @@ class _TutorialOverlayState extends State return; } - await Scrollable.ensureVisible( - targetContext, - alignment: 0.35, - duration: const Duration(milliseconds: 320), - curve: Curves.easeOutCubic, - ); + try { + await Scrollable.ensureVisible( + targetContext, + alignment: 0.35, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } catch (_) { + // Ignorer si l'élément n'est pas dans un widget scrollable + } + // Laisse le temps au défilement de se stabiliser avant de mesurer. await Future.delayed(const Duration(milliseconds: 60)); if (!mounted) return; @@ -80,13 +85,18 @@ class _TutorialOverlayState extends State } Rect? _measure(BuildContext targetContext, double padding) { - final box = targetContext.findRenderObject() as RenderBox?; - if (box == null || !box.hasSize) return null; - final origin = box.localToGlobal(Offset.zero); - final screen = MediaQuery.of(context).size; - return Rect.fromLTWH(origin.dx, origin.dy, box.size.width, box.size.height) - .inflate(padding) - .intersect(Offset.zero & screen); + if (!targetContext.mounted) return null; + final renderObject = targetContext.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return null; + try { + final origin = renderObject.localToGlobal(Offset.zero); + final screen = MediaQuery.of(context).size; + return Rect.fromLTWH(origin.dx, origin.dy, renderObject.size.width, renderObject.size.height) + .inflate(padding) + .intersect(Offset.zero & screen); + } catch (_) { + return null; + } } void _next() { @@ -96,7 +106,7 @@ class _TutorialOverlayState extends State } setState(() { _index++; - _ready = false; + _ready = widget.steps[_index].targetKey == null; _spotRect = null; }); WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep()); @@ -182,10 +192,14 @@ class _TutorialOverlayState extends State ); if (spot == null) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: card, + return Positioned.fill( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: card, + ), + ), ), ); } @@ -195,11 +209,19 @@ class _TutorialOverlayState extends State // Zone très large (image plein écran) : la bulle flotte en bas de l'écran // pour laisser la main animée visible au centre. if (spot.height > screenHeight * 0.55) { + final available = (screenHeight * 0.4).clamp(140.0, screenHeight); return Positioned( left: 16, right: 16, bottom: media.padding.bottom + 24, - child: card, + child: SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: available), + child: Center( + child: card, + ), + ), + ), ); } @@ -207,12 +229,25 @@ class _TutorialOverlayState extends State final above = _step.preferTooltipAbove ?? (spot.top > screenHeight - spot.bottom); + final available = above + ? (spot.top - gap - media.padding.top).clamp(140.0, screenHeight) + : (screenHeight - spot.bottom - gap - media.padding.bottom).clamp(140.0, screenHeight); + return Positioned( left: 16, right: 16, - top: above ? null : spot.bottom + gap, + top: above ? null : (spot.bottom + gap), bottom: above ? (screenHeight - spot.top + gap) : null, - child: card, + child: SafeArea( + top: !above, + bottom: above, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: available), + child: Center( + child: card, + ), + ), + ), ); } } @@ -246,10 +281,9 @@ class _TutorialCard extends StatelessWidget { final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary; - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: Container( + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Container( padding: const EdgeInsets.fromLTRB(18, 16, 18, 12), decoration: BoxDecoration( color: surface, @@ -270,7 +304,7 @@ class _TutorialCard extends StatelessWidget { ), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ @@ -298,6 +332,7 @@ class _TutorialCard extends StatelessWidget { ), ), ), + const SizedBox(width: 8), Text( '${index + 1}/$total', style: TextStyle( @@ -339,23 +374,21 @@ class _TutorialCard extends StatelessWidget { const SizedBox(height: 12), Row( children: [ - Row( - children: List.generate(total, (i) { - final active = i == index; - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(right: 5), - width: active ? 18 : 6, - height: 6, - decoration: BoxDecoration( - color: active - ? primary - : primary.withValues(alpha: 0.28), - borderRadius: BorderRadius.circular(3), - ), - ); - }), - ), + ...List.generate(total, (i) { + final active = i == index; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(right: 5), + width: active ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: active + ? primary + : primary.withValues(alpha: 0.28), + borderRadius: BorderRadius.circular(3), + ), + ); + }), const Spacer(), TextButton( onPressed: onSkip, @@ -363,9 +396,9 @@ class _TutorialCard extends StatelessWidget { child: const Text('Passer'), ), const SizedBox(width: 4), - ElevatedButton( + FilledButton( onPressed: onNext, - style: ElevatedButton.styleFrom( + style: FilledButton.styleFrom( backgroundColor: primary, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( @@ -384,8 +417,7 @@ class _TutorialCard extends StatelessWidget { ], ), ), - ), - ); + ); } static String _gestureHint(TutorialGesture gesture) { diff --git a/test/features/full_app_render_test.dart b/test/features/full_app_render_test.dart new file mode 100644 index 00000000..8bf8e790 --- /dev/null +++ b/test/features/full_app_render_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:bully/app.dart'; +import 'package:bully/core/theme/theme_provider.dart'; +import 'package:bully/data/repositories/session_repository.dart'; +import 'package:bully/features/session/session_provider.dart'; +import 'package:bully/features/tutorial/tutorial_provider.dart'; +import 'package:bully/services/grouping_analyzer_service.dart'; +import 'package:bully/services/score_calculator_service.dart'; +import 'package:bully/services/tutorial_service.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + FlutterError.onError = (details) { + // ignore: avoid_print + print('CAUGHT_FLUTTER_ERROR: ${details.exceptionAsString()}'); + // ignore: avoid_print + print('CAUGHT_STACK: ${details.stack}'); + }; + }); + + testWidgets('BullyApp démarre et affiche HomeScreen + Tutorial sans erreur', + (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + final repository = SessionRepository(); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider( + create: (_) => ScoreCalculatorService(), + ), + Provider( + create: (_) => GroupingAnalyzerService(), + ), + Provider.value(value: repository), + ChangeNotifierProvider(create: (_) => ThemeProvider()), + ChangeNotifierProvider( + create: (_) => SessionProvider()), + ChangeNotifierProvider( + create: (_) => TutorialProvider(service: TutorialService())), + ], + child: const BullyApp(), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('BULLY'), findsOneWidget); + expect(find.text('Bienvenue dans Bully'), findsOneWidget); + + // Passer à l'étape suivante + await tester.tap(find.text('Suivant')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Démarrez une session'), findsOneWidget); + + // Passer + await tester.tap(find.text('Passer')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Démarrez une session'), findsNothing); + }); +} diff --git a/test/features/tutorial_overlay_test.dart b/test/features/tutorial_overlay_test.dart index f9d086b7..59e5945e 100644 --- a/test/features/tutorial_overlay_test.dart +++ b/test/features/tutorial_overlay_test.dart @@ -124,4 +124,62 @@ void main() { // Aucune cible : pas de main animée non plus. expect(find.byType(TutorialHand), findsNothing); }); + + testWidgets('l\'overlay se rend correctement dans un Navigator push modal', + (WidgetTester tester) async { + final targetKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Column( + children: [ + SizedBox(key: targetKey, width: 200, height: 60), + ElevatedButton( + onPressed: () { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + pageBuilder: (routeCtx, _, __) => TutorialOverlay( + steps: [ + const TutorialStep( + title: 'Bienvenue', + description: 'Texte introductif', + ), + TutorialStep( + targetKey: targetKey, + title: 'Bouton', + description: 'Texte bouton', + gesture: TutorialGesture.tap, + ), + ], + onFinished: () => Navigator.of(routeCtx).pop(), + ), + ), + ); + }, + child: const Text('Ouvrir'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Ouvrir')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Bienvenue'), findsOneWidget); + + await tester.tap(find.text('Suivant')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Bouton'), findsOneWidget); + expect(find.byType(TutorialHand), findsOneWidget); + }); } +