diff --git a/assets/rive/bulb.riv b/assets/rive/bulb.bk.riv similarity index 100% rename from assets/rive/bulb.riv rename to assets/rive/bulb.bk.riv diff --git a/assets/rive/hint.riv b/assets/rive/hint.riv new file mode 100755 index 0000000..5f46e45 Binary files /dev/null and b/assets/rive/hint.riv differ diff --git a/assets/rive/scorecard.bk.riv b/assets/rive/scorecard.bk.riv new file mode 100755 index 0000000..1c1106f Binary files /dev/null and b/assets/rive/scorecard.bk.riv differ diff --git a/assets/rive/scorecard.riv b/assets/rive/scorecard.riv new file mode 100755 index 0000000..18d1192 Binary files /dev/null and b/assets/rive/scorecard.riv differ diff --git a/lib/core/common/widgets/game_button.dart b/lib/core/common/widgets/game_button.dart index 920caf8..3333027 100644 --- a/lib/core/common/widgets/game_button.dart +++ b/lib/core/common/widgets/game_button.dart @@ -2,7 +2,6 @@ // TODO(Test): Rive causes this to fail, so, restore this test after the next // Rive major update when the issue is fixed import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:rive/rive.dart'; import 'package:sozzle/core/res/media.dart'; @@ -20,6 +19,8 @@ class _GameButtonState extends State { File? _riveFile; late RiveWidgetController controller; + late ViewModelInstance viewModelInstance; + ViewModelInstanceString? currentState; bool clicked = false; @@ -32,13 +33,14 @@ class _GameButtonState extends State { @override void dispose() { controller.dispose(); + currentState?.clearListeners(); + viewModelInstance.dispose(); super.dispose(); } Future _preload() async { - final data = await rootBundle.load(Media.gameButton); - _riveFile = await File.decode( - data.buffer.asUint8List(), + _riveFile = await File.asset( + Media.gameButton, riveFactory: Factory.rive, ); controller = RiveWidgetController(_riveFile!); @@ -47,11 +49,11 @@ class _GameButtonState extends State { } void _onInit(Artboard artboard) { - artboard.setText('buttonText', widget.text); + viewModelInstance = controller.dataBind(DataBind.auto()); - final viewModelInstance = controller.dataBind(DataBind.auto()); + viewModelInstance.string('buttonText')?.value = widget.text; - final currentState = viewModelInstance.string('currentState'); + currentState = viewModelInstance.string('currentState'); currentState?.addListener((value) { if (value == 'click') { clicked = true; diff --git a/lib/core/common/widgets/scorecard.dart b/lib/core/common/widgets/scorecard.dart new file mode 100644 index 0000000..3aff5c4 --- /dev/null +++ b/lib/core/common/widgets/scorecard.dart @@ -0,0 +1,142 @@ +// coverage:ignore-file +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rive/rive.dart'; +import 'package:sozzle/core/enums/rive_enums.dart'; +import 'package:sozzle/core/res/media.dart'; +import 'package:sozzle/src/theme/cubit/theme_cubit.dart'; + +class Scorecard extends StatefulWidget { + const Scorecard({ + required this.level, + required this.failedAttempts, + super.key, + }); + + final int level; + final String failedAttempts; + + @override + State createState() => _ScorecardState(); +} + +class _ScorecardState extends State { + File? _riveFile; + + late RiveWidgetController controller; + late ViewModelInstance viewModelInstance; + + ViewModelInstanceNumber? _levelValue; + ViewModelInstanceString? _failedAttemptsValue; + ViewModelInstanceBoolean? _isDarkMode; + ViewModelInstanceEnum? _levelAlignment; + ViewModelInstanceEnum? _failedAttemptsAlignment; + ViewModelInstanceEnum? _boardLayout; + ViewModelInstanceEnum? _cardLayoutAlignment; + ViewModelInstanceEnum? _scoreSectionHeightScale; + + bool clicked = false; + + @override + void initState() { + super.initState(); + _preload(); + } + + @override + void dispose() { + controller.dispose(); + viewModelInstance.dispose(); + _isDarkMode?.dispose(); + _levelValue?.dispose(); + _failedAttemptsValue?.dispose(); + _levelAlignment?.dispose(); + _failedAttemptsAlignment?.dispose(); + _boardLayout?.dispose(); + _cardLayoutAlignment?.dispose(); + _scoreSectionHeightScale?.dispose(); + super.dispose(); + } + + Future _preload() async { + _riveFile = await File.asset( + Media.scorecard, + riveFactory: Factory.rive, + ); + + controller = RiveWidgetController( + _riveFile!, + artboardSelector: ArtboardSelector.byName('Scorecard'), + ); + _onInit(); + setState(() {}); + } + + void _onInit() { + viewModelInstance = controller.dataBind(DataBind.auto()); + + _levelValue = viewModelInstance.number('levelValue'); + _levelValue?.value = widget.level.toDouble(); + + _failedAttemptsValue = viewModelInstance.string('failedAttemptsValue'); + _failedAttemptsValue?.value = widget.failedAttempts; + + _isDarkMode = viewModelInstance.boolean('isDarkMode'); + _isDarkMode?.value = context.read().state is ThemeStateDark; + + _boardLayout = viewModelInstance.enumerator('scoreSectionLayout'); + + _levelAlignment = viewModelInstance.enumerator('levelAlignment'); + + _failedAttemptsAlignment = viewModelInstance.enumerator( + 'failedAttemptsAlignment', + ); + + _cardLayoutAlignment = viewModelInstance.enumerator( + 'cardLayoutAlignment', + ); + + _scoreSectionHeightScale = viewModelInstance.enumerator( + 'scoreSectionHeightScale', + ); + } + + void renderLayout({required bool isNarrow}) { + if (isNarrow) { + _boardLayout?.value = LayoutDirection.column.value; + _failedAttemptsAlignment?.value = AlignmentType.topLeft.value; + _levelAlignment?.value = AlignmentType.bottomLeft.value; + _cardLayoutAlignment?.value = AlignmentType.bottomCenter.value; + _scoreSectionHeightScale?.value = LayoutScale.fill.value; + } else { + _boardLayout?.value = LayoutDirection.row.value; + _levelAlignment?.value = AlignmentType.center.value; + _failedAttemptsAlignment?.value = AlignmentType.center.value; + _cardLayoutAlignment?.value = AlignmentType.centerLeft.value; + _scoreSectionHeightScale?.value = LayoutScale.hug.value; + } + } + + @override + Widget build(BuildContext context) { + if (_riveFile == null) return const SizedBox.shrink(); + return LayoutBuilder( + builder: (_, constraints) { + renderLayout(isNarrow: constraints.maxWidth < 330); + return BlocListener( + listener: (context, state) { + _isDarkMode?.value = state is ThemeStateDark; + }, + child: SizedBox( + height: 100, + child: RiveWidget( + controller: controller, + alignment: Alignment.centerLeft, + fit: Fit.layout, + ), + ), + ); + }, + ); + } +} diff --git a/lib/core/enums/rive_enums.dart b/lib/core/enums/rive_enums.dart new file mode 100644 index 0000000..e774628 --- /dev/null +++ b/lib/core/enums/rive_enums.dart @@ -0,0 +1,43 @@ +// coverage:ignore-file + +/// Represents the primary direction of layout. +enum LayoutDirection { + column('Column'), + columnReverse('Column Reverse'), + row('Row'), + rowReverse('Row Reverse'); + + const LayoutDirection(this.value); + + final String value; +} + +/// Represents various types of alignment for layout. +enum AlignmentType { + topLeft('Top Left'), + topCenter('Top Center'), + topRight('Top Right'), + centerLeft('Center Left'), + center('Center'), + centerRight('Center Right'), + bottomLeft('Bottom Left'), + bottomCenter('Bottom Center'), + bottomRight('Bottom Right'), + spaceBetweenStart('Space Between Start'), + spaceBetweenCenter('Space Between Center'), + spaceBetweenEnd('Space Between End'); + + const AlignmentType(this.value); + + final String value; +} + +/// Represents different scaling options for layout. +enum LayoutScale { + fixed('Fixed'), + fill('Fill'), + hug('Hug'); + + const LayoutScale(this.value); + final String value; +} diff --git a/lib/core/extensions/string_extensions.dart b/lib/core/extensions/string_extensions.dart new file mode 100644 index 0000000..752a1b6 --- /dev/null +++ b/lib/core/extensions/string_extensions.dart @@ -0,0 +1,41 @@ +// coverage:ignore-file + +import 'package:sozzle/core/enums/rive_enums.dart'; + +extension StringExtensions on String { + /// Converts this string to an [AlignmentType] enum value. + /// + /// Returns `null` if no matching [AlignmentType] is found. + AlignmentType? toAlignmentType() { + for (final alignment in AlignmentType.values) { + if (alignment.value == this) { + return alignment; + } + } + return null; + } + + /// Converts this string to a [LayoutDirection] enum value. + /// + /// Returns `null` if no matching [LayoutDirection] is found. + LayoutDirection? toLayoutDirection() { + for (final direction in LayoutDirection.values) { + if (direction.value == this) { + return direction; + } + } + return null; + } + + /// Converts this string to a [LayoutScale] enum value. + /// + /// Returns `null` if no matching [LayoutScale] is found. + LayoutScale? toLayoutScale() { + for (final scale in LayoutScale.values) { + if (scale.value == this) { + return scale; + } + } + return null; + } +} diff --git a/lib/core/res/media.dart b/lib/core/res/media.dart index 68892ed..396a56d 100644 --- a/lib/core/res/media.dart +++ b/lib/core/res/media.dart @@ -11,6 +11,7 @@ sealed class Media { // Rive static const lake = '$_baseRive/small_lake_on_a_rainy_day.riv'; static const space = '$_baseRive/space.riv'; - static const animatedHint = '$_baseRive/bulb.riv'; + static const animatedHint = '$_baseRive/hint.riv'; static const gameButton = '$_baseRive/game_button.riv'; + static const scorecard = '$_baseRive/scorecard.riv'; } diff --git a/lib/src/game_play/view/components/game_play_board.dart b/lib/src/game_play/view/components/game_play_board.dart index 58a8df6..7016135 100644 --- a/lib/src/game_play/view/components/game_play_board.dart +++ b/lib/src/game_play/view/components/game_play_board.dart @@ -6,18 +6,27 @@ import 'package:sozzle/src/game_play/bloc/game_play_bloc.dart'; import 'package:sozzle/src/theme/cubit/theme_cubit.dart'; import 'package:sozzle/src/user_stats/user_stats.dart'; -class GamePlayBoard extends StatelessWidget { +class GamePlayBoard extends StatefulWidget { const GamePlayBoard(this.levelData, {super.key}); final LevelData levelData; @override - Widget build(BuildContext context) { - final theme = BlocProvider.of(context).state; + State createState() => _GamePlayBoardState(); +} - final gridSize = GridSize(levelData.boardWidth, levelData.boardHeight); +class _GamePlayBoardState extends State { + late final GridBoardController controller; - final cells = levelData.boardData.map( + @override + void initState() { + super.initState(); + final gridSize = GridSize( + widget.levelData.boardWidth, + widget.levelData.boardHeight, + ); + + final cells = widget.levelData.boardData.map( (e) { return GridCell( controller: GridCellController(), @@ -34,12 +43,23 @@ class GamePlayBoard extends StatelessWidget { }, ).toList(); - final controller = GridBoardController( + controller = GridBoardController( gridBoardProperties: GridBoardProperties( gridSize: gridSize, ), cells: cells, ); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = BlocProvider.of(context).state; return BlocListener( listener: (context, gameState) { if (gameState is LetterRevealed) { @@ -56,8 +76,7 @@ class GamePlayBoard extends StatelessWidget { } } }, - child: SizedBox( - width: MediaQuery.of(context).size.width - 50, + child: Center( child: GridBoard( backgroundColor: theme.backgroundColor, controller: controller, diff --git a/lib/src/game_play/view/components/game_play_header.dart b/lib/src/game_play/view/components/game_play_header.dart index c4fcaa3..12627ae 100644 --- a/lib/src/game_play/view/components/game_play_header.dart +++ b/lib/src/game_play/view/components/game_play_header.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:sozzle/core/common/widgets/scorecard.dart'; import 'package:sozzle/src/game_play/view/components/hint.dart'; import 'package:sozzle/src/home/home.dart'; import 'package:sozzle/src/theme/cubit/theme_cubit.dart'; @@ -12,27 +13,91 @@ class GamePlayHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final theme = context.watch().state; - return AppBar( - elevation: 0, - backgroundColor: theme.backgroundColor, - leading: IconButton( - icon: const Icon(Icons.home), - color: theme.primaryTextColor, - onPressed: () { - context.go(HomePage.path); - }, - ), - centerTitle: true, - title: Text( - 'Level ${state.progress.currentLevel}', - style: TextStyle(color: theme.primaryTextColor), - ), - actions: const [Hint(), SizedBox(width: 16)], + return LayoutBuilder( + builder: (_, constraint) { + final renderColumn = constraint.maxWidth < 731; + return BlocBuilder( + builder: (context, state) { + final theme = context.watch().state; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16).copyWith( + top: renderColumn ? 16 : 32, + ), + child: switch (renderColumn) { + true => MobileHeader(state: state, theme: theme), + _ => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: DesktopHeader(state: state, theme: theme), + ), + }, + ); + }, ); }, ); } } + +class DesktopHeader extends StatelessWidget { + const DesktopHeader({required this.state, required this.theme, super.key}); + + final UserStatsState state; + final ThemeState theme; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Scorecard( + level: state.progress.currentLevel, + failedAttempts: '0', + ), + ), + IconButton( + icon: const Icon(Icons.home), + iconSize: 30, + color: theme.primaryTextColor, + onPressed: () { + context.go(HomePage.path); + }, + ), + const Hint(), + ], + ); + } +} + +class MobileHeader extends StatelessWidget { + const MobileHeader({required this.state, required this.theme, super.key}); + + final UserStatsState state; + final ThemeState theme; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Scorecard( + level: state.progress.currentLevel, + failedAttempts: '0', + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.home), + iconSize: 30, + color: theme.primaryTextColor, + onPressed: () { + context.go(HomePage.path); + }, + ), + const Hint(), + ], + ), + ], + ); + } +} diff --git a/lib/src/game_play/view/components/hint.dart b/lib/src/game_play/view/components/hint.dart index 4970864..a6af88e 100644 --- a/lib/src/game_play/view/components/hint.dart +++ b/lib/src/game_play/view/components/hint.dart @@ -3,7 +3,6 @@ // Rive major update when the issue is fixed import 'package:awesome_dialog/awesome_dialog.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:rive/rive.dart'; import 'package:sozzle/core/common/widgets/game_button.dart'; @@ -23,10 +22,11 @@ class Hint extends StatefulWidget { class _HintState extends State { File? _hintFile; late RiveWidgetController _controller; - late StateMachine? _stateController; - late StateMachine? _themeController; - BooleanInput? _luminance; - BooleanInput? _theme; + ViewModelInstance? _viewModelInstance; + ViewModelInstanceBoolean? _luminance; + ViewModelInstanceBoolean? _theme; + ViewModelInstanceNumber? _hintCount; + ViewModelInstanceColor? _textColour; @override void initState() { @@ -35,23 +35,32 @@ class _HintState extends State { } Future preload() async { - final data = await rootBundle.load(Media.animatedHint); - _hintFile = await File.decode( - data.buffer.asUint8List(), + _hintFile = await File.asset( + Media.animatedHint, riveFactory: Factory.rive, ); _controller = RiveWidgetController(_hintFile!); - _onInit(_controller.artboard); + _onInit(); setState(() {}); } - void _onInit(Artboard artboard) { - _stateController = artboard.stateMachine('bulb'); - _themeController = artboard.stateMachine('theme'); - _luminance = _stateController?.boolean('pressed'); - _theme = _themeController?.boolean('isDark'); - flipBulbByBooster(context.read().state); - _theme?.value = context.read().state is ThemeStateDark; + void _onInit() { + _viewModelInstance = _controller.dataBind(DataBind.auto()); + + _luminance = _viewModelInstance?.boolean('isLightOn'); + _theme = _viewModelInstance?.boolean('isDarkTheme'); + _hintCount = _viewModelInstance?.number('hintCount'); + _textColour = _viewModelInstance?.color('textColour'); + final statsState = context.read().state; + flipBulbByBooster(statsState); + final themeState = context.read().state; + _theme?.value = themeState is ThemeStateDark; + _textColour?.value = themeState.primaryTextColor; + _hintCount?.value = statsState.progress.boosters + .whereType() + .first + .boosterCount + .toDouble(); } void flipBulbByBooster(UserStatsState statsState) { @@ -65,11 +74,18 @@ class _HintState extends State { } } + bool hasHint(UserStatsState statsState) { + return statsState.progress.boosters.any( + (booster) => booster is UseAHint && booster.boosterCount > 0, + ); + } + @override void dispose() { _controller.dispose(); - _stateController?.dispose(); - _themeController?.dispose(); + _viewModelInstance?.dispose(); + _textColour?.dispose(); + _hintCount?.dispose(); _luminance?.dispose(); _theme?.dispose(); super.dispose(); @@ -83,68 +99,47 @@ class _HintState extends State { child: BlocConsumer( listener: (context, themeState) { _theme?.value = themeState is ThemeStateDark; + _textColour?.value = themeState.primaryTextColor; }, builder: (context, themeState) { return BlocConsumer( listener: (context, statsState) { flipBulbByBooster(statsState); + _hintCount?.value = statsState.progress.boosters + .whereType() + .first + .boosterCount + .toDouble(); }, builder: (context, statsState) { - final userHasHint = statsState.progress.boosters.any( - (booster) => booster is UseAHint && booster.boosterCount > 0, - ); - var hintCount = 0; - if (userHasHint) { - hintCount = statsState.progress.boosters - .whereType() - .first - .boosterCount; - } - return GestureDetector( - onTap: () { - if (userHasHint) { - AwesomeDialog( - context: context, - dialogType: DialogType.question, - animType: AnimType.rightSlide, - title: 'Reveal a letter?', - desc: 'Are you sure you want to use a hint?', - btnOk: GameButton( - text: 'Reveal', - onPressed: () { - Navigator.of(context).pop(); - context.read().add( - const RevealRandomLetterEvent(), - ); - }, - ), - ).show(); - // context.read().useAHint(); - } - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Baseline( - baselineType: TextBaseline.alphabetic, - baseline: 45, - child: SizedBox( - width: 50, - child: RiveWidget( - key: UniqueKey(), - controller: _controller, - fit: Fit.cover, + final userHasHint = hasHint(statsState); + + return SizedBox( + height: 100, + width: 100, + child: GestureDetector( + onTap: () { + if (userHasHint) { + AwesomeDialog( + context: context, + dialogType: DialogType.question, + animType: AnimType.rightSlide, + title: 'Reveal a letter?', + desc: 'Are you sure you want to use a hint?', + btnOk: GameButton( + text: 'Reveal', + onPressed: () { + Navigator.of(context).pop(); + context.read().add( + const RevealRandomLetterEvent(), + ); + }, ), - ), - ), - Text( - hintCount.toString(), - style: TextStyle( - color: themeState.primaryTextColor, - fontSize: 25, - ), - ), - ], + ).show(); + // context.read().useAHint(); + } + }, + child: RiveWidget(key: UniqueKey(), controller: _controller), ), ); }, diff --git a/lib/src/game_play/view/game_play_page.dart b/lib/src/game_play/view/game_play_page.dart index 0961838..6dc06ea 100644 --- a/lib/src/game_play/view/game_play_page.dart +++ b/lib/src/game_play/view/game_play_page.dart @@ -19,49 +19,61 @@ class GamePlayPage extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = BlocProvider.of(context).state; - return Scaffold( - backgroundColor: theme.backgroundColor, - body: Center( - child: GameLoader( - future: RepositoryProvider.of(context) - .getLevel(levelID), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const CircularProgressIndicator(); - } - if (!snapshot.hasData) { - return const Text('Ops an error!'); - } else { - final bloc = GamePlayBloc( - levelData: snapshot.data!, - audio: RepositoryProvider.of(context), - ); - debugPrint(bloc.levelData.words.toString()); - return BlocProvider( - create: (context) => bloc..add(const GamePlayInitialEvent()), - child: BlocListener( - listener: (context, state) { - if (state.actualState == GamePlayActualState.allFound) { - context.read().advanceLevelUp(); - final levelData = bloc.levelData; - context.go(LevelCompletePage.path, extra: levelData); - } - }, - child: Flex( - direction: Axis.vertical, - children: [ - const GamePlayHeader(), - Flexible(flex: 4, child: GamePlayBoard(snapshot.data!)), - Flexible(flex: 3, child: GamePlayLetters(snapshot.data!)), - ], - ), - ), - ); - } - }, - ), - ), + return BlocBuilder( + builder: (context, themeState) { + return Scaffold( + backgroundColor: themeState.backgroundColor, + body: Center( + child: GameLoader( + future: RepositoryProvider.of(context) + .getLevel(levelID), + builder: + (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const CircularProgressIndicator(); + } + if (!snapshot.hasData) { + return const Text('Ops an error!'); + } else { + final bloc = GamePlayBloc( + levelData: snapshot.data!, + audio: RepositoryProvider.of(context), + ); + debugPrint(bloc.levelData.words.toString()); + return BlocProvider( + create: (context) => + bloc..add(const GamePlayInitialEvent()), + child: BlocListener( + listener: (context, state) { + if (state.actualState == GamePlayActualState.allFound) { + context.read().advanceLevelUp(); + final levelData = bloc.levelData; + context.go(LevelCompletePage.path, extra: levelData); + } + }, + child: Flex( + direction: Axis.vertical, + children: [ + const GamePlayHeader(), + const SizedBox(height: 10), + Flexible( + flex: 4, + child: GamePlayBoard(snapshot.data!), + ), + Flexible( + flex: 3, + child: GamePlayLetters(snapshot.data!), + ), + ], + ), + ), + ); + } + }, + ), + ), + ); + }, ); } } diff --git a/pubspec.lock b/pubspec.lock index 3af5304..b9735cb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -338,7 +338,7 @@ packages: description: path: "." ref: main - resolved-ref: "79cc1694cfda95fdbc8e9cd6eb5aef0fd3b33743" + resolved-ref: b3318d5e4e7904a2bad7c467562146b801f817c7 url: "https://github.com/agtrlabs/grid_board.git" source: git version: "0.0.1"