From c203d079699020ef4635292aef307d3bb47e5394 Mon Sep 17 00:00:00 2001 From: Jeroen Meijer Date: Tue, 21 Jul 2020 00:44:28 +0200 Subject: [PATCH] feat: add countdown and word entry details card --- README.md | 1 + app/lib/app/app_root.dart | 2 +- app/lib/assets/assets.dart | 2 +- app/lib/assets/dictionary_files.dart | 7 + app/lib/backend/backend.dart | 1 + .../models}/dictionaries.dart | 4 +- app/lib/backend/models/game.dart | 41 +++++ app/lib/backend/models/game_settings.dart | 60 +++++++ app/lib/backend/models/models.dart | 3 + app/lib/intl/arb/intl_messages.arb | 44 +++++- app/lib/intl/strings/game_strings.dart | 20 +++ app/lib/main.dart | 5 +- app/lib/theme/theme.dart | 30 +++- app/lib/ui/screens/game/game_screen.dart | 128 ++++++--------- .../game/pages/countdown/countdown.dart | 1 + .../game/pages/countdown/countdown_page.dart | 89 +++++++++++ .../screens/game/pages/in_game/in_game.dart | 1 + .../game/pages/in_game/in_game_page.dart | 52 +++++++ .../game/pages/in_game/widgets/widgets.dart | 1 + .../widgets/word_entry_details_card.dart | 147 ++++++++++++++++++ app/lib/ui/screens/game/pages/pages.dart | 2 + app/lib/ui/screens/home/home_screen.dart | 9 +- .../ui/screens/home/widgets/home_header.dart | 4 +- .../ui/screens/home/widgets/home_menu.dart | 13 +- app/lib/utils/route_notifier.dart | 108 ------------- app/lib/utils/utils.dart | 56 +++++-- app/pubspec.lock | 16 ++ app/pubspec.yaml | 8 +- shared_models/lib/src/dictionary.dart | 2 +- shared_models/lib/src/word_entry.dart | 5 + 30 files changed, 630 insertions(+), 232 deletions(-) create mode 100644 app/lib/assets/dictionary_files.dart rename app/lib/{assets => backend/models}/dictionaries.dart (90%) create mode 100644 app/lib/backend/models/game.dart create mode 100644 app/lib/backend/models/game_settings.dart create mode 100644 app/lib/backend/models/models.dart create mode 100644 app/lib/ui/screens/game/pages/countdown/countdown.dart create mode 100644 app/lib/ui/screens/game/pages/countdown/countdown_page.dart create mode 100644 app/lib/ui/screens/game/pages/in_game/in_game.dart create mode 100644 app/lib/ui/screens/game/pages/in_game/in_game_page.dart create mode 100644 app/lib/ui/screens/game/pages/in_game/widgets/widgets.dart create mode 100644 app/lib/ui/screens/game/pages/in_game/widgets/word_entry_details_card.dart create mode 100644 app/lib/ui/screens/game/pages/pages.dart delete mode 100644 app/lib/utils/route_notifier.dart diff --git a/README.md b/README.md index a98ae5a..680f9a3 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Run the dict_parser by using the included script: Here are some things I still need/want to implement. - Actually being able to play the game. +- Kana-insensitive word search (i.e., refactor and improve `package:kana`) - Backend integration to play against other players preferably using Firebase. - More pretty animations. diff --git a/app/lib/app/app_root.dart b/app/lib/app/app_root.dart index 44d07fc..c3d5231 100644 --- a/app/lib/app/app_root.dart +++ b/app/lib/app/app_root.dart @@ -3,7 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; -import 'package:shiritori/assets/assets.dart'; +import 'package:shiritori/backend/backend.dart'; import 'package:shiritori/intl/intl.dart'; import 'package:shiritori/theme/theme.dart'; import 'package:shiritori/ui/routes/routes.dart'; diff --git a/app/lib/assets/assets.dart b/app/lib/assets/assets.dart index 71864a1..129ebb6 100644 --- a/app/lib/assets/assets.dart +++ b/app/lib/assets/assets.dart @@ -1,3 +1,3 @@ -export 'dictionaries.dart'; +export 'dictionary_files.dart'; export 'fonts.dart'; export 'images.dart'; diff --git a/app/lib/assets/dictionary_files.dart b/app/lib/assets/dictionary_files.dart new file mode 100644 index 0000000..c806c53 --- /dev/null +++ b/app/lib/assets/dictionary_files.dart @@ -0,0 +1,7 @@ +import 'package:shiritori/backend/backend.dart'; + +const _basePath = 'assets/dicts'; + +final dictionaryFiles = { + Language.japanese: '$_basePath/dict_ja.json', +}; diff --git a/app/lib/backend/backend.dart b/app/lib/backend/backend.dart index d08598b..b9314f1 100644 --- a/app/lib/backend/backend.dart +++ b/app/lib/backend/backend.dart @@ -1 +1,2 @@ +export 'models/models.dart'; export 'repositories/repositories.dart'; diff --git a/app/lib/assets/dictionaries.dart b/app/lib/backend/models/dictionaries.dart similarity index 90% rename from app/lib/assets/dictionaries.dart rename to app/lib/backend/models/dictionaries.dart index 75eecc1..327ec84 100644 --- a/app/lib/assets/dictionaries.dart +++ b/app/lib/backend/models/dictionaries.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:provider/provider.dart'; import 'package:shared_models/shared_models.dart'; +import 'package:shiritori/assets/assets.dart'; export 'package:shared_models/shared_models.dart'; @@ -27,8 +28,7 @@ class Dictionaries { } static Future _loadDictionaryFromDisk(Language language) async { - final dictText = - await rootBundle.loadString('assets/dicts/dict_${language.code}.json'); + final dictText = await rootBundle.loadString(dictionaryFiles[language]); final dictJson = json.decode(dictText) as Map; final dict = Dictionary.fromJson(dictJson); diff --git a/app/lib/backend/models/game.dart b/app/lib/backend/models/game.dart new file mode 100644 index 0000000..7db5914 --- /dev/null +++ b/app/lib/backend/models/game.dart @@ -0,0 +1,41 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; +import 'package:provider/provider.dart'; +import 'package:shiritori/backend/backend.dart'; + +class Game { + const Game({ + @required this.settings, + @required this.guessesByPlayerIndex, + this.winningPlayerIndex, + }) : assert(settings != null), + assert(guessesByPlayerIndex != null); + + factory Game.startNew(GameSettings settings) { + return Game( + settings: settings, + guessesByPlayerIndex: {0: {}, 1: {}}, + winningPlayerIndex: null, + ); + } + + static Game of(BuildContext context) { + return Provider.of(context, listen: false); + } + + final GameSettings settings; + final Map> guessesByPlayerIndex; + final int winningPlayerIndex; + + Set get allGuesses { + return guessesByPlayerIndex.entries.expand((entry) => entry.value).toSet(); + } + + bool hasBeenGuessed(String guess) { + return allGuesses.contains(guess); + } + + bool isValidGuess(String guess) { + return hasBeenGuessed(guess); + } +} diff --git a/app/lib/backend/models/game_settings.dart b/app/lib/backend/models/game_settings.dart new file mode 100644 index 0000000..b49534a --- /dev/null +++ b/app/lib/backend/models/game_settings.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; +import 'package:shiritori/backend/backend.dart'; + +@immutable +abstract class GameSettings { + const GameSettings({ + @required this.dictionary, + @required this.answeringDuration, + @required this.enemyType, + }); + + final Dictionary dictionary; + final Duration answeringDuration; + final GameEnemyType enemyType; +} + +@immutable +class SingleplayerGameSettings implements GameSettings { + const SingleplayerGameSettings({ + @required this.dictionary, + @required this.answeringDuration, + }); + + @override + final Dictionary dictionary; + + @override + final Duration answeringDuration; + + @override + final GameEnemyType enemyType = GameEnemyType.singleplayer; +} + +@immutable +class MultiplayerGameSettings implements GameSettings { + const MultiplayerGameSettings({ + @required this.dictionary, + @required this.answeringDuration, + }); + + @override + final Dictionary dictionary; + + @override + final Duration answeringDuration; + + @override + final GameEnemyType enemyType = GameEnemyType.multiplayer; +} + +enum GameEnemyType { + singleplayer, + multiplayer, +} + +extension GameEnemyTypeX on GameEnemyType { + bool get isSingleplayer => this == GameEnemyType.singleplayer; + bool get isMultiplayer => this == GameEnemyType.multiplayer; +} diff --git a/app/lib/backend/models/models.dart b/app/lib/backend/models/models.dart new file mode 100644 index 0000000..cf82d0d --- /dev/null +++ b/app/lib/backend/models/models.dart @@ -0,0 +1,3 @@ +export 'dictionaries.dart'; +export 'game.dart'; +export 'game_settings.dart'; diff --git a/app/lib/intl/arb/intl_messages.arb b/app/lib/intl/arb/intl_messages.arb index 2f12663..1e455d0 100644 --- a/app/lib/intl/arb/intl_messages.arb +++ b/app/lib/intl/arb/intl_messages.arb @@ -1,4 +1,22 @@ { + "singleplayerTitle": "Singleplayer", + "@singleplayerTitle": { + "description": "The header for the Singleplayer page. Usually, this string should be the same as HomeStrings.singleplayerCardTitle.", + "type": "text", + "placeholders": {} + }, + "multiplayerTitle": "Multiplayer", + "@multiplayerTitle": { + "description": "The header for the Multiplayer page. Usually, this string should be the same as HomeStrings.multiplayerCardTitle.", + "type": "text", + "placeholders": {} + }, + "getReady": "Get Ready...", + "@getReady": { + "description": "Text indicating a match is about to start and the player needs to start paying attention and get themselves ready.", + "type": "text", + "placeholders": {} + }, "welcomeHeader": "Welcome back,\n{name}.", "@welcomeHeader": { "description": "Welcome message displayed at the top of the screen that greets the user.", @@ -27,15 +45,15 @@ "type": "text", "placeholders": {} }, - "quickPlayCardTitle": "Quick Play", - "@quickPlayCardTitle": { - "description": "Title on the Quick Play card that allows the user to start a quick play match.", + "singleplayerCardTitle": "Singleplayer", + "@singleplayerCardTitle": { + "description": "Title on the Singleplayer card that allows the user to start a quick play match.", "type": "text", "placeholders": {} }, - "quickPlayCardSubtitle": "PLAY AGAINST CPU", - "@quickPlayCardSubtitle": { - "description": "Subtitle on the Quick Play card that indicates it is a new game mode. Should be all capitals if applicable.", + "singleplayerCardSubtitle": "AGAINST CPU", + "@singleplayerCardSubtitle": { + "description": "Subtitle on the Singleplayer card that indicates it is a new game mode. Should be all capitals if applicable.", "type": "text", "placeholders": {} }, @@ -45,7 +63,7 @@ "type": "text", "placeholders": {} }, - "multiplayerCardSubtitle": "PLAY WITH FRIENDS", + "multiplayerCardSubtitle": "WITH FRIENDS", "@multiplayerCardSubtitle": { "description": "Subtitle on the Multiplayer card that indicates the user can play with friends. Should be all capitals if applicable.", "type": "text", @@ -75,10 +93,22 @@ "type": "text", "placeholders": {} }, + "quickPlayButtonTitle": "Quick Play", + "@quickPlayButtonTitle": { + "description": "Text on the Quick Play button that instantly starts a singleplayer CPU match.", + "type": "text", + "placeholders": {} + }, "snackBarConfirmButton": "OK", "@snackBarConfirmButton": { "description": "Button text displayed on a snackbar that confirms and dismisses it.", "type": "text", "placeholders": {} + }, + "back": "BACK", + "@back": { + "description": "Button text displayed on back buttons that confirms and dismisses it. Should be all capitals if applicable.", + "type": "text", + "placeholders": {} } } \ No newline at end of file diff --git a/app/lib/intl/strings/game_strings.dart b/app/lib/intl/strings/game_strings.dart index ac20d89..7cdec11 100644 --- a/app/lib/intl/strings/game_strings.dart +++ b/app/lib/intl/strings/game_strings.dart @@ -10,4 +10,24 @@ class GameStrings { 'HomeStrings.singleplayerCardTitle.', ); } + + String get multiplayerTitle { + return Intl.message( + 'Multiplayer', + name: 'multiplayerTitle', + desc: 'The header for the Multiplayer page. ' + 'Usually, this string should be the same as ' + 'HomeStrings.multiplayerCardTitle.', + ); + } + + String get getReady { + return Intl.message( + 'Get Ready...', + name: 'getReady', + desc: 'Text indicating a match is about to start ' + 'and the player needs to start paying attention ' + 'and get themselves ready.', + ); + } } diff --git a/app/lib/main.dart b/app/lib/main.dart index 6f02697..e8570c2 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -2,12 +2,13 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:shiritori/app/app.dart'; +import 'package:shiritori/backend/backend.dart'; import 'assets/assets.dart'; void main() async { log('Initializing bindings...'); - + final initStopwatch = Stopwatch()..start(); WidgetsFlutterBinding.ensureInitialized(); log('Loading background image...'); @@ -15,7 +16,7 @@ void main() async { log('Loading dictionaries...'); final dictionaries = await Dictionaries.loadFromDisk(); initStopwatch.stop(); - + final seconds = (initStopwatch.elapsedMilliseconds / 1000).toStringAsFixed(3); log('Initialization done. (took $seconds seconds)'); diff --git a/app/lib/theme/theme.dart b/app/lib/theme/theme.dart index 16410da..75fddbe 100644 --- a/app/lib/theme/theme.dart +++ b/app/lib/theme/theme.dart @@ -6,6 +6,7 @@ abstract class AppTheme { static ThemeData get themeDataLight { return ThemeData( buttonTheme: buttonTheme, + canvasColor: veryLightGrey, cardTheme: cardTheme, dividerTheme: dividerTheme, fontFamily: Fonts.hurmeGeometricSans, @@ -36,19 +37,19 @@ abstract class AppTheme { static const _letterSpacing = -0.5; static const textTheme = TextTheme( headline1: TextStyle( - color: white, + color: blue, fontWeight: FontWeight.bold, height: 1.0, letterSpacing: _letterSpacing, ), headline2: TextStyle( - color: white, + color: blue, height: 1.0, fontWeight: FontWeight.bold, letterSpacing: _letterSpacing, ), headline3: TextStyle( - color: white, + color: blue, height: 1.0, fontWeight: FontWeight.bold, letterSpacing: _letterSpacing, @@ -67,25 +68,40 @@ abstract class AppTheme { ), headline6: TextStyle( color: blue, + fontSize: 22, height: 1.0, fontWeight: FontWeight.bold, letterSpacing: _letterSpacing, ), - overline: TextStyle( + bodyText1: TextStyle( + fontSize: 20, + ), + bodyText2: TextStyle( + fontSize: 18, + ), + subtitle1: TextStyle( color: blue, + fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: _letterSpacing, ), - subtitle1: TextStyle( + subtitle2: TextStyle( color: blue, + fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: _letterSpacing, ), - subtitle2: TextStyle( + caption: TextStyle( color: blue, + fontSize: 16, fontWeight: FontWeight.bold, letterSpacing: _letterSpacing, ), + overline: TextStyle( + color: blue, + fontSize: 14, + letterSpacing: _letterSpacing, + ), ); static const white = Color(0xFFFFFFFF); @@ -114,7 +130,7 @@ abstract class AppTheme { static const colorSingleplayer = orange; - static const elevationDefault = 18.0; + static const elevationDefault = 8.0; static const elevationDisabled = elevationDefault / 3; static const radiusCardDefault = Radius.circular(16.0); static const shapeDefault = RoundedRectangleBorder( diff --git a/app/lib/ui/screens/game/game_screen.dart b/app/lib/ui/screens/game/game_screen.dart index de76d54..b2d4dbb 100644 --- a/app/lib/ui/screens/game/game_screen.dart +++ b/app/lib/ui/screens/game/game_screen.dart @@ -1,117 +1,79 @@ +import 'dart:async'; + +import 'package:animations/animations.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:shiritori/assets/assets.dart'; -import 'package:shiritori/intl/intl.dart'; -import 'package:shiritori/theme/theme.dart'; -import 'package:shiritori/ui/widgets/widgets.dart'; +import 'package:shiritori/backend/backend.dart'; +import 'package:shiritori/ui/screens/game/pages/pages.dart'; /// TEMPORARILY A SEARCH SCREEN FOR DICTIONARIES class GameScreen extends StatefulWidget { GameScreen({ Key key, - @required this.dictionary, - }) : assert(dictionary != null), + @required this.game, + }) : assert(game != null), super(key: key); - final Dictionary dictionary; + final Game game; @override _GameScreenState createState() => _GameScreenState(); } class _GameScreenState extends State { - var _searchResults = {}; + static const _playCountdownSeconds = 3; + + Timer _playCountdownTimer; @override void initState() { super.initState(); - } - void onChangeQuery(String query) { - setState(() { - _searchResults = widget.dictionary.searchWord(query); + // Delaying the timer to sync up better with the surrounding animation. + Future.delayed(const Duration(milliseconds: 300), () { + _playCountdownTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _onPlayCountdownTimerTick(), + ); }); } - String get _formattedSearchResult { - if (_searchResults.isEmpty) { - 'No word found...'; - } - - final sb = StringBuffer(); - - sb.writeln('${_searchResults.length} words found!\n'); + @override + void dispose() { + _playCountdownTimer?.cancel(); + super.dispose(); + } - for (final word in _searchResults) { - sb.writeln('${[...word.spellings, word.phoneticSpellings].join(', ')}'); - for (final definition in word.definitions) { - sb.writeln(' - $definition'); - } - sb.writeln(); + void _onPlayCountdownTimerTick() { + setState(() {}); + if (_playCountdownTimer.tick == _playCountdownSeconds) { + _playCountdownTimer.cancel(); } + } - return sb.toString(); + int get _playCountdownSecondsRemaining { + return _playCountdownSeconds - (_playCountdownTimer?.tick ?? 0); } @override Widget build(BuildContext context) { - // final intl = ShiritoriLocalizations.of(context).game; - final uiIntl = ShiritoriLocalizations.of(context).ui; - - return Provider.value( - value: widget.dictionary, - child: DefaultStylingColor( - color: AppTheme.colorSingleplayer, - child: Scaffold( - resizeToAvoidBottomInset: true, - resizeToAvoidBottomPadding: true, - body: CustomScrollView( - physics: const NeverScrollableScrollPhysics(), - slivers: [ - AppSliverNavigationBar( - // title: Text(intl.singleplayerTitle), - title: const Text('Dictionary Test'), - leading: TextButton( - onTap: Navigator.of(context).pop, - child: Text(uiIntl.back), - ), - ), - SliverFillRemaining( - child: Padding( - padding: const EdgeInsets.all(14.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Text(_formattedSearchResult), - ), - verticalMargin12, - SizedBox( - width: double.infinity, - child: TextField( - autocorrect: false, - minLines: 1, - maxLines: 1, - decoration: InputDecoration( - errorText: _searchResults.isNotEmpty - ? null - : 'No word found.', - labelText: 'Word Query', - hintText: 'ことば', - ), - onChanged: onChangeQuery, - ), - ), - ], - ), - ), - ), - ], - ), - ), + return Provider.value( + value: widget.game, + child: PageTransitionSwitcher( + transitionBuilder: (child, animation, secondaryAnimation) { + return SharedAxisTransition( + transitionType: SharedAxisTransitionType.vertical, + animation: animation, + secondaryAnimation: secondaryAnimation, + child: child, + ); + }, + child: _playCountdownSecondsRemaining > 0 + ? CountdownPage( + secondsRemaining: _playCountdownSecondsRemaining, + ) + : const InGamePage(), ), ); } diff --git a/app/lib/ui/screens/game/pages/countdown/countdown.dart b/app/lib/ui/screens/game/pages/countdown/countdown.dart new file mode 100644 index 0000000..993e4b2 --- /dev/null +++ b/app/lib/ui/screens/game/pages/countdown/countdown.dart @@ -0,0 +1 @@ +export 'countdown_page.dart'; diff --git a/app/lib/ui/screens/game/pages/countdown/countdown_page.dart b/app/lib/ui/screens/game/pages/countdown/countdown_page.dart new file mode 100644 index 0000000..d30b144 --- /dev/null +++ b/app/lib/ui/screens/game/pages/countdown/countdown_page.dart @@ -0,0 +1,89 @@ +import 'package:avatar_glow/avatar_glow.dart'; +import 'package:flutter/material.dart'; +import 'package:shiritori/backend/backend.dart'; +import 'package:shiritori/intl/intl.dart'; +import 'package:shiritori/theme/theme.dart'; +import 'package:shiritori/ui/widgets/widgets.dart'; + +class CountdownPage extends StatelessWidget { + const CountdownPage({ + Key key, + @required this.secondsRemaining, + }) : assert(secondsRemaining != null), + super(key: key); + + final int secondsRemaining; + + Color get _countdownGlowColor { + if (secondsRemaining == 1) { + return AppTheme.green; + } else if (secondsRemaining == 2) { + return AppTheme.lightBlue; + } else { + return AppTheme.blue; + } + } + + @override + Widget build(BuildContext context) { + final intl = ShiritoriLocalizations.of(context).game; + final textTheme = Theme.of(context).textTheme; + final game = Game.of(context); + + return Scaffold( + body: Column( + children: [ + topSafePadding, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + game.settings.enemyType.isSingleplayer + ? intl.singleplayerTitle + : intl.multiplayerTitle, + style: textTheme.headline3, + ), + Text( + game.settings.dictionary.language.name, + style: textTheme.headline4, + ), + ], + ), + ), + Text( + intl.getReady, + style: textTheme.headline5.copyWith(color: AppTheme.grey), + ), + Expanded( + flex: 2, + child: Center( + child: AvatarGlow( + endRadius: 100, + duration: const Duration(seconds: 1), + repeatPauseDuration: Duration.zero, + glowColor: _countdownGlowColor, + child: Material( + elevation: AppTheme.elevationDisabled, + animationDuration: const Duration(milliseconds: 150), + shape: const CircleBorder(), + child: SizedBox.fromSize( + size: const Size.square(100.0), + child: Center( + child: Text( + '$secondsRemaining', + style: textTheme.headline3, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/ui/screens/game/pages/in_game/in_game.dart b/app/lib/ui/screens/game/pages/in_game/in_game.dart new file mode 100644 index 0000000..984d92b --- /dev/null +++ b/app/lib/ui/screens/game/pages/in_game/in_game.dart @@ -0,0 +1 @@ +export 'in_game_page.dart'; diff --git a/app/lib/ui/screens/game/pages/in_game/in_game_page.dart b/app/lib/ui/screens/game/pages/in_game/in_game_page.dart new file mode 100644 index 0000000..49ff81f --- /dev/null +++ b/app/lib/ui/screens/game/pages/in_game/in_game_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:shiritori/backend/backend.dart'; +import 'package:shiritori/theme/theme.dart'; +import 'package:shiritori/ui/screens/game/pages/in_game/widgets/widgets.dart'; + +class InGamePage extends StatefulWidget { + const InGamePage({Key key}) : super(key: key); + + @override + _InGamePageState createState() => _InGamePageState(); +} + +class _InGamePageState extends State { + FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _focusNode?.unfocus(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final game = Game.of(context); + final theme = Theme.of(context); + + return Scaffold( + body: Center( + child: Theme( + data: theme.copyWith( + cardTheme: theme.cardTheme.copyWith( + color: AppTheme.blue, + ), + textTheme: theme.textTheme.apply( + bodyColor: AppTheme.white, + displayColor: AppTheme.white, + ), + ), + child: WordEntryDetailsCard( + wordEntry: game.settings.dictionary.searchWord('大学').first, + ), + ), + ), + ); + } +} diff --git a/app/lib/ui/screens/game/pages/in_game/widgets/widgets.dart b/app/lib/ui/screens/game/pages/in_game/widgets/widgets.dart new file mode 100644 index 0000000..fb96de7 --- /dev/null +++ b/app/lib/ui/screens/game/pages/in_game/widgets/widgets.dart @@ -0,0 +1 @@ +export 'word_entry_details_card.dart'; diff --git a/app/lib/ui/screens/game/pages/in_game/widgets/word_entry_details_card.dart b/app/lib/ui/screens/game/pages/in_game/widgets/word_entry_details_card.dart new file mode 100644 index 0000000..0296fe8 --- /dev/null +++ b/app/lib/ui/screens/game/pages/in_game/widgets/word_entry_details_card.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:kana/kana.dart'; +import 'package:shiritori/backend/backend.dart'; +import 'package:shiritori/theme/theme.dart'; +import 'package:shiritori/ui/widgets/widgets.dart'; +import 'package:shiritori/utils/utils.dart'; + +class WordEntryDetailsCard extends StatelessWidget { + const WordEntryDetailsCard({ + Key key, + @required this.wordEntry, + }) : super(key: key); + + final WordEntry wordEntry; + + /// Picks the most suitable spelling from a collection of options. + /// + /// 1. Picks for the first spelling that is fully written in Kana. + /// 2. If none can be found, it picks the first spelling that has both the + /// most characters written in Kana and has a Kana character at the end. + /// + /// The result is converted into Hiragana and returned. + /// + String _pickMostSuitableSpelling(List spellings) { + assert(spellings != null); + assert(spellings.isNotEmpty); + + final kanaCharsPerSpelling = spellings.map((spelling) { + final kanaChars = spelling.split('').fold(0, (acc, cur) { + return acc + (isCharHiragana(cur) || isCharKatakana(cur) ? 1 : 0); + }); + + return Tuple(spelling, kanaChars); + }).toList(); + + kanaCharsPerSpelling + .sort((tuple1, tuple2) => tuple2.right.compareTo(tuple1.right)); + + final result = kanaCharsPerSpelling + .firstWhere( + (tuple) => tuple.left.length == tuple.right, + orElse: () => kanaCharsPerSpelling.first, + ) + .left; + + return toHiragana(result); + } + + String get primarySpelling { + final spellings = [ + ...wordEntry.phoneticSpellings, + ...wordEntry.spellings, + ]; + + return _pickMostSuitableSpelling(spellings); + } + + List get secondarySpellings { + return List.of(wordEntry.phoneticSpellings) + ..remove(primarySpelling) + ..sort((first, second) => second.length.compareTo(first.length)); + } + + List get tertiarySpellings { + return List.of(wordEntry.spellings) + ..remove(primarySpelling) + ..sort((first, second) => second.length.compareTo(first.length)); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + final primarySpelling = this.primarySpelling; + + final primarySpellingParts = primarySpelling.chipOffLast(); + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(14.0), + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: 8.0, + runSpacing: 4.0, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan(text: primarySpellingParts.left), + TextSpan( + style: const TextStyle(color: AppTheme.orange), + text: primarySpellingParts.right, + ), + ], + ), + style: textTheme.headline4, + ), + for (final spelling in secondarySpellings) + Text( + spelling, + style: textTheme.headline5.copyWith( + color: textTheme.headline5.color.withOpacity(0.7), + ), + ), + ], + ), + verticalMargin8, + Wrap( + spacing: 8.0, + runSpacing: 8.0, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final spelling in tertiarySpellings) + Text( + spelling, + style: textTheme.headline6.copyWith( + color: textTheme.bodyText1.color.withOpacity(0.6), + fontWeight: FontWeight.normal, + ), + ), + ], + ), + verticalMargin8, + for (final definition in wordEntry.definitions.take(4)) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text(' ・ '), + Expanded( + child: Text(definition), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/ui/screens/game/pages/pages.dart b/app/lib/ui/screens/game/pages/pages.dart new file mode 100644 index 0000000..6f709e4 --- /dev/null +++ b/app/lib/ui/screens/game/pages/pages.dart @@ -0,0 +1,2 @@ +export 'countdown/countdown.dart'; +export 'in_game/in_game.dart'; diff --git a/app/lib/ui/screens/home/home_screen.dart b/app/lib/ui/screens/home/home_screen.dart index babe343..df5ac4e 100644 --- a/app/lib/ui/screens/home/home_screen.dart +++ b/app/lib/ui/screens/home/home_screen.dart @@ -3,7 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:shiritori/assets/assets.dart'; +import 'package:shiritori/backend/backend.dart'; import 'package:shiritori/intl/intl.dart'; import 'package:shiritori/theme/theme.dart'; import 'package:shiritori/ui/screens/game/game.dart'; @@ -53,7 +53,12 @@ class HomeScreen extends StatelessWidget { ExpandingRouteButton( routeBuilder: (context) { return GameScreen( - dictionary: Dictionaries.of(context).japanese, + game: Game.startNew( + SingleplayerGameSettings( + dictionary: Dictionaries.of(context).japanese, + answeringDuration: const Duration(seconds: 10), + ), + ), ); }, child: Text(intl.quickPlayButtonTitle), diff --git a/app/lib/ui/screens/home/widgets/home_header.dart b/app/lib/ui/screens/home/widgets/home_header.dart index 3678771..8727f26 100644 --- a/app/lib/ui/screens/home/widgets/home_header.dart +++ b/app/lib/ui/screens/home/widgets/home_header.dart @@ -22,7 +22,9 @@ class HomeHeader extends StatelessWidget { width: double.infinity, child: Text( intl.welcomeHeader('Jeroen'), - style: textTheme.headline3, + style: textTheme.headline3.copyWith( + color: AppTheme.white, + ), ), ), ), diff --git a/app/lib/ui/screens/home/widgets/home_menu.dart b/app/lib/ui/screens/home/widgets/home_menu.dart index 989f0c0..ed31b51 100644 --- a/app/lib/ui/screens/home/widgets/home_menu.dart +++ b/app/lib/ui/screens/home/widgets/home_menu.dart @@ -1,9 +1,7 @@ -import 'dart:developer'; - import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:shiritori/assets/assets.dart'; +import 'package:shiritori/backend/backend.dart'; import 'package:shiritori/intl/intl.dart'; import 'package:shiritori/theme/theme.dart'; import 'package:shiritori/ui/screens/game/game.dart'; @@ -32,7 +30,12 @@ class HomeMenu extends StatelessWidget { icon: const Text('遊ぶ'), expandedChildBuilder: (context) { return GameScreen( - dictionary: Dictionaries.of(context).japanese, + game: Game.startNew( + SingleplayerGameSettings( + dictionary: Dictionaries.of(context).japanese, + answeringDuration: const Duration(seconds: 10), + ), + ), ); }, ), @@ -124,7 +127,7 @@ class _PlayCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ DefaultTextStyle.merge( - style: textTheme.subtitle2, + style: textTheme.caption, child: subtitle, ), const SubtitleLine(), diff --git a/app/lib/utils/route_notifier.dart b/app/lib/utils/route_notifier.dart deleted file mode 100644 index 2f96092..0000000 --- a/app/lib/utils/route_notifier.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class RouteNotifier extends NavigatorObserver { - final _listeners = []; - - @override - void didPop(Route route, Route previousRoute) { - if (route is R || previousRoute is R) { - for (final listener in _listeners) { - listener.didPop?.call(route, previousRoute); - } - } - } - - @override - void didPush(Route route, Route previousRoute) { - if (route is R || previousRoute is R) { - for (final listener in _listeners) { - listener.didPush?.call(route, previousRoute); - } - } - } - - @override - void didRemove(Route route, Route previousRoute) { - if (route is R || previousRoute is R) { - for (final listener in _listeners) { - listener.didRemove?.call(route, previousRoute); - } - } - } - - @override - void didReplace({Route newRoute, Route oldRoute}) { - if (newRoute is R || oldRoute is R) { - for (final listener in _listeners) { - listener.didReplace?.call(newRoute: newRoute, oldRoute: oldRoute); - } - } - } -} - -mixin RouteNotifierStateMixin - on State { - RouteNotifierListener _listener; - - RouteNotifier get routeNotifier; - - @override - void initState() { - super.initState(); - - _listener = RouteNotifierListener( - didPop: didPop, - didPush: didPush, - didRemove: didRemove, - didReplace: didReplace, - ); - routeNotifier._listeners.add(_listener); - } - - @override - void dispose() { - if (_listener != null) { - routeNotifier._listeners.remove(_listener); - } - super.dispose(); - } - - void didPop(Route route, Route previousRoute); - void didPush(Route route, Route previousRoute); - void didRemove(Route route, Route previousRoute); - void didReplace({Route newRoute, Route oldRoute}); -} - -@immutable -class RouteNotifierListener { - const RouteNotifierListener({ - this.didPop, - this.didPush, - this.didRemove, - this.didReplace, - }); - - final void Function(Route route, Route previousRoute) didPop; - final void Function(Route route, Route previousRoute) didPush; - final void Function(Route route, Route previousRoute) didRemove; - final void Function({Route newRoute, Route oldRoute}) didReplace; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is RouteNotifierListener && - other.didPop == didPop && - other.didPush == didPush && - other.didRemove == didRemove && - other.didReplace == didReplace; - } - - @override - int get hashCode => - didPop.hashCode ^ - didPush.hashCode ^ - didRemove.hashCode & didReplace.hashCode; -} diff --git a/app/lib/utils/utils.dart b/app/lib/utils/utils.dart index 2538bf6..cd4fd2f 100644 --- a/app/lib/utils/utils.dart +++ b/app/lib/utils/utils.dart @@ -1,9 +1,8 @@ import 'dart:async'; +import 'dart:math'; import 'package:meta/meta.dart'; -export 'route_notifier.dart'; - // Typedefs typedef FromJson = T Function(Map json); typedef FutureValueChanged = FutureOr Function(T value); @@ -17,13 +16,18 @@ Mapper castDynamic() => (value) => value as T; // Classes class Tuple { - const Tuple(this.first, this.second); + const Tuple(this.left, this.right); - final T1 first; - final T2 second; + final T1 left; + final T2 right; } // Extensions +extension TupleListUtils on List> { + List get allLefts => map((t) => t.left).toList(); + List get allRights => map((t) => t.right).toList(); +} + extension NumUtils on num { int roundToNearest(int rounding) { return (this / rounding).round() * rounding; @@ -80,11 +84,7 @@ extension IterableUtils on Iterable { } } - T get random { - return (List.from(this)..shuffle()).first; - } - - Iterable safeWhere(bool Function(T element) test) { + Iterable whereOrEmpty(bool Function(T element) test) { try { return where(test); // ignore: avoid_catching_errors @@ -93,6 +93,10 @@ extension IterableUtils on Iterable { } } + T firstWhereOrNull(bool Function(T element) test) { + return firstWhere(test, orElse: () => null); + } + Iterable intersperse(T element) sync* { final iterator = this.iterator; if (iterator.moveNext()) { @@ -113,9 +117,41 @@ extension ListUtils on List { return null; } + + T get random { + return elementAt(Random().nextInt(length)); + } } extension StringUtils on String { + /// Divides this [String] into two parts. + /// + /// The first part contains all the characters string from index `0` until + /// [index] (exclusively). The seconds part contains the remaining characters. + /// + /// This means an [index] of `0` will return a [Tuple] with an empty string + /// on the `left` and the entire string on the `right`. + /// An [index] of the length of this [String] will return a [Tuple] with the + /// entire string on the `left` and an empty string on the `right`. + Tuple divide(int index) { + assert(index >= 0 && index <= length); + + return Tuple( + chars.sublist(0, index).join(), + chars.sublist(index).join(), + ); + } + + /// Divides this [String] into two parts with the entire string except for the + /// last character on the `left` and the last character on the `right`. + Tuple chipOffLast() { + return divide(length - 1); + } + + List get chars => split(''); + + String get reversed => chars.reversed.join(); + bool containsAny(Iterable candidates) { if (candidates.isEmpty) { return false; diff --git a/app/pubspec.lock b/app/pubspec.lock index 12a4f03..86cd090 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -36,6 +36,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.4.1" + avatar_glow: + dependency: "direct main" + description: + name: avatar_glow + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" boolean_selector: dependency: transitive description: @@ -184,6 +191,15 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + kana: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: bfd94ccc1b1881e16d1fe576e4052883567e3020 + url: "git://github.com/jeroen-meijer/kana.git" + source: git + version: "1.0.0" matcher: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 75213c7..086ea6f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -15,13 +15,17 @@ dependencies: sdk: flutter shared_models: path: ../shared_models - animations: ^1.1.1 + kana: + git: + url: git://github.com/jeroen-meijer/kana.git + animations: 1.1.1 + avatar_glow: 1.2.0 equatable: 1.2.2 flutter_svg: 0.17.4 font_awesome_flutter: 8.8.1 intl: 0.16.1 meta: 1.1.8 - provider: ^4.3.1 + provider: 4.3.1 dev_dependencies: flutter_test: diff --git a/shared_models/lib/src/dictionary.dart b/shared_models/lib/src/dictionary.dart index 406ad86..895aed1 100644 --- a/shared_models/lib/src/dictionary.dart +++ b/shared_models/lib/src/dictionary.dart @@ -52,7 +52,7 @@ class Dictionary extends Equatable { final List entries; Set searchWord(String query) { - log('SEARCH'); + log('SEARCH "$query"'); final queryIndicies = indicies[query]; if (queryIndicies == null) { return {}; diff --git a/shared_models/lib/src/word_entry.dart b/shared_models/lib/src/word_entry.dart index 04de717..9a5c0ee 100644 --- a/shared_models/lib/src/word_entry.dart +++ b/shared_models/lib/src/word_entry.dart @@ -19,6 +19,11 @@ class WordEntry extends Equatable { final List phoneticSpellings; final List definitions; + List get allSpellings => [ + ...spellings, + ...phoneticSpellings, + ]; + factory WordEntry.fromJson(Map json) => _$WordEntryFromJson(json); Map toJson() => _$WordEntryToJson(this);