import 'dart:math'; import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Components/loading_common.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Game/message_dialog.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart'; import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/constants.dart'; import 'package:provider/provider.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Game/sliding_puzzle_piece.dart'; import 'puzzle_piece.dart'; const IMAGE_PATH = 'image_path'; class GamePage extends StatefulWidget { final GameDTO section; GamePage({required this.section}); @override _GamePage createState() => _GamePage(); } class _GamePage extends State { GameDTO gameDTO = GameDTO(); int allInPlaceCount = 0; bool isFinished = false; DateTime? _gameStartTime; GlobalKey _widgetKey = GlobalKey(); Size? realWidgetSize; List pieces = []; bool isSplittingImage = true; bool showHint = false; List slidingTileIndices = []; // Maps current slot index to original tile index. -1 for empty. int emptySlotIndex = -1; @override void initState() { //puzzleDTO = PuzzleDTO.fromJson(jsonDecode(widget.section!.data!))!; gameDTO = widget.section; gameDTO.rows = gameDTO.rows ?? 3; gameDTO.cols = gameDTO.cols ?? 3; _gameStartTime = DateTime.now(); WidgetsBinding.instance.addPostFrameCallback((_) async { Size size = MediaQuery.of(context).size; final appContext = Provider.of(context, listen: false); VisitAppContext visitAppContext = appContext.getContext(); print(gameDTO.messageDebut); TranslationAndResourceDTO? messageDebut = gameDTO.messageDebut != null && gameDTO.messageDebut!.isNotEmpty ? gameDTO.messageDebut!.where((message) => message.language!.toUpperCase() == visitAppContext.language!.toUpperCase()).firstOrNull : null; //await Future.delayed(const Duration(milliseconds: 50)); await WidgetsBinding.instance.endOfFrame; getRealWidgetSize(); if(gameDTO.puzzleImage != null && gameDTO.puzzleImage!.url != null) { //splitImage(Image.network(puzzleDTO.image!.resourceUrl!)); splitImage(CachedNetworkImage( imageUrl: gameDTO.puzzleImage!.url!, fit: BoxFit.fill, errorWidget: (context, url, error) => Icon(Icons.error), )); } else { setState(() { isSplittingImage = false; }); } if(messageDebut != null) { showMessage(messageDebut, appContext, context, size); } }); super.initState(); } Future getRealWidgetSize() async { RenderBox renderBox = _widgetKey.currentContext?.findRenderObject() as RenderBox; Size size = renderBox.size; setState(() { realWidgetSize = size; }); print("Taille réelle du widget : $size"); } // here we will split the image into small pieces // using the rows and columns defined above; each piece will be added to a stack void splitImage(CachedNetworkImage image) async { final Completer completer = Completer(); final ImageProvider provider = CachedNetworkImageProvider(gameDTO.puzzleImage!.url!); provider.resolve(const ImageConfiguration()).addListener( ImageStreamListener((ImageInfo info, bool _) { if (!completer.isCompleted) { completer.complete(Size(info.image.width.toDouble(), info.image.height.toDouble())); } }), ); Size imageOriginalSize = await completer.future; double imageAspectRatio = imageOriginalSize.width / imageOriginalSize.height; // Calculate best fit for the puzzle inside the available area double containerWidth = realWidgetSize!.width * 0.9; // 90% of available width double containerHeight = realWidgetSize!.height * 0.8; // 80% of available height double containerAspectRatio = containerWidth / containerHeight; double puzzleWidth, puzzleHeight; if (imageAspectRatio > containerAspectRatio) { puzzleWidth = containerWidth; puzzleHeight = containerWidth / imageAspectRatio; } else { puzzleHeight = containerHeight; puzzleWidth = containerHeight * imageAspectRatio; } final appContext = Provider.of(context, listen: false); VisitAppContext visitAppContext = appContext.getContext(); setState(() { visitAppContext.puzzleSize = Size(puzzleWidth, puzzleHeight); appContext.setContext(visitAppContext); }); final pieceWidth = puzzleWidth / gameDTO.cols!; final pieceHeight = puzzleHeight / gameDTO.rows!; if (gameDTO.gameType == GameTypes.SlidingPuzzle) { // Initialize sliding puzzle slots: 0 to N-2 are tiles, last one is empty (-1) int totalTiles = gameDTO.rows! * gameDTO.cols!; slidingTileIndices = List.generate(totalTiles, (i) => i); // Shuffle by making random valid moves to ensure solvability emptySlotIndex = totalTiles - 1; slidingTileIndices[emptySlotIndex] = -1; // -1 represents the empty slot // Perform enough random moves to shuffle decently int shuffleMoves = 100; Random random = Random(); for (int i = 0; i < shuffleMoves; i++) { List adjacent = _getAdjacentIndices(emptySlotIndex, gameDTO.rows!, gameDTO.cols!); int moveIndex = adjacent[random.nextInt(adjacent.length)]; // Swap empty slot with the adjacent tile slidingTileIndices[emptySlotIndex] = slidingTileIndices[moveIndex]; slidingTileIndices[moveIndex] = -1; emptySlotIndex = moveIndex; } } else { for (int x = 0; x < gameDTO.rows!; x++) { for (int y = 0; y < gameDTO.cols!; y++) { // Target position in the puzzle grid double targetLeft = y * pieceWidth; double targetTop = x * pieceHeight; // Scatter logic: Randomly place within the container. double initialLeft = Random().nextDouble() * (containerWidth - pieceWidth); double initialTop = Random().nextDouble() * (containerHeight - pieceHeight); setState(() { pieces.add( PuzzlePiece( key: GlobalKey(), image: image, imageSize: Size(puzzleWidth, puzzleHeight), row: x, col: y, maxRow: gameDTO.rows!, maxCol: gameDTO.cols!, bringToTop: bringToTop, sendToBack: sendToBack, initialLeft: initialLeft - targetLeft, initialTop: initialTop - targetTop, ), ); }); } } } setState(() { isSplittingImage = false; }); } List _getAdjacentIndices(int index, int rows, int cols) { List adjacent = []; int r = index ~/ cols; int c = index % cols; if (r > 0) adjacent.add(index - cols); // Top if (r < rows - 1) adjacent.add(index + cols); // Bottom if (c > 0) adjacent.add(index - 1); // Left if (c < cols - 1) adjacent.add(index + 1); // Right return adjacent; } void _onSlidingTileTapped(int currentSlot) { if (isFinished) return; // Check if empty slot is adjacent List adjacent = _getAdjacentIndices(currentSlot, gameDTO.rows!, gameDTO.cols!); if (adjacent.contains(emptySlotIndex)) { setState(() { // Swap slidingTileIndices[emptySlotIndex] = slidingTileIndices[currentSlot]; slidingTileIndices[currentSlot] = -1; emptySlotIndex = currentSlot; // Check win condition bool won = true; for (int i = 0; i < slidingTileIndices.length; i++) { // In a solved puzzle, index i should contain tile i (or -1 at the very last slot) if (i == slidingTileIndices.length - 1) { if (slidingTileIndices[i] != -1) won = false; } else { if (slidingTileIndices[i] != i) won = false; } } if (won) { isFinished = true; _onGameFinished('SlidingPuzzle'); } }); } } // when the pan of a piece starts, we need to bring it to the front of the stack void bringToTop(Widget widget) { setState(() { pieces.remove(widget); pieces.add(widget); }); } // when a piece reaches its final position, // it will be sent to the back of the stack to not get in the way of other, still movable, pieces void sendToBack(Widget widget) { setState(() { allInPlaceCount++; isFinished = allInPlaceCount == gameDTO.rows! * gameDTO.cols!; pieces.remove(widget); pieces.insert(0, widget); if (isFinished) { _onGameFinished('Puzzle'); } }); } void _onGameFinished(String gameType) { Size size = MediaQuery.of(context).size; final appContext = Provider.of(context, listen: false); VisitAppContext visitAppContext = appContext.getContext(); final duration = _gameStartTime != null ? DateTime.now().difference(_gameStartTime!).inSeconds : 0; visitAppContext.statisticsService?.track( VisitEventType.gameComplete, metadata: {'gameType': gameType, 'durationSeconds': duration}, ); TranslationAndResourceDTO? messageFin = gameDTO.messageFin != null && gameDTO.messageFin!.isNotEmpty ? gameDTO.messageFin!.where((message) => message.language!.toUpperCase() == visitAppContext.language!.toUpperCase()).firstOrNull : null; if(messageFin != null) { showMessage(messageFin, appContext, context, size); } } Widget _buildContent(VisitAppContext visitAppContext) { if (gameDTO.gameType == GameTypes.Escape) { final paths = gameDTO.guidedPaths ?? []; if (paths.isEmpty) { return Center( child: Text('Aucun parcours disponible', style: TextStyle(color: Colors.grey[500], fontSize: 15)), ); } return ListView.builder( padding: const EdgeInsets.all(16), itemCount: paths.length, itemBuilder: (_, i) { final path = paths[i]; final title = TranslationHelper.get(path.title, visitAppContext); final desc = TranslationHelper.get(path.description, visitAppContext); final stepCount = path.steps?.length ?? 0; return Card( margin: const EdgeInsets.only(bottom: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ListTile( contentPadding: const EdgeInsets.all(12), leading: Container( width: 42, height: 42, decoration: BoxDecoration( color: kMainColor.withOpacity(0.12), shape: BoxShape.circle, ), child: const Icon(Icons.explore, color: kMainColor, size: 22), ), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), subtitle: Text( desc.isNotEmpty ? desc : '$stepCount étape${stepCount > 1 ? 's' : ''}', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: Colors.grey[600], fontSize: 12), ), trailing: const Icon(Icons.arrow_forward_ios, size: 14), onTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => GuidedPathContentProgressionPage( path: path, visitAppContext: visitAppContext, ), )), ), ); }, ); } if (gameDTO.gameType == GameTypes.SlidingPuzzle) { if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon()); final puzzleSize = visitAppContext.puzzleSize ?? Size(realWidgetSize!.width * 0.8, realWidgetSize!.height * 0.6); final tileWidth = puzzleSize.width / gameDTO.cols!; final tileHeight = puzzleSize.height / gameDTO.rows!; return Center( child: Container( width: puzzleSize.width, height: puzzleSize.height, child: Stack( children: [ // Hint Background if (showHint) Opacity( opacity: 0.25, child: CachedNetworkImage( imageUrl: gameDTO.puzzleImage!.url!, fit: BoxFit.fill, ), ), // Tiles for (int i = 0; i < slidingTileIndices.length; i++) if (slidingTileIndices[i] != -1) // Don't draw the empty slot AnimatedPositioned( key: ValueKey('tile_${slidingTileIndices[i]}'), duration: const Duration(milliseconds: 250), curve: Curves.easeInOut, left: (i % gameDTO.cols!) * tileWidth, top: (i ~/ gameDTO.cols!) * tileHeight, child: SlidingPuzzlePiece( image: CachedNetworkImage(imageUrl: gameDTO.puzzleImage!.url!, fit: BoxFit.fill), imageSize: puzzleSize, originalRow: slidingTileIndices[i] ~/ gameDTO.cols!, originalCol: slidingTileIndices[i] % gameDTO.cols!, maxRows: gameDTO.rows!, maxCols: gameDTO.cols!, width: tileWidth, height: tileHeight, showNumberHint: showHint, onTap: () => _onSlidingTileTapped(i), ), ), ], ), ), ); } // Default: Puzzle if (gameDTO.puzzleImage == null || gameDTO.puzzleImage!.url == null || realWidgetSize == null) { return Center(child: Text("Aucune image à afficher", style: TextStyle(fontSize: kNoneInfoOrIncorrect))); } final puzzleSize = visitAppContext.puzzleSize ?? Size(realWidgetSize!.width * 0.8, realWidgetSize!.height * 0.6); return Center( child: Container( width: puzzleSize.width, height: puzzleSize.height, child: Stack( clipBehavior: Clip.none, children: [ // Hint Background if (showHint) Opacity( opacity: 0.2, // Unified opacity for both child: CachedNetworkImage( imageUrl: gameDTO.puzzleImage!.url!, fit: BoxFit.fill, ), ), ...pieces, ], ), ), ); } @override Widget build(BuildContext context) { final appContext = Provider.of(context); VisitAppContext visitAppContext = appContext.getContext(); Size size = MediaQuery.of(context).size; var title = TranslationHelper.get(widget.section.title, appContext.getContext()); String cleanedTitle = title.replaceAll('\n', ' ').replaceAll('
', ' '); return Stack( children: [ Container( height: size.height * 0.28, decoration: BoxDecoration( boxShadow: const [ BoxShadow( color: kMainGrey, spreadRadius: 0.5, blurRadius: 5, offset: Offset(0, 1), // changes position of shadow ), ], gradient: const LinearGradient( begin: Alignment.centerRight, end: Alignment.centerLeft, colors: [ /*Color(0xFFDD79C2), Color(0xFFB65FBE), Color(0xFF9146BA), Color(0xFF7633B8), Color(0xFF6528B6), Color(0xFF6025B6)*/ kMainColor0, //Color(0xFFf6b3c4) kMainColor1, kMainColor2, ], ), image: widget.section.imageSource != null ? DecorationImage( fit: BoxFit.cover, opacity: 0.65, image: NetworkImage( widget.section.imageSource!, ), ): null, ), ), Column( children: [ SizedBox( height: size.height * 0.11, width: size.width, child: Stack( fit: StackFit.expand, children: [ Center( child: Padding( padding: const EdgeInsets.only(top: 22.0), child: SizedBox( width: size.width *0.7, child: HtmlWidget( cleanedTitle, textStyle: const TextStyle(color: Colors.white, fontFamily: 'Roboto', fontSize: 20), customStylesBuilder: (element) { return {'text-align': 'center', 'font-family': "Roboto", '-webkit-line-clamp': "2"}; }, ), ), ), ), Positioned( top: 35, left: 10, child: SizedBox( width: 50, height: 50, child: InkWell( onTap: () { Navigator.of(context).pop(); }, child: Container( decoration: const BoxDecoration( color: kMainColor, shape: BoxShape.circle, ), child: const Icon(Icons.arrow_back, size: 23, color: Colors.white) ), ) ), ), ], ), ), Expanded( child: Container( margin: const EdgeInsets.only(top: 0), decoration: const BoxDecoration( boxShadow: [ BoxShadow( color: kMainGrey, spreadRadius: 0.5, blurRadius: 2, offset: Offset(0, 1), // changes position of shadow ), ], color: kBackgroundColor, borderRadius: BorderRadius.only( topLeft: Radius.circular(30), topRight: Radius.circular(30), ), ), child: ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(30), topRight: Radius.circular(30), ), child: Center( //color: Colors.green, child: Container( //color: Colors.green, child: Padding( key: _widgetKey, padding: const EdgeInsets.all(0.0), child: isSplittingImage ? Center(child: LoadingCommon()) : _buildContent(visitAppContext), ), ), ) ) ), ), ], ), if (gameDTO.gameType == null || gameDTO.gameType == GameTypes.Puzzle || gameDTO.gameType == GameTypes.SlidingPuzzle) Positioned( bottom: 25, right: 20, child: FloatingActionButton( heroTag: 'hint_button', onPressed: () { setState(() { showHint = !showHint; }); }, backgroundColor: showHint ? kMainColor : Colors.grey[400], child: const Icon(Icons.help_outline, color: Colors.white, size: 28), ), ), ], ); } }