diff --git a/android/gradle.properties b/android/gradle.properties index 777ac2de0980e935649cf32bd85097eaf789185a..cd2d833ca96b3d1ada4a39df51dc5f5ee67665b7 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true -app.versionName=0.0.16 -app.versionCode=16 +app.versionName=0.0.17 +app.versionCode=17 diff --git a/assets/fonts/Nunito-Bold.ttf b/assets/fonts/Nunito-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6519feb781449ebe0015cbc74dfd9e13110fbba9 Binary files /dev/null and b/assets/fonts/Nunito-Bold.ttf differ diff --git a/assets/fonts/Nunito-Light.ttf b/assets/fonts/Nunito-Light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8a0736c41cd6c2a1225d356bf274de1d0afc3497 Binary files /dev/null and b/assets/fonts/Nunito-Light.ttf differ diff --git a/assets/fonts/Nunito-Medium.ttf b/assets/fonts/Nunito-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..88fccdc0638b6f5d6ac49d9d269dc3d518618ad1 Binary files /dev/null and b/assets/fonts/Nunito-Medium.ttf differ diff --git a/assets/fonts/Nunito-Regular.ttf b/assets/fonts/Nunito-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e7b8375a896ef0cd8e06730a78c84532b377e784 Binary files /dev/null and b/assets/fonts/Nunito-Regular.ttf differ diff --git a/assets/translations/en.json b/assets/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..edfb95a30bace7bf5b2c37e3946e068ccbea735e --- /dev/null +++ b/assets/translations/en.json @@ -0,0 +1,16 @@ +{ + "app_name": "Solitaire", + + "long_press_to_quit": "Long press to quit game...", + + "bottom_nav_home": "Game", + "bottom_nav_settings": "Settings", + "bottom_nav_about": "About", + + "settings_title": "Settings", + "settings_label_theme": "Theme mode", + + "about_title": "About", + "about_content": "Solitaire.", + "about_version": "Version: {version}" +} diff --git a/assets/translations/fr.json b/assets/translations/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..a4e231eb9e29deab5b2082bfcd0e981020be4b9e --- /dev/null +++ b/assets/translations/fr.json @@ -0,0 +1,16 @@ +{ + "app_name": "Solitaire", + + "long_press_to_quit": "Appuyer longtemps pour quitter le jeu...", + + "bottom_nav_home": "Jeu", + "bottom_nav_settings": "Réglages", + "bottom_nav_about": "Infos", + + "settings_title": "Réglages", + "settings_label_theme": "Thème de couleurs", + + "about_title": "Informations", + "about_content": "Solitaire.", + "about_version": "Version : {version}" +} diff --git a/fastlane/metadata/android/en-US/changelogs/17.txt b/fastlane/metadata/android/en-US/changelogs/17.txt new file mode 100644 index 0000000000000000000000000000000000000000..6b884ec4ea3e942b988f5815114a1c3ab59810b8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/17.txt @@ -0,0 +1 @@ +Improve game conception/architecture. diff --git a/fastlane/metadata/android/fr-FR/changelogs/17.txt b/fastlane/metadata/android/fr-FR/changelogs/17.txt new file mode 100644 index 0000000000000000000000000000000000000000..386b2cdcc090f19264599ba6dc6b215246489bce --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/17.txt @@ -0,0 +1 @@ +Amélioration de la conception/architecture du jeu. diff --git a/lib/config/menu.dart b/lib/config/menu.dart new file mode 100644 index 0000000000000000000000000000000000000000..ca5d227fee343cb63c211fd9904fd9d1a48477b1 --- /dev/null +++ b/lib/config/menu.dart @@ -0,0 +1,54 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:unicons/unicons.dart'; + +import 'package:solitaire/ui/screens/about_page.dart'; +import 'package:solitaire/ui/screens/game_page.dart'; +import 'package:solitaire/ui/screens/settings_page.dart'; + +class MenuItem { + final String code; + final Icon icon; + final Widget page; + + const MenuItem({ + required this.code, + required this.icon, + required this.page, + }); +} + +class Menu { + static List<MenuItem> items = [ + const MenuItem( + code: 'bottom_nav_home', + icon: Icon(UniconsLine.home), + page: GamePage(), + ), + const MenuItem( + code: 'bottom_nav_settings', + icon: Icon(UniconsLine.setting), + page: SettingsPage(), + ), + const MenuItem( + code: 'bottom_nav_about', + icon: Icon(UniconsLine.info_circle), + page: AboutPage(), + ), + ]; + + static Widget getPageWidget(int pageIndex) { + return Menu.items.elementAt(pageIndex).page; + } + + static List<BottomNavigationBarItem> getMenuItems() { + return Menu.items + .map((MenuItem item) => BottomNavigationBarItem( + icon: item.icon, + label: tr(item.code), + )) + .toList(); + } + + static int itemsCount = Menu.items.length; +} diff --git a/lib/config/theme.dart b/lib/config/theme.dart new file mode 100644 index 0000000000000000000000000000000000000000..be390348c7868e7c63387df13e13c46de43f8a23 --- /dev/null +++ b/lib/config/theme.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; + +/// Colors from Tailwind CSS (v3.0) - June 2022 +/// +/// https://tailwindcss.com/docs/customizing-colors + +const int _primaryColor = 0xFF6366F1; +const MaterialColor primarySwatch = MaterialColor(_primaryColor, <int, Color>{ + 50: Color(0xFFEEF2FF), // indigo-50 + 100: Color(0xFFE0E7FF), // indigo-100 + 200: Color(0xFFC7D2FE), // indigo-200 + 300: Color(0xFFA5B4FC), // indigo-300 + 400: Color(0xFF818CF8), // indigo-400 + 500: Color(_primaryColor), // indigo-500 + 600: Color(0xFF4F46E5), // indigo-600 + 700: Color(0xFF4338CA), // indigo-700 + 800: Color(0xFF3730A3), // indigo-800 + 900: Color(0xFF312E81), // indigo-900 +}); + +const int _textColor = 0xFF64748B; +const MaterialColor textSwatch = MaterialColor(_textColor, <int, Color>{ + 50: Color(0xFFF8FAFC), // slate-50 + 100: Color(0xFFF1F5F9), // slate-100 + 200: Color(0xFFE2E8F0), // slate-200 + 300: Color(0xFFCBD5E1), // slate-300 + 400: Color(0xFF94A3B8), // slate-400 + 500: Color(_textColor), // slate-500 + 600: Color(0xFF475569), // slate-600 + 700: Color(0xFF334155), // slate-700 + 800: Color(0xFF1E293B), // slate-800 + 900: Color(0xFF0F172A), // slate-900 +}); + +const Color errorColor = Color(0xFFDC2626); // red-600 + +final ColorScheme lightColorScheme = ColorScheme.light( + primary: primarySwatch.shade500, + secondary: primarySwatch.shade500, + onSecondary: Colors.white, + error: errorColor, + background: textSwatch.shade200, + onBackground: textSwatch.shade500, + onSurface: textSwatch.shade500, + surface: textSwatch.shade50, + surfaceVariant: Colors.white, + shadow: textSwatch.shade900.withOpacity(.1), +); + +final ColorScheme darkColorScheme = ColorScheme.dark( + primary: primarySwatch.shade500, + secondary: primarySwatch.shade500, + onSecondary: Colors.white, + error: errorColor, + background: const Color(0xFF171724), + onBackground: textSwatch.shade400, + onSurface: textSwatch.shade300, + surface: const Color(0xFF262630), + surfaceVariant: const Color(0xFF282832), + shadow: textSwatch.shade900.withOpacity(.2), +); + +final ThemeData lightTheme = ThemeData( + colorScheme: lightColorScheme, + fontFamily: 'Nunito', + textTheme: TextTheme( + displayLarge: TextStyle( + color: textSwatch.shade700, + fontFamily: 'Nunito', + ), + displayMedium: TextStyle( + color: textSwatch.shade600, + fontFamily: 'Nunito', + ), + displaySmall: TextStyle( + color: textSwatch.shade500, + fontFamily: 'Nunito', + ), + headlineLarge: TextStyle( + color: textSwatch.shade700, + fontFamily: 'Nunito', + ), + headlineMedium: TextStyle( + color: textSwatch.shade600, + fontFamily: 'Nunito', + ), + headlineSmall: TextStyle( + color: textSwatch.shade500, + fontFamily: 'Nunito', + ), + titleLarge: TextStyle( + color: textSwatch.shade700, + fontFamily: 'Nunito', + ), + titleMedium: TextStyle( + color: textSwatch.shade600, + fontFamily: 'Nunito', + ), + titleSmall: TextStyle( + color: textSwatch.shade500, + fontFamily: 'Nunito', + ), + bodyLarge: TextStyle( + color: textSwatch.shade700, + fontFamily: 'Nunito', + ), + bodyMedium: TextStyle( + color: textSwatch.shade600, + fontFamily: 'Nunito', + ), + bodySmall: TextStyle( + color: textSwatch.shade500, + fontFamily: 'Nunito', + ), + labelLarge: TextStyle( + color: textSwatch.shade700, + fontFamily: 'Nunito', + ), + labelMedium: TextStyle( + color: textSwatch.shade600, + fontFamily: 'Nunito', + ), + labelSmall: TextStyle( + color: textSwatch.shade500, + fontFamily: 'Nunito', + ), + ), +); + +final ThemeData darkTheme = lightTheme.copyWith( + colorScheme: darkColorScheme, + textTheme: TextTheme( + displayLarge: TextStyle( + color: textSwatch.shade200, + fontFamily: 'Nunito', + ), + displayMedium: TextStyle( + color: textSwatch.shade300, + fontFamily: 'Nunito', + ), + displaySmall: TextStyle( + color: textSwatch.shade400, + fontFamily: 'Nunito', + ), + headlineLarge: TextStyle( + color: textSwatch.shade200, + fontFamily: 'Nunito', + ), + headlineMedium: TextStyle( + color: textSwatch.shade300, + fontFamily: 'Nunito', + ), + headlineSmall: TextStyle( + color: textSwatch.shade400, + fontFamily: 'Nunito', + ), + titleLarge: TextStyle( + color: textSwatch.shade200, + fontFamily: 'Nunito', + ), + titleMedium: TextStyle( + color: textSwatch.shade300, + fontFamily: 'Nunito', + ), + titleSmall: TextStyle( + color: textSwatch.shade400, + fontFamily: 'Nunito', + ), + bodyLarge: TextStyle( + color: textSwatch.shade200, + fontFamily: 'Nunito', + ), + bodyMedium: TextStyle( + color: textSwatch.shade300, + fontFamily: 'Nunito', + ), + bodySmall: TextStyle( + color: textSwatch.shade400, + fontFamily: 'Nunito', + ), + labelLarge: TextStyle( + color: textSwatch.shade200, + fontFamily: 'Nunito', + ), + labelMedium: TextStyle( + color: textSwatch.shade300, + fontFamily: 'Nunito', + ), + labelSmall: TextStyle( + color: textSwatch.shade400, + fontFamily: 'Nunito', + ), + ), +); + +final ThemeData appTheme = darkTheme; diff --git a/lib/cubit/bottom_nav_cubit.dart b/lib/cubit/bottom_nav_cubit.dart new file mode 100644 index 0000000000000000000000000000000000000000..8f04dffa9682eb52139e44fcb2ab9a4d75a613a8 --- /dev/null +++ b/lib/cubit/bottom_nav_cubit.dart @@ -0,0 +1,31 @@ +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import 'package:solitaire/config/menu.dart'; + +class BottomNavCubit extends HydratedCubit<int> { + BottomNavCubit() : super(0); + + void updateIndex(int index) { + if (isIndexAllowed(index)) { + emit(index); + } else { + goToHomePage(); + } + } + + bool isIndexAllowed(int index) { + return (index >= 0) && (index < Menu.itemsCount); + } + + void goToHomePage() => emit(0); + + @override + int fromJson(Map<String, dynamic> json) { + return 0; + } + + @override + Map<String, dynamic>? toJson(int state) { + return <String, int>{'pageIndex': state}; + } +} diff --git a/lib/cubit/theme_cubit.dart b/lib/cubit/theme_cubit.dart new file mode 100644 index 0000000000000000000000000000000000000000..b793e895dbb0c672d451cd403e0036c3d9ac9b42 --- /dev/null +++ b/lib/cubit/theme_cubit.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +part 'theme_state.dart'; + +class ThemeCubit extends HydratedCubit<ThemeModeState> { + ThemeCubit() : super(const ThemeModeState()); + + void getTheme(ThemeModeState state) { + emit(state); + } + + @override + ThemeModeState? fromJson(Map<String, dynamic> json) { + switch (json['themeMode']) { + case 'ThemeMode.dark': + return const ThemeModeState(themeMode: ThemeMode.dark); + case 'ThemeMode.light': + return const ThemeModeState(themeMode: ThemeMode.light); + case 'ThemeMode.system': + default: + return const ThemeModeState(themeMode: ThemeMode.system); + } + } + + @override + Map<String, String>? toJson(ThemeModeState state) { + return <String, String>{'themeMode': state.themeMode.toString()}; + } +} diff --git a/lib/cubit/theme_state.dart b/lib/cubit/theme_state.dart new file mode 100644 index 0000000000000000000000000000000000000000..e479a50f12fe72a35a1fd1722ff72afbb692a136 --- /dev/null +++ b/lib/cubit/theme_state.dart @@ -0,0 +1,15 @@ +part of 'theme_cubit.dart'; + +@immutable +class ThemeModeState extends Equatable { + const ThemeModeState({ + this.themeMode, + }); + + final ThemeMode? themeMode; + + @override + List<Object?> get props => <Object?>[ + themeMode, + ]; +} diff --git a/lib/entities/tile.dart b/lib/entities/tile.dart index c9d585dbd766be04de40247b75bfd1e903c786a3..5f88b97058b643f3b357bd60efdfc7bca2fe0e26 100644 --- a/lib/entities/tile.dart +++ b/lib/entities/tile.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:solitaire_game/provider/data.dart'; -import 'package:solitaire_game/utils/game_utils.dart'; +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/utils/game_utils.dart'; class Tile { int currentRow; diff --git a/lib/layout/board.dart b/lib/layout/board.dart deleted file mode 100644 index d00a12d842b5713a69249731d9e9e367d325e8c4..0000000000000000000000000000000000000000 --- a/lib/layout/board.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:solitaire_game/entities/tile.dart'; -import 'package:solitaire_game/provider/data.dart'; - -class Board { - static Widget buildGameBoard(Data myProvider) { - return Column( - children: [ - buildGameTileset(myProvider), - ], - ); - } - - static Table buildGameTileset(Data myProvider) { - List<List<Tile?>> board = myProvider.board; - - Widget boardTileWithoutHole = Image( - image: AssetImage('assets/skins/${myProvider.parameterSkin}_board.png'), - width: myProvider.tileSize, - height: myProvider.tileSize, - fit: BoxFit.fill, - ); - - return Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - children: [ - for (int row = 0; row < board.length; row++) - TableRow( - children: [ - for (int col = 0; col < board[row].length; col++) - TableCell( - child: board[row][col] != null - ? (board[row][col]?.render(myProvider) ?? Container()) - : boardTileWithoutHole, - ), - ], - ), - ], - ); - } -} diff --git a/lib/layout/game.dart b/lib/layout/game.dart deleted file mode 100644 index 69d533d52639dca309a4fe61efcc57bdf33ee457..0000000000000000000000000000000000000000 --- a/lib/layout/game.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:solitaire_game/layout/board.dart'; -import 'package:solitaire_game/provider/data.dart'; -import 'package:solitaire_game/utils/game_utils.dart'; - -class Game { - static Widget buildGameWidget(Data myProvider) { - final bool gameIsFinished = myProvider.gameIsFinished; - - return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 8), - Game.buildTopIndicatorWidget(myProvider), - const SizedBox(height: 2), - Expanded( - child: Board.buildGameBoard(myProvider), - ), - const SizedBox(height: 2), - Container( - child: gameIsFinished - ? Game.buildEndGameMessage(myProvider) - : const SizedBox(height: 2), - ), - ], - ); - } - - static Widget buildTopIndicatorWidget(Data myProvider) { - final int allowedMovesCount = myProvider.allowedMovesCount; - - return Table( - children: [ - TableRow( - children: [ - Column( - children: [ - Text( - '♟️ ${myProvider.remainingPegsCount}', - style: const TextStyle( - fontSize: 40, - fontWeight: FontWeight.w600, - color: Colors.black, - ), - ), - ], - ), - Column( - children: [ - Text( - allowedMovesCount.toString(), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Colors.green, - ), - ), - ], - ), - ], - ), - ], - ); - } - - static TextButton buildQuitGameButton(Data myProvider) { - return TextButton( - child: const Image( - image: AssetImage('assets/icons/button_back.png'), - fit: BoxFit.fill, - ), - onPressed: () => GameUtils.quitAndDeleteCurrentGame(myProvider), - ); - } - - static Container buildEndGameMessage(Data myProvider) { - String decorationImageAssetName = ''; - if (myProvider.gameWon()) { - decorationImageAssetName = 'assets/icons/game_win.png'; - } else { - decorationImageAssetName = 'assets/icons/placeholder.png'; - } - - final Image decorationImage = Image( - image: AssetImage(decorationImageAssetName), - fit: BoxFit.fill, - ); - - return Container( - margin: const EdgeInsets.all(2), - padding: const EdgeInsets.all(2), - child: Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - children: [ - TableRow( - children: [ - Column( - children: [decorationImage], - ), - Column( - children: [buildQuitGameButton(myProvider)], - ), - Column( - children: [decorationImage], - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/layout/parameters.dart b/lib/layout/parameters.dart deleted file mode 100644 index f2edf749b878980c9b953f55c87ebf320e2592f7..0000000000000000000000000000000000000000 --- a/lib/layout/parameters.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:solitaire_game/provider/data.dart'; -import 'package:solitaire_game/utils/game_utils.dart'; - -class Parameters { - static double separatorHeight = 2.0; - static double blockMargin = 3.0; - static double blockPadding = 2.0; - static Color buttonBackgroundColor = Colors.white; - static Color buttonBorderColorActive = Colors.blue; - static Color buttonBorderColorInactive = Colors.white; - static double buttonBorderWidth = 10.0; - static double buttonBorderRadius = 8.0; - static double buttonPadding = 0.0; - static double buttonMargin = 0.0; - - static Widget buildParametersSelector(Data myProvider) { - List<Widget> lines = []; - - List parameters = myProvider.availableParameters; - for (int index = 0; index < parameters.length; index++) { - lines.add(buildParameterSelector(myProvider, parameters[index])); - lines.add(SizedBox(height: separatorHeight)); - } - - myProvider.loadCurrentSavedState(); - Widget buttonsBlock = myProvider.hasCurrentSavedState() - ? buildResumeGameButton(myProvider) - : buildStartNewGameButton(myProvider); - - return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: separatorHeight), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: lines, - ), - ), - SizedBox(height: separatorHeight), - Container( - child: buttonsBlock, - ), - ], - ); - } - - static Image buildImageWidget(String imageAssetCode) { - return Image( - image: AssetImage('assets/icons/$imageAssetCode.png'), - fit: BoxFit.fill, - ); - } - - static Container buildImageContainerWidget(String imageAssetCode) { - return Container( - child: buildImageWidget(imageAssetCode), - ); - } - - static Column buildDecorationImageWidget() { - return Column( - children: [ - TextButton( - child: buildImageContainerWidget('placeholder'), - onPressed: () {}, - ), - ], - ); - } - - static Container buildStartNewGameButton(Data myProvider) { - return Container( - margin: EdgeInsets.all(blockMargin), - padding: EdgeInsets.all(blockPadding), - child: Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - children: [ - TableRow( - children: [ - buildDecorationImageWidget(), - Column( - children: [ - TextButton( - child: buildImageContainerWidget('button_start'), - onPressed: () => GameUtils.startNewGame(myProvider), - ), - ], - ), - buildDecorationImageWidget(), - ], - ), - ], - ), - ); - } - - static Container buildResumeGameButton(Data myProvider) { - return Container( - margin: EdgeInsets.all(blockMargin), - padding: EdgeInsets.all(blockPadding), - child: Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - children: [ - TableRow( - children: [ - Column( - children: [ - TextButton( - child: buildImageContainerWidget('button_delete_saved_game'), - onPressed: () => GameUtils.deleteSavedGame(myProvider), - ), - ], - ), - Column( - children: [ - TextButton( - child: buildImageContainerWidget('button_resume_game'), - onPressed: () => GameUtils.resumeSavedGame(myProvider), - ), - ], - ), - buildDecorationImageWidget(), - ], - ), - ], - ), - ); - } - - static Widget buildParameterSelector(Data myProvider, String parameterCode) { - List availableValues = myProvider.getParameterAvailableValues(parameterCode); - - if (availableValues.length == 1) { - return const SizedBox(height: 0.0); - } - - return Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - children: [ - TableRow( - children: [ - for (int index = 0; index < availableValues.length; index++) - Column( - children: [ - _buildParameterButton(myProvider, parameterCode, availableValues[index]) - ], - ), - ], - ), - ], - ); - } - - static Widget _buildParameterButton( - Data myProvider, String parameterCode, String parameterValue) { - String currentValue = myProvider.getParameterValue(parameterCode).toString(); - - bool isActive = (parameterValue == currentValue); - String imageAsset = '${parameterCode}_$parameterValue'; - - return TextButton( - child: Container( - margin: EdgeInsets.all(buttonMargin), - padding: EdgeInsets.all(buttonPadding), - decoration: BoxDecoration( - color: buttonBackgroundColor, - borderRadius: BorderRadius.circular(buttonBorderRadius), - border: Border.all( - color: isActive ? buttonBorderColorActive : buttonBorderColorInactive, - width: buttonBorderWidth, - ), - ), - child: buildImageWidget(imageAsset), - ), - onPressed: () => myProvider.setParameterValue(parameterCode, parameterValue), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index 1967ce48b7b68fc784b45da9f4ee9e121c7acdd9..e84151f467ffe84165623e79580b02b340e2aa35 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,42 @@ +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hive/hive.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:overlay_support/overlay_support.dart'; -import 'package:solitaire_game/provider/data.dart'; -import 'package:solitaire_game/screens/home.dart'; -void main() { +import 'package:solitaire/config/theme.dart'; +import 'package:solitaire/cubit/bottom_nav_cubit.dart'; +import 'package:solitaire/cubit/theme_cubit.dart'; +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/ui/skeleton.dart'; + +void main() async { + /// Initialize packages WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]) - .then((value) => runApp(const MyApp())); + await EasyLocalization.ensureInitialized(); + final Directory tmpDir = await getTemporaryDirectory(); + Hive.init(tmpDir.toString()); + HydratedBloc.storage = await HydratedStorage.build( + storageDirectory: tmpDir, + ); + + runApp( + EasyLocalization( + path: 'assets/translations', + supportedLocales: const <Locale>[ + Locale('en'), + Locale('fr'), + ], + fallbackLocale: const Locale('en'), + useFallbackTranslations: true, + child: const MyApp(), + ), + ); } class MyApp extends StatelessWidget { @@ -16,23 +44,39 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (BuildContext context) => Data(), - child: Consumer<Data>(builder: (context, data, child) { - return OverlaySupport( - child: MaterialApp( - debugShowCheckedModeBanner: false, - theme: ThemeData( - primaryColor: Colors.blue, - visualDensity: VisualDensity.adaptivePlatformDensity, + return MultiBlocProvider( + providers: [ + BlocProvider<BottomNavCubit>(create: (context) => BottomNavCubit()), + BlocProvider<ThemeCubit>(create: (context) => ThemeCubit()), + ], + child: BlocBuilder<ThemeCubit, ThemeModeState>( + builder: (BuildContext context, ThemeModeState state) { + return ChangeNotifierProvider( + create: (BuildContext context) => Data(), + child: Consumer<Data>( + builder: (context, data, child) { + return OverlaySupport( + child: MaterialApp( + title: 'Solitaire', + home: const SkeletonScreen(), + + // Theme stuff + theme: lightTheme, + darkTheme: darkTheme, + themeMode: state.themeMode, + + // Localization stuff + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + debugShowCheckedModeBanner: false, + ), + ); + }, ), - home: const Home(), - routes: { - Home.id: (context) => const Home(), - }, - ), - ); - }), + ); + }, + ), ); } } diff --git a/lib/provider/data.dart b/lib/provider/data.dart index 699b5c5f0cde537a8a9f39334b3e1758dcb1050f..9c18e794f5d5eb7045d31de5a7a7c3b26582a444 100644 --- a/lib/provider/data.dart +++ b/lib/provider/data.dart @@ -2,8 +2,10 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:solitaire_game/entities/tile.dart'; -import 'package:solitaire_game/utils/game_utils.dart'; +import 'package:solitaire/entities/tile.dart'; +import 'package:solitaire/utils/game_utils.dart'; + +typedef Board = List<List<Tile?>>; class Data extends ChangeNotifier { // Configuration available values @@ -30,7 +32,7 @@ class Data extends ChangeNotifier { bool _assetsPreloaded = false; bool _gameIsRunning = false; bool _gameIsFinished = false; - List<List<Tile?>> _board = []; + Board _board = []; int _boardSize = 0; double _tileSize = 0; int _movesCount = 0; @@ -145,7 +147,7 @@ class Data extends ChangeNotifier { Map<String, dynamic> getCurrentSavedState() { if (_currentState != '') { - Map<String, dynamic> savedState = json.decode(_currentState); + final Map<String, dynamic> savedState = json.decode(_currentState); if (savedState.isNotEmpty) { return savedState; } @@ -168,8 +170,8 @@ class Data extends ChangeNotifier { _boardSize = boardSize; } - List<List<Tile?>> get board => _board; - void updateBoard(List<List<Tile?>> board) { + Board get board => _board; + void updateBoard(Board board) { _board = board; updateBoardSize(board.length); updateRemainingPegsCount(GameUtils.countRemainingPegs(this)); diff --git a/lib/screens/home.dart b/lib/screens/home.dart deleted file mode 100644 index 62b3a0ff7abf625c6460087864f9f7430dfabeff..0000000000000000000000000000000000000000 --- a/lib/screens/home.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:overlay_support/overlay_support.dart'; -import 'package:solitaire_game/layout/game.dart'; -import 'package:solitaire_game/layout/parameters.dart'; -import 'package:solitaire_game/provider/data.dart'; -import 'package:solitaire_game/utils/game_utils.dart'; - -class Home extends StatefulWidget { - const Home({super.key}); - - static const String id = 'home'; - - @override - HomeState createState() => HomeState(); -} - -class HomeState extends State<Home> { - @override - void initState() { - super.initState(); - - Data myProvider = Provider.of<Data>(context, listen: false); - myProvider.initParametersValues(); - myProvider.loadCurrentSavedState(); - } - - List<String> getImagesAssets(Data myProvider) { - final List<String> assets = []; - - final List<String> gameImages = [ - 'button_back', - 'button_delete_saved_game', - 'button_resume_game', - 'button_start', - 'game_fail', - 'game_win', - 'placeholder', - ]; - for (String layout in myProvider.availableLayoutValues) { - gameImages.add('layout_$layout'); - } - for (String skin in myProvider.availableSkinValues) { - gameImages.add('skin_$skin'); - } - - for (String image in gameImages) { - assets.add('${'assets/icons/$image'}.png'); - } - - const List<String> skinImages = [ - 'board', - 'hole', - 'peg', - ]; - - for (String skin in myProvider.availableSkinValues) { - for (String image in skinImages) { - assets.add('${'${'assets/skins/$skin'}_$image'}.png'); - } - } - - return assets; - } - - @override - Widget build(BuildContext context) { - Data myProvider = Provider.of<Data>(context); - - if (!myProvider.assetsPreloaded) { - List<String> assets = getImagesAssets(myProvider); - for (String asset in assets) { - precacheImage(AssetImage(asset), context); - } - myProvider.updateAssetsPreloaded(true); - } - - myProvider.updateTileSize((MediaQuery.of(context).size.width - 40) / myProvider.boardSize); - - List<Widget> menuActions = []; - - if (myProvider.gameIsRunning) { - menuActions = [ - TextButton( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: Colors.blue, - width: 4, - ), - ), - child: const Image( - image: AssetImage('assets/icons/button_back.png'), - fit: BoxFit.fill, - ), - ), - onPressed: () => toast('Long press to quit game...'), - onLongPress: () => GameUtils.quitGame(myProvider), - ), - ]; - } - - return Scaffold( - appBar: AppBar( - actions: menuActions, - ), - body: SafeArea( - child: Center( - child: myProvider.gameIsRunning - ? Game.buildGameWidget(myProvider) - : Parameters.buildParametersSelector(myProvider), - ), - ), - ); - } -} diff --git a/lib/ui/layout/game.dart b/lib/ui/layout/game.dart new file mode 100644 index 0000000000000000000000000000000000000000..aa9d47c9fdf68e5c14eff6121c3108ceb10376bb --- /dev/null +++ b/lib/ui/layout/game.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/ui/layout/tileset.dart'; +import 'package:solitaire/ui/widgets/game/indicator_top.dart'; +import 'package:solitaire/ui/widgets/game/message_game_end.dart'; + +class Game extends StatelessWidget { + const Game({super.key, required this.myProvider}); + + final Data myProvider; + + @override + Widget build(BuildContext context) { + final bool gameIsFinished = myProvider.gameIsFinished; + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 8), + TopIndicator(myProvider: myProvider), + const SizedBox(height: 2), + Expanded( + child: Tileset(myProvider: myProvider), + ), + const SizedBox(height: 2), + Container( + child: gameIsFinished + ? EndGameMessage(myProvider: myProvider) + : const SizedBox(height: 2), + ), + ], + ); + } +} diff --git a/lib/ui/layout/parameters.dart b/lib/ui/layout/parameters.dart new file mode 100644 index 0000000000000000000000000000000000000000..68d9ac836bb4c5bfc6c5017ec291fe3da4819f03 --- /dev/null +++ b/lib/ui/layout/parameters.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/ui/widgets/home/button_game_resume.dart'; +import 'package:solitaire/ui/widgets/home/button_game_start_new.dart'; + +class Parameters extends StatelessWidget { + const Parameters({super.key, required this.myProvider}); + + final Data myProvider; + + static const double separatorHeight = 2.0; + static const double blockMargin = 0.0; + static const double blockPadding = 0.0; + static const Color buttonBackgroundColor = Colors.white; + static const Color buttonBorderColorActive = Colors.blue; + static const Color buttonBorderColorInactive = Colors.white; + static const double buttonBorderWidth = 6.0; + static const double buttonBorderRadius = 8.0; + static const double buttonPadding = 0.0; + static const double buttonMargin = 0.0; + + @override + Widget build(BuildContext context) { + List<Widget> lines = []; + + List parameters = myProvider.availableParameters; + for (int index = 0; index < parameters.length; index++) { + lines.add(buildParameterSelector(myProvider, parameters[index])); + lines.add(const SizedBox(height: separatorHeight)); + } + + myProvider.loadCurrentSavedState(); + Widget buttonsBlock = myProvider.hasCurrentSavedState() + ? ResumeGameButton(myProvider: myProvider) + : StartNewGameButton(myProvider: myProvider); + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: separatorHeight), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: lines, + ), + ), + const SizedBox(height: separatorHeight), + Container( + child: buttonsBlock, + ), + ], + ); + } + + static Image buildImageWidget(String imageAssetCode) { + return Image( + image: AssetImage('assets/icons/$imageAssetCode.png'), + fit: BoxFit.fill, + ); + } + + static Container buildImageContainerWidget(String imageAssetCode) { + return Container( + child: buildImageWidget(imageAssetCode), + ); + } + + static Column buildDecorationImageWidget() { + return Column( + children: [ + TextButton( + child: buildImageContainerWidget('placeholder'), + onPressed: () {}, + ), + ], + ); + } + + Widget buildParameterSelector(Data myProvider, String parameterCode) { + List availableValues = myProvider.getParameterAvailableValues(parameterCode); + + if (availableValues.length == 1) { + return const SizedBox(height: 0.0); + } + + return Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + TableRow( + children: [ + for (int index = 0; index < availableValues.length; index++) + Column( + children: [ + buildParameterButton(myProvider, parameterCode, availableValues[index]) + ], + ), + ], + ), + ], + ); + } + + Widget buildParameterButton(Data myProvider, String parameterCode, String parameterValue) { + String currentValue = myProvider.getParameterValue(parameterCode).toString(); + + bool isActive = (parameterValue == currentValue); + String imageAsset = '${parameterCode}_$parameterValue'; + + return TextButton( + child: Container( + margin: const EdgeInsets.all(buttonMargin), + padding: const EdgeInsets.all(buttonPadding), + decoration: BoxDecoration( + color: buttonBackgroundColor, + borderRadius: BorderRadius.circular(buttonBorderRadius), + border: Border.all( + color: isActive ? buttonBorderColorActive : buttonBorderColorInactive, + width: buttonBorderWidth, + ), + ), + child: buildImageWidget(imageAsset), + ), + onPressed: () => myProvider.setParameterValue(parameterCode, parameterValue), + ); + } +} diff --git a/lib/ui/layout/tileset.dart b/lib/ui/layout/tileset.dart new file mode 100644 index 0000000000000000000000000000000000000000..4fe85a743bfc7cb8ccb3035a3dddd9c32d6b210c --- /dev/null +++ b/lib/ui/layout/tileset.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/provider/data.dart'; + +class Tileset extends StatelessWidget { + const Tileset({super.key, required this.myProvider}); + + final Data myProvider; + + @override + Widget build(BuildContext context) { + final Board board = myProvider.board; + + Widget boardTileWithoutHole = Image( + image: AssetImage('assets/skins/${myProvider.parameterSkin}_board.png'), + width: myProvider.tileSize, + height: myProvider.tileSize, + fit: BoxFit.fill, + ); + + return Column( + children: [ + Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + for (int row = 0; row < board.length; row++) + TableRow( + children: [ + for (int col = 0; col < board[row].length; col++) + TableCell( + child: board[row][col] != null + ? (board[row][col]?.render(myProvider) ?? Container()) + : boardTileWithoutHole, + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/lib/ui/screens/about_page.dart b/lib/ui/screens/about_page.dart new file mode 100644 index 0000000000000000000000000000000000000000..f22e2adbcf2c3fbf29b74937ef251a5ab64db479 --- /dev/null +++ b/lib/ui/screens/about_page.dart @@ -0,0 +1,41 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:solitaire/ui/widgets/header_app.dart'; + +class AboutPage extends StatelessWidget { + const AboutPage({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: <Widget>[ + const SizedBox(height: 8), + const AppHeader(text: 'about_title'), + const Text('about_content').tr(), + FutureBuilder<PackageInfo>( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.done: + return const Text('about_version').tr( + namedArgs: { + 'version': snapshot.data!.version, + }, + ); + default: + return const SizedBox(); + } + }, + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/game_page.dart b/lib/ui/screens/game_page.dart new file mode 100644 index 0000000000000000000000000000000000000000..9171fc2ac981a36123338eedce6cd09105852e5f --- /dev/null +++ b/lib/ui/screens/game_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/ui/layout/game.dart'; +import 'package:solitaire/ui/layout/parameters.dart'; + +class GamePage extends StatefulWidget { + const GamePage({super.key}); + + @override + GamePageState createState() => GamePageState(); +} + +class GamePageState extends State<GamePage> { + @override + void initState() { + super.initState(); + + final Data myProvider = Provider.of<Data>(context, listen: false); + myProvider.initParametersValues(); + myProvider.loadCurrentSavedState(); + } + + List<String> getImagesAssets(Data myProvider) { + final List<String> assets = []; + + final List<String> gameImages = [ + 'button_back', + 'button_delete_saved_game', + 'button_resume_game', + 'button_start', + 'game_fail', + 'game_win', + 'placeholder', + ]; + for (String layout in myProvider.availableLayoutValues) { + gameImages.add('layout_$layout'); + } + for (String skin in myProvider.availableSkinValues) { + gameImages.add('skin_$skin'); + } + + for (String image in gameImages) { + assets.add('${'assets/icons/$image'}.png'); + } + + const List<String> skinImages = [ + 'board', + 'hole', + 'peg', + ]; + + for (String skin in myProvider.availableSkinValues) { + for (String image in skinImages) { + assets.add('${'${'assets/skins/$skin'}_$image'}.png'); + } + } + + return assets; + } + + @override + Widget build(BuildContext context) { + final Data myProvider = Provider.of<Data>(context); + + if (!myProvider.assetsPreloaded) { + final List<String> assets = getImagesAssets(myProvider); + for (String asset in assets) { + precacheImage(AssetImage(asset), context); + } + myProvider.updateAssetsPreloaded(true); + } + + myProvider.updateTileSize((MediaQuery.of(context).size.width - 40) / myProvider.boardSize); + + return SafeArea( + child: Center( + child: myProvider.gameIsRunning + ? Game(myProvider: myProvider) + : Parameters(myProvider: myProvider), + ), + ); + } +} diff --git a/lib/ui/screens/settings_page.dart b/lib/ui/screens/settings_page.dart new file mode 100644 index 0000000000000000000000000000000000000000..63cb3f2c502e952d128108dceff49b16dce7a10d --- /dev/null +++ b/lib/ui/screens/settings_page.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/ui/widgets/header_app.dart'; +import 'package:solitaire/ui/widgets/settings/settings_form.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: <Widget>[ + SizedBox(height: 8), + AppHeader(text: 'settings_title'), + SizedBox(height: 8), + SettingsForm(), + ], + ), + ); + } +} diff --git a/lib/ui/skeleton.dart b/lib/ui/skeleton.dart new file mode 100644 index 0000000000000000000000000000000000000000..36cd48520de4304219d8273481aecd572b181e5f --- /dev/null +++ b/lib/ui/skeleton.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +import 'package:solitaire/config/menu.dart'; +import 'package:solitaire/cubit/bottom_nav_cubit.dart'; +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/ui/widgets/app_bar.dart'; +import 'package:solitaire/ui/widgets/bottom_nav_bar.dart'; + +class SkeletonScreen extends StatefulWidget { + const SkeletonScreen({super.key}); + + @override + State<SkeletonScreen> createState() => _SkeletonScreenState(); +} + +class _SkeletonScreenState extends State<SkeletonScreen> { + @override + Widget build(BuildContext context) { + final Data myProvider = Provider.of<Data>(context); + + return Scaffold( + extendBodyBehindAppBar: false, + appBar: StandardAppBar(myProvider: myProvider), + bottomNavigationBar: const BottomNavBar(), + body: BlocBuilder<BottomNavCubit, int>(builder: (BuildContext context, int state) { + return Menu.getPageWidget(state); + }), + backgroundColor: Theme.of(context).colorScheme.background, + ); + } +} diff --git a/lib/ui/widgets/app_bar.dart b/lib/ui/widgets/app_bar.dart new file mode 100644 index 0000000000000000000000000000000000000000..1dc274e7c430ae5822e3c01d400e16ee1844227f --- /dev/null +++ b/lib/ui/widgets/app_bar.dart @@ -0,0 +1,37 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:overlay_support/overlay_support.dart'; + +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/ui/widgets/header_app.dart'; +import 'package:solitaire/utils/game_utils.dart'; + +class StandardAppBar extends StatelessWidget implements PreferredSizeWidget { + const StandardAppBar({super.key, required this.myProvider}); + + final Data myProvider; + + @override + Widget build(BuildContext context) { + final List<Widget> menuActions = []; + + if (myProvider.gameIsRunning) { + menuActions.add(TextButton( + child: const Image( + image: AssetImage('assets/icons/button_back.png'), + fit: BoxFit.fill, + ), + onPressed: () => toast(tr('long_press_to_quit')), + onLongPress: () => GameUtils.quitGame(myProvider), + )); + } + + return AppBar( + title: const AppHeader(text: 'app_name'), + actions: menuActions, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(50); +} diff --git a/lib/ui/widgets/bottom_nav_bar.dart b/lib/ui/widgets/bottom_nav_bar.dart new file mode 100644 index 0000000000000000000000000000000000000000..7e3d4717dfcaff4a58173b18531da62361a389b8 --- /dev/null +++ b/lib/ui/widgets/bottom_nav_bar.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:solitaire/config/menu.dart'; +import 'package:solitaire/cubit/bottom_nav_cubit.dart'; + +class BottomNavBar extends StatelessWidget { + const BottomNavBar({super.key}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.all(0), + elevation: 4, + shadowColor: Theme.of(context).colorScheme.shadow, + color: Theme.of(context).colorScheme.surfaceVariant, + shape: const ContinuousRectangleBorder(), + child: BlocBuilder<BottomNavCubit, int>( + builder: (BuildContext context, int state) { + return BottomNavigationBar( + currentIndex: state, + onTap: (int index) { + context.read<BottomNavCubit>().updateIndex(index); + }, + type: BottomNavigationBarType.fixed, + elevation: 0, + backgroundColor: Colors.transparent, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: Theme.of(context).textTheme.bodySmall!.color, + items: Menu.getMenuItems(), + ); + }, + ), + ); + } +} diff --git a/lib/ui/widgets/game/indicator_top.dart b/lib/ui/widgets/game/indicator_top.dart new file mode 100644 index 0000000000000000000000000000000000000000..7ce0c3ef4fe6cfeab481697472e822a1823768cd --- /dev/null +++ b/lib/ui/widgets/game/indicator_top.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/provider/data.dart'; + +class TopIndicator extends StatelessWidget { + const TopIndicator({super.key, required this.myProvider}); + + final Data myProvider; + + @override + Widget build(BuildContext context) { + final int allowedMovesCount = myProvider.allowedMovesCount; + + return Table( + children: [ + TableRow( + children: [ + Column( + children: [ + Text( + '♟️ ${myProvider.remainingPegsCount}', + style: const TextStyle( + fontSize: 40, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + ], + ), + Column( + children: [ + Text( + allowedMovesCount.toString(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.green, + ), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/lib/ui/widgets/game/message_game_end.dart b/lib/ui/widgets/game/message_game_end.dart new file mode 100644 index 0000000000000000000000000000000000000000..cd6ca3ad64384a7cf48e93a3c91519bd04ffe06f --- /dev/null +++ b/lib/ui/widgets/game/message_game_end.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/ui/widgets/home/button_game_restart.dart'; + +class EndGameMessage extends StatelessWidget { + const EndGameMessage({super.key, required this.myProvider}); + + final Data myProvider; + + @override + Widget build(BuildContext context) { + String decorationImageAssetName = ''; + if (myProvider.gameWon()) { + decorationImageAssetName = 'assets/icons/game_win.png'; + } else { + decorationImageAssetName = 'assets/icons/placeholder.png'; + } + + final Image decorationImage = Image( + image: AssetImage(decorationImageAssetName), + fit: BoxFit.fill, + ); + + return Container( + margin: const EdgeInsets.all(2), + padding: const EdgeInsets.all(2), + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + TableRow( + children: [ + Column(children: [decorationImage]), + Column(children: [RestartGameButton(myProvider: myProvider)]), + Column(children: [decorationImage]), + ], + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/header_app.dart b/lib/ui/widgets/header_app.dart new file mode 100644 index 0000000000000000000000000000000000000000..bf54b77375fbd0260f876f2885d0572b71715383 --- /dev/null +++ b/lib/ui/widgets/header_app.dart @@ -0,0 +1,23 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class AppHeader extends StatelessWidget { + const AppHeader({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr(text), + textAlign: TextAlign.start, + style: Theme.of(context).textTheme.headlineMedium!.apply(fontWeightDelta: 2), + ), + ], + ); + } +} diff --git a/lib/ui/widgets/home/button_game_restart.dart b/lib/ui/widgets/home/button_game_restart.dart new file mode 100644 index 0000000000000000000000000000000000000000..48dfc3438732370f607b916c7f15a4c1b6968673 --- /dev/null +++ b/lib/ui/widgets/home/button_game_restart.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/utils/game_utils.dart'; + +class RestartGameButton extends StatelessWidget { + const RestartGameButton({super.key, required this.myProvider}); + + final Data myProvider; + + @override + Widget build(BuildContext context) { + return TextButton( + child: const Image( + image: AssetImage('assets/icons/button_back.png'), + fit: BoxFit.fill, + ), + onPressed: () => GameUtils.quitAndDeleteCurrentGame(myProvider), + ); + } +} diff --git a/lib/ui/widgets/home/button_game_resume.dart b/lib/ui/widgets/home/button_game_resume.dart new file mode 100644 index 0000000000000000000000000000000000000000..185c92f9c1fc6d607c9d947804700a74f8ae4a84 --- /dev/null +++ b/lib/ui/widgets/home/button_game_resume.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/ui/layout/parameters.dart'; +import 'package:solitaire/utils/game_utils.dart'; + +class ResumeGameButton extends Parameters { + const ResumeGameButton({super.key, required super.myProvider}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(Parameters.blockMargin), + padding: const EdgeInsets.all(Parameters.blockPadding), + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + TableRow( + children: [ + Column( + children: [ + TextButton( + child: Parameters.buildImageContainerWidget('button_delete_saved_game'), + onPressed: () => GameUtils.deleteSavedGame(myProvider), + ), + ], + ), + Column( + children: [ + TextButton( + child: Parameters.buildImageContainerWidget('button_resume_game'), + onPressed: () => GameUtils.resumeSavedGame(myProvider), + ), + ], + ), + Parameters.buildDecorationImageWidget(), + ], + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/home/button_game_start_new.dart b/lib/ui/widgets/home/button_game_start_new.dart new file mode 100644 index 0000000000000000000000000000000000000000..2de0c55bc99e5830965595bde7dd09617add80a2 --- /dev/null +++ b/lib/ui/widgets/home/button_game_start_new.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/ui/layout/parameters.dart'; +import 'package:solitaire/utils/game_utils.dart'; + +class StartNewGameButton extends StatelessWidget { + const StartNewGameButton({super.key, required this.myProvider}); + + final Data myProvider; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(Parameters.blockMargin), + padding: const EdgeInsets.all(Parameters.blockPadding), + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + TableRow( + children: [ + Parameters.buildDecorationImageWidget(), + Column( + children: [ + TextButton( + child: Parameters.buildImageContainerWidget('button_start'), + onPressed: () => GameUtils.startNewGame(myProvider), + ), + ], + ), + Parameters.buildDecorationImageWidget(), + ], + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/settings/settings_form.dart b/lib/ui/widgets/settings/settings_form.dart new file mode 100644 index 0000000000000000000000000000000000000000..91e9fd50423ac3bdadcccfde1abb3d0939e0dcba --- /dev/null +++ b/lib/ui/widgets/settings/settings_form.dart @@ -0,0 +1,63 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:unicons/unicons.dart'; + +import 'package:solitaire/ui/widgets/settings/theme_card.dart'; + +class SettingsForm extends StatefulWidget { + const SettingsForm({super.key}); + + @override + State<SettingsForm> createState() => _SettingsFormState(); +} + +class _SettingsFormState extends State<SettingsForm> { + @override + void dispose() { + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: <Widget>[ + // Light/dark theme + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: <Widget>[ + const Text('settings_label_theme').tr(), + const Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ThemeCard( + mode: ThemeMode.system, + icon: UniconsLine.cog, + ), + ThemeCard( + mode: ThemeMode.light, + icon: UniconsLine.sun, + ), + ThemeCard( + mode: ThemeMode.dark, + icon: UniconsLine.moon, + ) + ], + ), + ], + ), + + const SizedBox(height: 16), + ], + ); + } +} diff --git a/lib/ui/widgets/settings/theme_card.dart b/lib/ui/widgets/settings/theme_card.dart new file mode 100644 index 0000000000000000000000000000000000000000..71aafb03b99cb4909868892735a2ec48e019b01b --- /dev/null +++ b/lib/ui/widgets/settings/theme_card.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:solitaire/cubit/theme_cubit.dart'; + +class ThemeCard extends StatelessWidget { + const ThemeCard({ + super.key, + required this.mode, + required this.icon, + }); + + final IconData icon; + final ThemeMode mode; + + @override + Widget build(BuildContext context) { + return BlocBuilder<ThemeCubit, ThemeModeState>( + builder: (BuildContext context, ThemeModeState state) { + return Card( + elevation: 2, + shadowColor: Theme.of(context).colorScheme.shadow, + color: state.themeMode == mode + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + margin: const EdgeInsets.all(5), + child: InkWell( + onTap: () => BlocProvider.of<ThemeCubit>(context).getTheme( + ThemeModeState(themeMode: mode), + ), + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Icon( + icon, + size: 32, + color: state.themeMode != mode + ? Theme.of(context).colorScheme.primary + : Colors.white, + ), + ), + ); + }, + ); + } +} diff --git a/lib/utils/board_utils.dart b/lib/utils/board_utils.dart index 19754fb6a536c31587ff7118954b0c3e22f0c5b8..349b05dd4e48f223109bb7647591e9bcbb27a12b 100644 --- a/lib/utils/board_utils.dart +++ b/lib/utils/board_utils.dart @@ -1,8 +1,8 @@ import 'dart:math'; -import 'package:solitaire_game/entities/tile.dart'; -import 'package:solitaire_game/provider/data.dart'; -import 'package:solitaire_game/utils/tools.dart'; +import 'package:solitaire/entities/tile.dart'; +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/utils/tools.dart'; class BoardUtils { static printGrid(List cells) { @@ -28,8 +28,8 @@ class BoardUtils { printlog(''); } - static List<List<Tile?>> createBoardFromSavedState(Data myProvider, String savedBoard) { - List<List<Tile?>> board = []; + static Board createBoardFromSavedState(Data myProvider, String savedBoard) { + Board board = []; int boardSize = pow((savedBoard.length), 1 / 2).round(); myProvider.updateBoardSize(boardSize); @@ -99,7 +99,7 @@ class BoardUtils { List<String>? template = templates[myProvider.parameterLayout]; - List<List<Tile?>> grid = []; + Board grid = []; int row = 0; template?.forEach((String line) { List<Tile?> gridLine = []; diff --git a/lib/utils/game_utils.dart b/lib/utils/game_utils.dart index cd0c5ea7c309f56876771e558b54a1d8c7540670..69b0f4f014abb036969d43e86a62c94f405540e3 100644 --- a/lib/utils/game_utils.dart +++ b/lib/utils/game_utils.dart @@ -1,7 +1,7 @@ -import 'package:solitaire_game/entities/tile.dart'; -import 'package:solitaire_game/provider/data.dart'; -import 'package:solitaire_game/utils/board_utils.dart'; -import 'package:solitaire_game/utils/tools.dart'; +import 'package:solitaire/entities/tile.dart'; +import 'package:solitaire/provider/data.dart'; +import 'package:solitaire/utils/board_utils.dart'; +import 'package:solitaire/utils/tools.dart'; class GameUtils { static Future<void> quitGame(Data myProvider) async { @@ -51,11 +51,11 @@ class GameUtils { static bool isMoveAllowed(Data myProvider, List<int> source, List<int> target) { // printlog('(test) Pick from ' + source.toString() + ' and drop on ' + target.toString()); - List<List<Tile?>> board = myProvider.board; - int sourceCol = source[0]; - int sourceRow = source[1]; - int targetCol = target[0]; - int targetRow = target[1]; + final Board board = myProvider.board; + final int sourceCol = source[0]; + final int sourceRow = source[1]; + final int targetCol = target[0]; + final int targetRow = target[1]; // ensure source and target are inside range if (sourceRow < 0 || @@ -99,8 +99,8 @@ class GameUtils { } // ensure middle tile exists and has a peg - int middleRow = (sourceRow + ((targetRow - sourceRow) / 2)).round(); - int middleCol = (sourceCol + ((targetCol - sourceCol) / 2)).round(); + final int middleRow = (sourceRow + ((targetRow - sourceRow) / 2)).round(); + final int middleCol = (sourceCol + ((targetCol - sourceCol) / 2)).round(); if (board[middleRow][middleCol] == null || board[middleRow][middleCol]?.hasPeg == false) { // printlog('move forbidden: tile between source and target does not contain a peg'); return false; @@ -112,13 +112,13 @@ class GameUtils { static void move(Data myProvider, List<int> source, List<int> target) { printlog('Move from $source to $target'); - int sourceCol = source[0]; - int sourceRow = source[1]; - int targetCol = target[0]; - int targetRow = target[1]; + final int sourceCol = source[0]; + final int sourceRow = source[1]; + final int targetCol = target[0]; + final int targetRow = target[1]; - int middleRow = (sourceRow + ((targetRow - sourceRow) / 2)).round(); - int middleCol = (sourceCol + ((targetCol - sourceCol) / 2)).round(); + final int middleRow = (sourceRow + ((targetRow - sourceRow) / 2)).round(); + final int middleCol = (sourceCol + ((targetCol - sourceCol) / 2)).round(); // remove peg from source myProvider.updatePegValue(sourceRow, sourceCol, false); @@ -138,7 +138,7 @@ class GameUtils { static List<Tile> listRemainingPegs(Data myProvider) { List<Tile> pegs = []; - List<List<Tile?>> board = myProvider.board; + Board board = myProvider.board; for (int rowIndex = 0; rowIndex < board.length; rowIndex++) { for (int colIndex = 0; colIndex < board[rowIndex].length; colIndex++) { Tile? tile = board[rowIndex][colIndex]; @@ -159,10 +159,10 @@ class GameUtils { int allowedMovesCount = 0; List<Tile> pegs = GameUtils.listRemainingPegs(myProvider); for (Tile tile in pegs) { - int row = tile.currentRow; - int col = tile.currentCol; - List<int> source = [col, row]; - List<List<int>> targets = [ + final int row = tile.currentRow; + final int col = tile.currentCol; + final List<int> source = [col, row]; + final List<List<int>> targets = [ [col - 2, row], [col + 2, row], [col, row - 2], diff --git a/pubspec.lock b/pubspec.lock index 6b1fdfef808496ebd4ff8ce5bb9e51bfbc6469e8..12d429bb18ac218313d455ba71028cdbb3234bb9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" async: dependency: transitive description: @@ -9,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bloc: + dependency: transitive + description: + name: bloc + sha256: f53a110e3b48dcd78136c10daa5d51512443cea5e1348c9d80a320095fa2db9e + url: "https://pub.dev" + source: hosted + version: "8.1.3" characters: dependency: transitive description: @@ -17,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" collection: dependency: transitive description: @@ -25,6 +49,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: c145aeb6584aedc7c862ab8c737c3277788f47488bfdf9bae0fe112bd0a4789c + url: "https://pub.dev" + source: hosted + version: "3.0.5" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" ffi: dependency: transitive description: @@ -46,6 +102,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: "87325da1ac757fcc4813e6b34ed5dd61169973871fdf181d6c2109dd6935ece1" + url: "https://pub.dev" + source: hosted + version: "8.1.4" flutter_lints: dependency: "direct dev" description: @@ -54,11 +118,56 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_web_plugins: dependency: transitive description: flutter source: sdk version: "0.0.0" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + http: + dependency: transitive + description: + name: http + sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba + url: "https://pub.dev" + source: hosted + version: "1.2.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + hydrated_bloc: + dependency: "direct main" + description: + name: hydrated_bloc + sha256: "00a2099680162e74b5a836b8a7f446e478520a9cae9f6032e028ad8129f4432d" + url: "https://pub.dev" + source: hosted + version: "9.1.4" + intl: + dependency: transitive + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" lints: dependency: transitive description: @@ -99,14 +208,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - path: + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79" + url: "https://pub.dev" + source: hosted + version: "5.0.1" + package_info_plus_platform_interface: dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + path: + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + url: "https://pub.dev" + source: hosted + version: "2.3.2" path_provider_linux: dependency: transitive description: @@ -199,10 +348,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.2.2" shared_preferences_windows: dependency: transitive description: @@ -216,6 +365,54 @@ packages: description: flutter source: sdk version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + unicons: + dependency: "direct main" + description: + name: unicons + sha256: dbfcf93ff4d4ea19b324113857e358e4882115ab85db04417a4ba1c72b17a670 + url: "https://pub.dev" + source: hosted + version: "2.1.1" vector_math: dependency: transitive description: @@ -228,18 +425,18 @@ packages: dependency: transitive description: name: web - sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" + sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.4.2" win32: dependency: transitive description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: "8cb58b45c47dcb42ab3651533626161d6b67a2921917d8d429791f76972b3480" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.3.0" xdg_directories: dependency: transitive description: @@ -250,4 +447,4 @@ packages: version: "1.0.4" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.19.0" + flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index f79e714a58972baa888f2bdfe1e18571c7daa1a9..5c4cd59b3a02df9bff6342803c1f8201562af795 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,8 @@ -name: solitaire_game +name: solitaire description: Solitaire Game + publish_to: 'none' -version: 0.0.16+16 +version: 0.0.17+17 environment: sdk: '^3.0.0' @@ -9,9 +10,19 @@ environment: dependencies: flutter: sdk: flutter + + easy_localization: ^3.0.1 + equatable: ^2.0.5 + flutter_bloc: ^8.1.1 + hive: ^2.2.3 + hydrated_bloc: ^9.0.0 + overlay_support: ^2.1.0 provider: ^6.0.5 shared_preferences: ^2.2.1 - overlay_support: ^2.1.0 + package_info_plus: ^5.0.1 + path: ^1.9.0 + path_provider: ^2.0.11 + unicons: ^2.1.1 dev_dependencies: flutter_lints: ^3.0.1 @@ -21,3 +32,17 @@ flutter: assets: - assets/icons/ - assets/skins/ + - assets/translations/ + + fonts: + - family: Nunito + fonts: + - asset: assets/fonts/Nunito-Bold.ttf + weight: 700 + - asset: assets/fonts/Nunito-Medium.ttf + weight: 500 + - asset: assets/fonts/Nunito-Regular.ttf + weight: 400 + - asset: assets/fonts/Nunito-Light.ttf + weight: 300 +