From df935773bfdc9c9e3caf5389722ed478caeff474 Mon Sep 17 00:00:00 2001 From: Sandip Date: Thu, 11 Jul 2024 23:31:22 +0530 Subject: [PATCH] feat(ui): revamp sudoku home page ui --- lib/app/view/app.dart | 13 +- lib/assets/assets.dart | 20 + lib/colors/colors.dart | 16 + lib/home/home.dart | 1 + lib/home/view/home_page.dart | 735 +++++++++++++++++++++++ lib/l10n/arb/app_en.arb | 64 ++ lib/typography/text_styles.dart | 6 +- lib/widgets/sudoku_background.dart | 144 +++++ lib/widgets/sudoku_elevated_button.dart | 66 ++ lib/widgets/sudoku_icon.dart | 42 ++ lib/widgets/sudoku_text_button.dart | 59 ++ lib/widgets/widgets.dart | 4 + test/app/view/app_test.dart | 6 +- test/home/home_page_test.dart | 219 +++++++ test/widgets/sudoku_background_test.dart | 55 ++ 15 files changed, 1442 insertions(+), 8 deletions(-) create mode 100644 lib/assets/assets.dart create mode 100644 lib/colors/colors.dart create mode 100644 lib/home/home.dart create mode 100644 lib/home/view/home_page.dart create mode 100644 lib/widgets/sudoku_background.dart create mode 100644 lib/widgets/sudoku_elevated_button.dart create mode 100644 lib/widgets/sudoku_icon.dart create mode 100644 lib/widgets/sudoku_text_button.dart create mode 100644 lib/widgets/widgets.dart create mode 100644 test/home/home_page_test.dart create mode 100644 test/widgets/sudoku_background_test.dart diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index d0ef48a..dd6d422 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -1,6 +1,7 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:sudoku/home/home.dart'; import 'package:sudoku/l10n/l10n.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; import 'package:sudoku/theme/theme.dart'; class App extends StatelessWidget { @@ -11,9 +12,17 @@ class App extends StatelessWidget { return MaterialApp( theme: SudokuTheme.light, darkTheme: SudokuTheme.dark, + themeMode: ThemeMode.light, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const SudokuPage(), + scrollBehavior: const MaterialScrollBehavior().copyWith( + dragDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + }, + ), + home: const HomePage(), ); } } diff --git a/lib/assets/assets.dart b/lib/assets/assets.dart new file mode 100644 index 0000000..cc67781 --- /dev/null +++ b/lib/assets/assets.dart @@ -0,0 +1,20 @@ +/// Defines the assets for the Sudoku App UI. +abstract class Assets { + /// Daily Challenge icon. + static const dailyChallengeIcon = 'assets/icons/challenge.png'; + + /// Unfinished Puzzle icon. + static const unfinishedPuzzleIcon = 'assets/icons/unfinished.png'; + + /// Easy Puzzle icon. + static const easyPuzzleIcon = 'assets/icons/easy.png'; + + /// Medium Puzzle icon. + static const mediumPuzzleIcon = 'assets/icons/medium.png'; + + /// Difficult Puzzle icon. + static const difficultPuzzleIcon = 'assets/icons/difficult.png'; + + /// Expert Puzzle icon. + static const expertPuzzleIcon = 'assets/icons/expert.png'; +} diff --git a/lib/colors/colors.dart b/lib/colors/colors.dart new file mode 100644 index 0000000..81c6569 --- /dev/null +++ b/lib/colors/colors.dart @@ -0,0 +1,16 @@ +import 'dart:ui'; + +/// Defines the colors used in the Sudoku App UI. +abstract class SudokuColors { + /// dark Pink + static const lightPink = Color(0xFFFF80B5); + + /// Light Purple + static const lightPurple = Color(0xFF9089FC); + + /// Dark Pink + static const darkPink = Color(0xFFC7649F); + + /// Dark Purple + static const darkPurple = Color(0xFF6C63C7); +} diff --git a/lib/home/home.dart b/lib/home/home.dart new file mode 100644 index 0000000..acd3393 --- /dev/null +++ b/lib/home/home.dart @@ -0,0 +1 @@ +export 'view/home_page.dart'; diff --git a/lib/home/view/home_page.dart b/lib/home/view/home_page.dart new file mode 100644 index 0000000..ae3070f --- /dev/null +++ b/lib/home/view/home_page.dart @@ -0,0 +1,735 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:sudoku/assets/assets.dart'; +import 'package:sudoku/l10n/l10n.dart'; +import 'package:sudoku/layout/layout.dart'; +import 'package:sudoku/typography/typography.dart'; +import 'package:sudoku/widgets/widgets.dart'; + +/// {@template home_page} +/// Displays the Home Page UI. +/// {@endtemplate} +class HomePage extends StatelessWidget { + /// {@macro home_page} + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return const HomeView(); + } +} + +class HomeView extends StatelessWidget { + const HomeView({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + body: AnnotatedRegion( + value: theme.brightness == Brightness.light + ? SystemUiOverlayStyle.dark + : SystemUiOverlayStyle.light, + child: const SudokuBackground( + child: HomeViewLayout(), + ), + ), + ); + } +} + +/// {@template home_view_layout} +/// Builds the layout depending upon the device screen size. +/// {@endtemplate} +@visibleForTesting +class HomeViewLayout extends StatelessWidget { + /// {@macro home_view_layout} + const HomeViewLayout({super.key}); + + @override + Widget build(BuildContext context) { + return ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => child!, + large: (_, child) => Align( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height, + maxWidth: SudokuBreakpoint.large, + ), + child: Align(child: child), + ), + ), + child: (_) { + return const SingleChildScrollView( + child: Column( + children: [ + ResponsiveGap( + small: 72, + medium: 24, + large: 32, + ), + HeaderSection(), + ResponsiveGap( + small: 36, + medium: 48, + large: 96, + ), + ResponsiveHomePageLayout( + highlightedSection: HighlightedSection(), + createGameSection: CreateGameSection(), + ), + ResponsiveGap( + small: 36, + medium: 48, + large: 96, + ), + ], + ), + ); + }, + ); + } +} + +/// {@template header_section} +/// Header Section of the [HomePage]. +/// +/// Displays the app title, and subtitle on medium and large screens. +/// {@endtemplate} +@visibleForTesting +class HeaderSection extends StatelessWidget { + /// {@macro header_section} + const HeaderSection({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => child!, + large: (_, child) => child!, + child: (layoutSize) { + final titleFontStyle = switch (layoutSize) { + ResponsiveLayoutSize.small => SudokuTextStyle.headline6, + ResponsiveLayoutSize.medium => SudokuTextStyle.headline5, + ResponsiveLayoutSize.large => SudokuTextStyle.headline1, + }; + + final subtitleFontStyle = switch (layoutSize) { + ResponsiveLayoutSize.small => SudokuTextStyle.subtitle2, + ResponsiveLayoutSize.medium => SudokuTextStyle.subtitle2, + ResponsiveLayoutSize.large => SudokuTextStyle.subtitle1, + }; + + return Align( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 620, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + children: [ + Text( + l10n.homeAppBarTitle, + style: titleFontStyle, + ), + const ResponsiveGap( + small: 12, + medium: 12, + large: 12, + ), + Text( + l10n.sudokuGameSubtitle, + style: subtitleFontStyle.copyWith( + color: theme.brightness == Brightness.light + ? Colors.black87 + : Colors.white70, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +/// {@template responsive_home_page_layout} +/// Layout of the [HomePage] body. +/// {@endtemplate} +@visibleForTesting +class ResponsiveHomePageLayout extends StatelessWidget { + /// {@macro responsive_home_page_layout} + const ResponsiveHomePageLayout({ + required this.highlightedSection, + required this.createGameSection, + super.key, + }); + + /// Widget to be shown for the daily challenge, and resume + /// unfinished puzzle. + final Widget highlightedSection; + + /// Widget to be shown for creating new game of different modes. + final Widget createGameSection; + + @override + Widget build(BuildContext context) { + return ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => child!, + large: (_, __) => Row( + children: [ + Expanded(child: highlightedSection), + Expanded(child: createGameSection), + ], + ), + child: (_) { + return Column( + children: [ + highlightedSection, + const ResponsiveGap( + small: 24, + medium: 48, + ), + createGameSection, + ], + ); + }, + ); + } +} + +/// {@template highlighted_secton} +/// Section creating the highlighted items. +/// +/// This includes 2 sections - Daily Challenge, and Resume +/// Unfinished Puzzle. +/// {@endtemplate} +@visibleForTesting +class HighlightedSection extends StatelessWidget { + /// {@macro highlighted_secton} + const HighlightedSection({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + final dailyChallengeWidget = HighlightedSectionItem( + key: const Key('daily_challenge_widget'), + elevatedButtonkey: const Key('daily_challenge_widget_elevated_button'), + iconAsset: Assets.dailyChallengeIcon, + title: l10n.dailyChallengeTitle, + subtitle: l10n.dailyChallengeSubtitle, + buttonText: 'Play', + onButtonPressed: () => log('daily_challenge'), + ); + + final resumePuzzleWidget = HighlightedSectionItem( + key: const Key('resume_puzzle_widget'), + elevatedButtonkey: const Key('resume_puzzle_widget_elevated_button'), + iconAsset: Assets.unfinishedPuzzleIcon, + title: l10n.resumeSudokuTitle, + subtitle: l10n.resumeSudokuSubtitle, + buttonText: 'Resume', + onButtonPressed: null, + ); + + return ResponsiveLayoutBuilder( + small: (_, __) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + children: [ + Expanded(child: dailyChallengeWidget), + const SizedBox(width: 16), + Expanded(child: resumePuzzleWidget), + ], + ), + ), + medium: (_, __) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 780, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + dailyChallengeWidget, + const SizedBox(height: 24), + resumePuzzleWidget, + ], + ), + ), + ), + large: (_, __) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Row( + children: [ + Expanded(child: dailyChallengeWidget), + const SizedBox(width: 32), + Expanded(child: resumePuzzleWidget), + ], + ), + ), + ); + } +} + +/// {@template highlighted_Section_item} +/// Each highlighted item in the [HighlightedSection]. +/// {@endtemplate} +@visibleForTesting +class HighlightedSectionItem extends StatelessWidget { + const HighlightedSectionItem({ + required this.iconAsset, + required this.title, + required this.subtitle, + required this.buttonText, + required this.onButtonPressed, + this.elevatedButtonkey, + super.key, + }); + + /// Icon from the assets + final String iconAsset; + + /// Title for the highlighted item. + final String title; + + /// Subtitle or caption for the highlighted item. + final String subtitle; + + /// text to be shown in the button. + final String buttonText; + + /// Function to be run when the button is pressed. + final VoidCallback? onButtonPressed; + + /// Key for the [SudokuElevatedButton]. + final Key? elevatedButtonkey; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: theme.dividerColor, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + flex: 2, + child: SudokuIcon( + iconAsset: iconAsset, + scaleFactor: 1.8, + ), + ), + const SizedBox(width: 32), + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + title, + style: SudokuTextStyle.bodyText1, + maxLines: 2, + ), + const SizedBox(height: 8), + Text( + subtitle, + style: SudokuTextStyle.subtitle2.copyWith( + color: theme.brightness == Brightness.light + ? Colors.black87 + : Colors.white70, + ), + maxLines: 2, + ), + const SizedBox(height: 16), + SudokuElevatedButton( + key: elevatedButtonkey, + buttonText: buttonText, + onPressed: onButtonPressed, + ), + ], + ), + ), + ], + ), + ), + ), + large: (_, child) => child!, + child: (layoutSize) { + final cardHeight = switch (layoutSize) { + ResponsiveLayoutSize.small => 252.0, + ResponsiveLayoutSize.medium => 216.0, + ResponsiveLayoutSize.large => 472.0, + }; + + final padding = switch (layoutSize) { + ResponsiveLayoutSize.small => 16.0, + ResponsiveLayoutSize.medium => 16.0, + ResponsiveLayoutSize.large => 24.0, + }; + + final iconScaleFactor = switch (layoutSize) { + ResponsiveLayoutSize.small => 1.0, + _ => 1.96, + }; + + final titleTextStyle = switch (layoutSize) { + ResponsiveLayoutSize.small => SudokuTextStyle.bodyText1, + _ => SudokuTextStyle.headline2, + }; + + return SizedBox( + height: cardHeight, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: theme.dividerColor, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: EdgeInsets.all(padding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SudokuIcon( + iconAsset: iconAsset, + scaleFactor: iconScaleFactor, + ), + const SizedBox(height: 16), + Text( + title, + style: titleTextStyle.copyWith( + fontWeight: SudokuFontWeight.medium, + ), + maxLines: 2, + ), + const SizedBox(height: 8), + Text( + subtitle, + style: SudokuTextStyle.subtitle2.copyWith( + color: theme.brightness == Brightness.light + ? Colors.black87 + : Colors.white70, + ), + maxLines: 2, + ), + const SizedBox(height: 16), + SudokuElevatedButton( + key: elevatedButtonkey, + buttonText: buttonText, + onPressed: onButtonPressed, + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +/// {@template create_game_section} +/// Section creating the new game cards based on modes. +/// {@endtemplate} +@visibleForTesting +class CreateGameSection extends StatelessWidget { + /// {@macro create_game_section} + const CreateGameSection({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + final createGameWidgets = [ + CreateGameSectionItem( + key: const Key('create_game_easy_mode'), + textButtonkey: const Key('create_game_easy_mode_text_button'), + iconAsset: Assets.easyPuzzleIcon, + title: l10n.createEasyGameTitle, + caption: l10n.createEasyGameCaption, + onButtonPressed: () => log('easy_mode'), + ), + CreateGameSectionItem( + key: const Key('create_game_medium_mode'), + textButtonkey: const Key('create_game_medium_mode_text_button'), + iconAsset: Assets.mediumPuzzleIcon, + title: l10n.createMediumGameTitle, + caption: l10n.createMediumGameCaption, + onButtonPressed: () => log('medium_mode'), + ), + CreateGameSectionItem( + key: const Key('create_game_difficult_mode'), + textButtonkey: const Key('create_game_difficult_mode_text_button'), + iconAsset: Assets.difficultPuzzleIcon, + title: l10n.createDifficultGameTitle, + caption: l10n.createDifficultGameCaption, + onButtonPressed: () => log('difficult_mode'), + ), + CreateGameSectionItem( + key: const Key('create_game_expert_mode'), + textButtonkey: const Key('create_game_expert_mode_text_button'), + iconAsset: Assets.expertPuzzleIcon, + title: l10n.createExpertGameTitle, + caption: l10n.createExpertGameCaption, + onButtonPressed: () => log('expert_mode'), + ), + ]; + + return ResponsiveLayoutBuilder( + small: (_, __) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.createNewGameHeader, + style: SudokuTextStyle.bodyText1.copyWith( + fontWeight: SudokuFontWeight.semiBold, + ), + ), + const SizedBox(height: 16), + for (final widget in createGameWidgets) ...[ + widget, + const SizedBox(height: 16), + ], + ], + ), + ), + medium: (_, child) => child!, + large: (_, child) => child!, + child: (layoutSize) { + final padding = switch (layoutSize) { + ResponsiveLayoutSize.small => 16.0, + ResponsiveLayoutSize.medium => 16.0, + ResponsiveLayoutSize.large => 24.0, + }; + + final maxWidth = switch (layoutSize) { + ResponsiveLayoutSize.medium => 780.0, + _ => double.maxFinite, + }; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: padding), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (layoutSize == ResponsiveLayoutSize.medium) ...[ + Text( + l10n.createNewGameHeader, + style: SudokuTextStyle.headline6, + textAlign: TextAlign.left, + ), + SizedBox(height: padding + 6), + ], + Row( + children: [ + Expanded(child: createGameWidgets[0]), + SizedBox(width: padding), + Expanded(child: createGameWidgets[1]), + ], + ), + SizedBox(height: padding), + Row( + children: [ + Expanded(child: createGameWidgets[2]), + SizedBox(width: padding), + Expanded(child: createGameWidgets[3]), + ], + ), + ], + ), + ), + ); + }, + ); + } +} + +/// {@template create_game_section_item} +/// Each new game item in the [CreateGameSection]. +/// {@endtemplate} +@visibleForTesting +class CreateGameSectionItem extends StatelessWidget { + /// {@macro create_game_section_item} + const CreateGameSectionItem({ + required this.iconAsset, + required this.title, + required this.caption, + required this.onButtonPressed, + this.textButtonkey, + super.key, + }); + + /// Icon from the assets. + final String iconAsset; + + /// Title for the create game card. + final String title; + + /// Caption for the create game card. + final String caption; + + /// Function to be run when the button is pressed. + final VoidCallback? onButtonPressed; + + /// Key for the [SudokuTextButton]. + final Key? textButtonkey; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + return ResponsiveLayoutBuilder( + small: (_, __) => DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: theme.dividerColor, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: SudokuIcon( + iconAsset: iconAsset, + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + title, + style: SudokuTextStyle.bodyText2.copyWith( + fontWeight: SudokuFontWeight.semiBold, + ), + maxLines: 1, + ), + const SizedBox(height: 4), + Text( + caption, + style: SudokuTextStyle.subtitle2.copyWith( + color: theme.brightness == Brightness.light + ? Colors.black87 + : Colors.white70, + fontSize: 13, + ), + maxLines: 3, + ), + const SizedBox(height: 8), + SudokuTextButton( + key: textButtonkey, + buttonText: l10n.buildSudokuBoardButtonText, + onPressed: onButtonPressed, + ), + ], + ), + ), + ], + ), + ), + ), + medium: (_, child) => child!, + large: (_, child) => child!, + child: (layoutSize) { + final cardHeight = switch (layoutSize) { + ResponsiveLayoutSize.small => null, + ResponsiveLayoutSize.medium => 212.0, + ResponsiveLayoutSize.large => 222.0, + }; + + final padding = switch (layoutSize) { + ResponsiveLayoutSize.small => 16.0, + ResponsiveLayoutSize.medium => 16.0, + ResponsiveLayoutSize.large => 24.0, + }; + + return SizedBox( + height: cardHeight, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: theme.dividerColor, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: EdgeInsets.all(padding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SudokuIcon(iconAsset: iconAsset), + const SizedBox(height: 16), + Text( + title, + style: SudokuTextStyle.bodyText1, + maxLines: 1, + ), + const SizedBox(height: 8), + Text( + caption, + style: SudokuTextStyle.subtitle2.copyWith( + color: theme.brightness == Brightness.light + ? Colors.black87 + : Colors.white70, + fontSize: 14, + ), + maxLines: 2, + ), + const SizedBox(height: 8), + SudokuTextButton( + key: textButtonkey, + buttonText: l10n.buildSudokuBoardButtonText, + onPressed: onButtonPressed, + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 974a3bd..5e01aff 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,5 +1,13 @@ { "@@locale": "en", + "homeAppBarTitle": "Sudoku Game", + "@homeAppBarTitle": { + "description": "Text shown in the AppBar of the Home Page" + }, + "sudokuGameSubtitle": "Conquer daily puzzles, continue your saved game, or start fresh on a new challenge with varying difficulty levels for all", + "@sudokuGameSubtitle": { + "description": "Text shown in Home Page for Medium and Large screen as the subtitle" + }, "sudokuAppBarTitle": "New Sudoku Game", "@sudokuAppBarTitle": { "description": "Text shown in the AppBar of the Sudoku Page" @@ -7,5 +15,61 @@ "resumeTimerButtonText": "Resume the puzzle", "@resumeTimerButtonText": { "description": "Text shown in the FloatingActionButton of the Sudoku Board" + }, + "dailyChallengeTitle": "Daily Challenge", + "@dailyChallengeTitle": { + "description": "Title shown in the Daily Challenge card of the Home Page" + }, + "dailyChallengeSubtitle": "Compete and stand in podium!", + "@dailyChallengeSubtitle": { + "description": "Subtitle shown in the Daily Challenge card of the Home Page" + }, + "resumeSudokuTitle": "Resume Puzzle", + "@resumeSudokuTitle": { + "description": "Title shown in the Resume Sudoku card of the Home Page" + }, + "resumeSudokuSubtitle": "All clear! Start a new game.", + "@resumeSudokuSubtitle": { + "description": "Subtitle shown in the Resume Sudoku card of the Home Page" + }, + "createNewGameHeader": "New Game", + "@createNewGameHeader": { + "description": "Header text shown in the Create New Game section of the Home Page" + }, + "createEasyGameTitle": "Easy Mode", + "@createEasyGameTitle": { + "description": "Header text shown in the Easy Game section of the Home Page" + }, + "createEasyGameCaption": "40 clues revealed, fill in the rest and sharpen your skills.", + "@createEasyGameCaption": { + "description": "Caption text shown in the Easy Game section of the Home Page" + }, + "createMediumGameTitle": "Medium Mode", + "@createMediumGameTitle": { + "description": "Header text shown in the Medium Game section of the Home Page" + }, + "createMediumGameCaption": "30 clues provided, test your logic with a moderate challenge.", + "@createMediumGameCaption": { + "description": "Caption text shown in the Medium Game section of the Home Page" + }, + "createDifficultGameTitle": "Difficult Mode", + "@createDifficultGameTitle": { + "description": "Header text shown in the Difficult Game section of the Home Page" + }, + "createDifficultGameCaption": "20 clues given, push your problem-solving abilities to the limit.", + "@createDifficultGameCaption": { + "description": "Caption text shown in the Difficult Game section of the Home Page" + }, + "createExpertGameTitle": "Expert Mode", + "@createExpertGameTitle": { + "description": "Header text shown in the Expert Game section of the Home Page" + }, + "createExpertGameCaption": "10 clues exposed, tackle the ultimate Sudoku challenge and master the grid.", + "@createExpertGameCaption": { + "description": "Caption text shown in the Expert Game section of the Home Page" + }, + "buildSudokuBoardButtonText": "Build Sudoku Board -->", + "@buildSudokuBoardButtonText": { + "description": "Text shown in the TextButton for New Game modes in Home Page" } } diff --git a/lib/typography/text_styles.dart b/lib/typography/text_styles.dart index 5d6b9db..500fe1b 100644 --- a/lib/typography/text_styles.dart +++ b/lib/typography/text_styles.dart @@ -56,7 +56,7 @@ class SudokuTextStyle { static TextStyle get subtitle1 { return _baseTextStyle.copyWith( fontSize: 16, - fontWeight: SudokuFontWeight.bold, + fontWeight: SudokuFontWeight.medium, ); } @@ -64,7 +64,7 @@ class SudokuTextStyle { static TextStyle get subtitle2 { return _baseTextStyle.copyWith( fontSize: 14, - fontWeight: SudokuFontWeight.bold, + fontWeight: SudokuFontWeight.medium, ); } @@ -96,7 +96,7 @@ class SudokuTextStyle { static TextStyle get button { return _baseTextStyle.copyWith( fontSize: 16, - fontWeight: SudokuFontWeight.medium, + fontWeight: SudokuFontWeight.semiBold, ); } diff --git a/lib/widgets/sudoku_background.dart b/lib/widgets/sudoku_background.dart new file mode 100644 index 0000000..3d51484 --- /dev/null +++ b/lib/widgets/sudoku_background.dart @@ -0,0 +1,144 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:sudoku/colors/colors.dart'; + +/// {@template sudoku_background} +/// Builds the background of the Sudoku app. +/// +/// Renders [GradientBackground] along with a [BackdropFilter] +/// inside a [Stack] to facilitate the blur effect. +/// {@endtemplate} +class SudokuBackground extends StatelessWidget { + /// {@macro sudoku_background} + const SudokuBackground({required this.child, super.key}); + + /// Widget to be rendered above the background. + final Widget child; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + const Align( + alignment: Alignment.topLeft, + child: GradientBackground( + child: SizedBox( + width: 520, + child: AspectRatio( + aspectRatio: 1155 / 678, + ), + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: Transform.rotate( + angle: 30.0 * pi / 180, // Rotate second shape + child: const GradientBackground( + child: SizedBox( + width: 620, + child: AspectRatio( + aspectRatio: 1155 / 678, + ), + ), + ), + ), + ), + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 65, sigmaY: 65), + child: Container(color: Colors.black.withOpacity(0)), + ), + ), + child, + ], + ); + } +} + +/// {@template gradient_background} +/// Widget to create [CustomPaint] with [GradientBackgroundPainter] painter. +/// {@endtemplate} +@visibleForTesting +class GradientBackground extends StatelessWidget { + /// {@macro gradient_background} + const GradientBackground({required this.child, super.key}); + + /// Child of the [CustomPaint]. + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return CustomPaint( + painter: GradientBackgroundPainter( + brightness: theme.brightness, + ), + child: child, + ); + } +} + +/// {@template gradient_background_painter} +/// Creates the shape used for the background for the Sudoku app. +/// +/// Extends [CustomPainter]. +/// {@endtemplate} +@visibleForTesting +class GradientBackgroundPainter extends CustomPainter { + /// {@macro gradient_background_painter} + GradientBackgroundPainter({ + required this.brightness, + }); + + final Brightness brightness; + + final Paint _paint = Paint(); + + List colors() { + return brightness == Brightness.light + ? [ + SudokuColors.lightPink, + SudokuColors.lightPurple, + ] + : [ + SudokuColors.darkPink, + SudokuColors.darkPurple, + ]; + } + + @override + void paint(Canvas canvas, Size size) { + final path1 = Path() + ..moveTo(size.width * 0.741, size.height * 0.441) + ..lineTo(size.width, size.height * 0.616) + ..lineTo(size.width * 0.975, size.height * 0.269) + ..lineTo(size.width * 0.855, size.height * 0.01) + ..lineTo(size.width * 0.807, size.height * 0.2) + ..lineTo(size.width * 0.725, size.height * 0.325) + ..lineTo(size.width * 0.602, size.height * 0.624) + ..lineTo(size.width * 0.524, size.height * 0.681) + ..lineTo(size.width * 0.475, size.height * 0.583) + ..lineTo(size.width * 0.452, size.height * 0.345) + ..lineTo(size.width * 0.275, size.height * 0.767) + ..lineTo(size.width * 0.01, size.height * 0.649) + ..lineTo(size.width * 0.179, size.height) + ..lineTo(size.width * 0.276, size.height * 0.768) + ..lineTo(size.width * 0.761, size.height * 0.977) + ..lineTo(size.width * 0.741, size.height * 0.441) + ..close(); + + _paint.shader = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: colors(), + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + + canvas.drawPath(path1, _paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => true; +} diff --git a/lib/widgets/sudoku_elevated_button.dart b/lib/widgets/sudoku_elevated_button.dart new file mode 100644 index 0000000..5608d5c --- /dev/null +++ b/lib/widgets/sudoku_elevated_button.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:sudoku/typography/typography.dart'; + +/// {@template sudoku_elevated_button} +/// Custom [ElevatedButton] widget with gradient background. +/// {@endtemplate} +class SudokuElevatedButton extends StatelessWidget { + /// {@macro sudoku_elevated_button} + const SudokuElevatedButton({ + required this.buttonText, + required this.onPressed, + super.key, + }); + + /// Text to be shown in the button. + final String buttonText; + + /// Triggers the `onPressed` from [ElevatedButton]. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + height: 36, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundBuilder: (context, states, child) { + if (states.contains(WidgetState.disabled)) { + return DecoratedBox( + decoration: BoxDecoration( + color: theme.disabledColor.withOpacity(0.1), + ), + child: child, + ); + } + + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFC7649F), + Color(0xFF6C63C7), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: child, + ); + }, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: onPressed, + child: Text( + buttonText, + style: SudokuTextStyle.button, + ), + ), + ); + } +} diff --git a/lib/widgets/sudoku_icon.dart b/lib/widgets/sudoku_icon.dart new file mode 100644 index 0000000..9d5e8d0 --- /dev/null +++ b/lib/widgets/sudoku_icon.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +/// {@template sudoku_icon} +/// Custom widget that renders icon asset in a designed layout. +/// {@endtemplate} +class SudokuIcon extends StatelessWidget { + /// {@macro sudoku_icon} + const SudokuIcon({ + required this.iconAsset, + this.scaleFactor = 1, + super.key, + }); + + /// Icon from the assets. + final String iconAsset; + + /// Defines the scale factor of the widget. + /// + /// Will be multiplied with the [Container] sides and image asset sides. + /// + /// Default is 1. And it creates a container of side as 48, and icon asset + /// with size of 32. + final double scaleFactor; + + @override + Widget build(BuildContext context) { + return Container( + height: 48 * scaleFactor, + width: 48 * scaleFactor, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.white.withOpacity(0.6), + ), + child: Image.asset( + iconAsset, + height: 32 * scaleFactor, + width: 32 * scaleFactor, + ), + ); + } +} diff --git a/lib/widgets/sudoku_text_button.dart b/lib/widgets/sudoku_text_button.dart new file mode 100644 index 0000000..39cea99 --- /dev/null +++ b/lib/widgets/sudoku_text_button.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:sudoku/typography/typography.dart'; + +/// {@template sudoku_text_button} +/// Custom [TextButton] widget with gradient foreground. +/// {@endtemplate} +class SudokuTextButton extends StatelessWidget { + /// {@macro sudoku_text_button} + const SudokuTextButton({ + required this.buttonText, + required this.onPressed, + super.key, + }); + + /// Text to be shown in the button. + final String buttonText; + + /// Triggers the `onPressed` from [TextButton]. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + const gradient = LinearGradient( + colors: [ + Color(0xFFC7649F), + Color(0xFF6C63C7), + ], + ); + + return SizedBox( + height: 26, + width: double.infinity, + child: TextButton( + style: TextButton.styleFrom( + foregroundBuilder: (context, states, child) { + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => gradient.createShader( + Rect.fromLTWH(0, 0, bounds.width, bounds.height), + ), + child: child, + ); + }, + padding: const EdgeInsets.only(left: 0.1), + alignment: Alignment.centerLeft, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(2), + ), + ), + onPressed: onPressed, + child: Text( + buttonText, + style: SudokuTextStyle.button, + maxLines: 2, + ), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart new file mode 100644 index 0000000..c1cc26d --- /dev/null +++ b/lib/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'sudoku_background.dart'; +export 'sudoku_elevated_button.dart'; +export 'sudoku_icon.dart'; +export 'sudoku_text_button.dart'; diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 96997d2..34fc43c 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -1,12 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sudoku/app/app.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/home/home.dart'; void main() { group('App', () { - testWidgets('renders SudokuPage', (tester) async { + testWidgets('renders HomePage', (tester) async { await tester.pumpWidget(const App()); - expect(find.byType(SudokuPage), findsOneWidget); + expect(find.byType(HomePage), findsOneWidget); }); }); } diff --git a/test/home/home_page_test.dart b/test/home/home_page_test.dart new file mode 100644 index 0000000..380e351 --- /dev/null +++ b/test/home/home_page_test.dart @@ -0,0 +1,219 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/home/home.dart'; +import 'package:sudoku/widgets/widgets.dart'; + +import '../helpers/helpers.dart'; + +void main() { + group('HomePage', () { + const dailyChallengeKey = Key('daily_challenge_widget'); + const resumePuzzleKey = Key('resume_puzzle_widget'); + + const dailyChallengeElevatedButtonKey = Key( + 'daily_challenge_widget_elevated_button', + ); + const resumePuzzleElevatedButtonKey = Key( + 'resume_puzzle_widget_elevated_button', + ); + + testWidgets('renders on a large layout', (tester) async { + tester.setLargeDisplaySize(); + await tester.pumpApp(HomePage()); + + expect(find.byType(HomeView), findsOneWidget); + }); + + testWidgets('renders on a medium layout', (tester) async { + tester.setMediumDisplaySize(); + await tester.pumpApp(HomePage()); + + expect(find.byType(HomeView), findsOneWidget); + }); + + testWidgets('renders on a small layout', (tester) async { + tester.setSmallDisplaySize(); + await tester.pumpApp(HomePage()); + + expect(find.byType(HomeView), findsOneWidget); + }); + + group('Daily Challenge', () { + testWidgets('exists in the widget tree', (tester) async { + await tester.pumpApp(HomeView()); + expect(find.byKey(dailyChallengeKey), findsOneWidget); + }); + + testWidgets('onPressed is defined', (tester) async { + await tester.pumpApp(HomeView()); + final finder = find.byWidgetPredicate( + (widget) => + widget is SudokuElevatedButton && + widget.key == dailyChallengeElevatedButtonKey && + widget.onPressed != null, + ); + expect( + finder, + findsOneWidget, + ); + await tester.tap(finder); + await tester.pumpAndSettle(); + }); + }); + + group('Resume Puzzle', () { + testWidgets('exists in the widget tree', (tester) async { + await tester.pumpApp(HomeView()); + expect(find.byKey(resumePuzzleKey), findsOneWidget); + }); + + testWidgets('onPressed is defined', (tester) async { + await tester.pumpApp(HomeView()); + final finder = find.byWidgetPredicate( + (widget) => + widget is SudokuElevatedButton && + widget.key == resumePuzzleElevatedButtonKey && + widget.onPressed == null, + ); + expect( + finder, + findsOneWidget, + ); + }); + }); + + group('Create Game', () { + const easyGameKey = Key('create_game_easy_mode'); + const mediumGameKey = Key('create_game_medium_mode'); + const difficultGameKey = Key('create_game_difficult_mode'); + const expertyGameKey = Key('create_game_expert_mode'); + + const easyGameTextButtonKey = Key( + 'create_game_easy_mode_text_button', + ); + const mediumGameTextButtonKey = Key( + 'create_game_medium_mode_text_button', + ); + const difficultGameTextButtonKey = Key( + 'create_game_difficult_mode_text_button', + ); + const expertGameTextButtonKey = Key( + 'create_game_expert_mode_text_button', + ); + + group('easy mode', () { + testWidgets('exists in the widget tree', (tester) async { + await tester.pumpApp(HomeView()); + expect(find.byKey(easyGameKey), findsOneWidget); + }); + + testWidgets('onPressed is defined', (tester) async { + // To avoid the below warning: + // Maybe the widget is actually off-screen, or another widget is + // obscuring it, or the widget cannot receive pointer events + tester.setLargeDisplaySize(); + + await tester.pumpApp(HomeView()); + final finder = find.byWidgetPredicate( + (widget) => + widget is SudokuTextButton && + widget.key == easyGameTextButtonKey && + widget.onPressed != null, + ); + expect( + finder, + findsOneWidget, + ); + await tester.tap(finder); + await tester.pumpAndSettle(); + }); + }); + + group('medium mode', () { + testWidgets('exists in the widget tree', (tester) async { + await tester.pumpApp(HomeView()); + expect(find.byKey(mediumGameKey), findsOneWidget); + }); + + testWidgets('onPressed is defined', (tester) async { + // To avoid the below warning: + // Maybe the widget is actually off-screen, or another widget is + // obscuring it, or the widget cannot receive pointer events + tester.setLargeDisplaySize(); + + await tester.pumpApp(HomeView()); + final finder = find.byWidgetPredicate( + (widget) => + widget is SudokuTextButton && + widget.key == mediumGameTextButtonKey && + widget.onPressed != null, + ); + expect( + finder, + findsOneWidget, + ); + await tester.tap(find.byKey(mediumGameTextButtonKey)); + await tester.pumpAndSettle(); + }); + }); + + group('difficult mode', () { + testWidgets('exists in the widget tree', (tester) async { + await tester.pumpApp(HomeView()); + expect(find.byKey(difficultGameKey), findsOneWidget); + }); + + testWidgets('onPressed is defined', (tester) async { + // To avoid the below warning: + // Maybe the widget is actually off-screen, or another widget is + // obscuring it, or the widget cannot receive pointer events + tester.setLargeDisplaySize(); + + await tester.pumpApp(HomeView()); + final finder = find.byWidgetPredicate( + (widget) => + widget is SudokuTextButton && + widget.key == difficultGameTextButtonKey && + widget.onPressed != null, + ); + expect( + finder, + findsOneWidget, + ); + await tester.tap(find.byKey(difficultGameTextButtonKey)); + await tester.pumpAndSettle(); + }); + }); + + group('expert mode', () { + testWidgets('exists in the widget tree', (tester) async { + await tester.pumpApp(HomeView()); + expect(find.byKey(expertyGameKey), findsOneWidget); + }); + + testWidgets('onPressed is defined', (tester) async { + // To avoid the below warning: + // Maybe the widget is actually off-screen, or another widget is + // obscuring it, or the widget cannot receive pointer events + tester.setLargeDisplaySize(); + + await tester.pumpApp(HomeView()); + final finder = find.byWidgetPredicate( + (widget) => + widget is SudokuTextButton && + widget.key == expertGameTextButtonKey && + widget.onPressed != null, + ); + expect( + finder, + findsOneWidget, + ); + await tester.tap(find.byKey(expertGameTextButtonKey)); + await tester.pumpAndSettle(); + }); + }); + }); + }); +} diff --git a/test/widgets/sudoku_background_test.dart b/test/widgets/sudoku_background_test.dart new file mode 100644 index 0000000..5486ecd --- /dev/null +++ b/test/widgets/sudoku_background_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/colors/colors.dart'; +import 'package:sudoku/widgets/widgets.dart'; + +class MockCustomPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) {} + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +void main() { + group('GradientBackgroundPainter', () { + test('extends [CustomPainter] and [shouldRepaint] is set to true', () { + final widget = GradientBackgroundPainter( + brightness: Brightness.light, + ); + final mockPainter = MockCustomPainter(); + expect(widget, isA()); + expect(widget.shouldRepaint(mockPainter), true); + }); + + test('uses colors for gradient based on [Brightness]', () { + final lightWidget = GradientBackgroundPainter( + brightness: Brightness.light, + ); + + final darkWidget = GradientBackgroundPainter( + brightness: Brightness.dark, + ); + + expect( + lightWidget.colors(), + equals( + [ + SudokuColors.lightPink, + SudokuColors.lightPurple, + ], + ), + ); + + expect( + darkWidget.colors(), + equals( + [ + SudokuColors.darkPink, + SudokuColors.darkPurple, + ], + ), + ); + }); + }); +}