Premier vrai build depuis avril. Le diagnostic des docs — « un seul défaut, purement Gradle, peut-être réglé par 5a6701d » — tenait parce que personne n'avait lancé la commande. K1 — dix crans d'outillage, chacun dicté par l'erreur du précédent : Gradle 7.5 → 8.11.1, AGP 7.2.0 → 8.9.1, Kotlin 1.9.0 → 2.3.10, enableUncompressedNativeLibs retirée (supprimée en AGP 8.1), jcenter → mavenCentral, heap 1536M → 4096M, Jetifier coupé, et retrait du resolutionStrategy qui forçait androidx.lifecycle à 2.4.0 « to fix mapbox issue » : il réglait un problème d'il y a trois ans et causait celui d'aujourd'hui, mapbox_maps_flutter 2.21.1 appelant setViewTreeLifecycleOwner. Les trois derniers crans étaient de simples alignements sur mymuseum-visitapp, qui utilise le même plugin sans ces problèmes. Le bloc buildscript commenté de android/build.gradle est supprimé : il déclarait une config qui n'était pas celle appliquée et a fait conclure deux fois à tort. K2 — les 132 erreurs Dart que le Gradle masquait. Trois causes : roundedValue, isDate, isHour, isSectionImageBackground et screenPercentageSectionsMainPage ont migré vers AppConfigurationLink ; GeoPointDTO.latitude/longitude sont devenus geometry ; le reste en découlait. Deux bugs latents trouvés au passage : - applicationInstanceDTO n'était assigné que si l'instance avait l'IA — il servait de drapeau d'assistant. Ce DTO portant désormais les AppConfigurationLink, les cinq réglages seraient retombés sur leurs défauts sur MDLF et le Fort, sans erreur ni log. Drapeau rendu explicite, en préservant le ET instance × canal. - ConfigurationDTO.isTablet ayant disparu, le filtre des configurations tablette n'avait plus de source : il porte sur les liens du canal AppType.Tablet. La formule de clé par coordonnées de geo_point_filter, dupliquée à quatre endroits alors que les deux côtés de l'appariement doivent produire la même valeur, ne vit plus qu'à un seul (Helpers/geo.dart). Ce fichier documente aussi les deux conventions de coordonnées du projet : [lng, lat] pour les GeoPoint, [lat, lng] pour les géométries de GuidedStep. K4 — l'écran Game repris de mymuseum-visitapp, Screens/Puzzle supprimé. La tablette gagne le puzzle glissant, le bouton d'indice et un dimensionnement au ratio de l'image. Les couleurs, codées en dur par flavor client chez mymuseum, sont dérivées de configuration.primaryColor : tablet-app n'a pas de flavor, un seul APK sert tous les clients. Reste à vérifier sur device : le rendu de l'écran Game, et l'écran de sélection de configuration, qui sera vide si les configurations ne sont pas rattachées au canal tablette. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
517 lines
19 KiB
Dart
517 lines
19 KiB
Dart
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:tablet_app/Components/loading_common.dart';
|
|
import 'package:tablet_app/Helpers/translationHelper.dart';
|
|
import 'package:tablet_app/Models/tabletContext.dart';
|
|
import 'package:tablet_app/Screens/Game/message_dialog.dart';
|
|
import 'package:tablet_app/app_context.dart';
|
|
import 'package:tablet_app/constants.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:tablet_app/Screens/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<GamePage> {
|
|
GameDTO gameDTO = GameDTO();
|
|
|
|
int allInPlaceCount = 0;
|
|
bool isFinished = false;
|
|
DateTime? _gameStartTime;
|
|
GlobalKey _widgetKey = GlobalKey();
|
|
Size? realWidgetSize;
|
|
List<Widget> pieces = [];
|
|
|
|
bool isSplittingImage = true;
|
|
bool showHint = false;
|
|
List<int> 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<AppContext>(context, listen: false);
|
|
TabletAppContext tabletAppContext = appContext.getContext();
|
|
|
|
print(gameDTO.messageDebut);
|
|
TranslationAndResourceDTO? messageDebut = gameDTO.messageDebut != null && gameDTO.messageDebut!.isNotEmpty ? gameDTO.messageDebut!.where((message) => message.language!.toUpperCase() == tabletAppContext.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<void> 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<Size> completer = Completer<Size>();
|
|
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<AppContext>(context, listen: false);
|
|
TabletAppContext tabletAppContext = appContext.getContext();
|
|
setState(() {
|
|
tabletAppContext.puzzleSize = Size(puzzleWidth, puzzleHeight);
|
|
appContext.setContext(tabletAppContext);
|
|
});
|
|
|
|
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<int> 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<int> _getAdjacentIndices(int index, int rows, int cols) {
|
|
List<int> 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<int> 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<AppContext>(context, listen: false);
|
|
TabletAppContext tabletAppContext = appContext.getContext();
|
|
final duration = _gameStartTime != null ? DateTime.now().difference(_gameStartTime!).inSeconds : 0;
|
|
|
|
tabletAppContext.statisticsService?.track(
|
|
VisitEventType.gameComplete,
|
|
metadata: {'gameType': gameType, 'durationSeconds': duration},
|
|
);
|
|
|
|
TranslationAndResourceDTO? messageFin = gameDTO.messageFin != null && gameDTO.messageFin!.isNotEmpty ? gameDTO.messageFin!.where((message) => message.language!.toUpperCase() == tabletAppContext.language!.toUpperCase()).firstOrNull : null;
|
|
|
|
if(messageFin != null) {
|
|
showMessage(messageFin, appContext, context, size);
|
|
}
|
|
}
|
|
|
|
/// mymuseum code ces couleurs en dur par flavor client (rouge MDLF, bleu Fort).
|
|
/// tablet-app n'a pas de flavor : un seul APK sert tous les clients et la charte
|
|
/// vient de la configuration choisie au pincode. Le dégradé en est donc dérivé,
|
|
/// sinon l'écran s'afficherait en bleu chez un client dont la charte est rouge.
|
|
Color _primaryColor(TabletAppContext tabletAppContext) {
|
|
final raw = tabletAppContext.configuration?.primaryColor;
|
|
if (raw == null) return kTestSecondColor;
|
|
return Color(int.parse(raw.split('(0x')[1].split(')')[0], radix: 16));
|
|
}
|
|
|
|
List<Color> _gradientColors(TabletAppContext tabletAppContext) {
|
|
final base = _primaryColor(tabletAppContext);
|
|
return [
|
|
base,
|
|
Color.lerp(base, Colors.white, 0.15)!,
|
|
Color.lerp(base, Colors.white, 0.30)!,
|
|
];
|
|
}
|
|
|
|
Widget _buildContent(TabletAppContext tabletAppContext) {
|
|
if (gameDTO.gameType == GameTypes.SlidingPuzzle) {
|
|
if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon());
|
|
|
|
final puzzleSize = tabletAppContext.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 = tabletAppContext.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<AppContext>(context);
|
|
TabletAppContext tabletAppContext = appContext.getContext();
|
|
Size size = MediaQuery.of(context).size;
|
|
var title = TranslationHelper.get(widget.section.title, appContext.getContext());
|
|
String cleanedTitle = title.replaceAll('\n', ' ').replaceAll('<br>', ' ');
|
|
|
|
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: LinearGradient(
|
|
begin: Alignment.centerRight,
|
|
end: Alignment.centerLeft,
|
|
colors: _gradientColors(tabletAppContext),
|
|
),
|
|
image: widget.section.imageSource != null ? DecorationImage(
|
|
fit: BoxFit.cover,
|
|
opacity: 0.65,
|
|
image: NetworkImage(
|
|
widget.section.imageSource!,
|
|
),
|
|
): null,
|
|
),
|
|
),
|
|
Column(
|
|
children: <Widget>[
|
|
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: BoxDecoration(
|
|
color: _primaryColor(tabletAppContext),
|
|
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(tabletAppContext),
|
|
),
|
|
),
|
|
)
|
|
)
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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 ? _primaryColor(tabletAppContext) : Colors.grey[400],
|
|
child: const Icon(Icons.help_outline, color: Colors.white, size: 28),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
} |