croupier: Change Board View again

This time, the Board View has a new view for Deal/Pass/Take.
It also shows much larger cards (on a tablet) for the Play phase.

Bundled changes (that can be unbundled):
- pass/take UI allowed dragging/tapping after passing (now disallowed)
- my sorted watch sequence was still race-y when it was applied, so
  now I manually loop. No more async foreach's...

Change-Id: If76356a074106fd2c756546f1340199de6cc57d5
diff --git a/lib/components/board.dart b/lib/components/board.dart
index 84ca7fc..27fd7a5 100644
--- a/lib/components/board.dart
+++ b/lib/components/board.dart
@@ -2,13 +2,15 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+import 'dart:math' as math;
+
 import 'package:flutter/material.dart';
+import 'package:vector_math/vector_math_64.dart' as vector_math;
 
 import '../logic/card.dart' as logic_card;
 import '../logic/croupier.dart' show Croupier;
-import '../logic/croupier_settings.dart' show CroupierSettings;
 import '../logic/game/game.dart' show Game, GameType, NoArgCb;
-import '../logic/hearts/hearts.dart' show HeartsGame;
+import '../logic/hearts/hearts.dart' show HeartsGame, HeartsPhase;
 import '../styles/common.dart' as style;
 import 'card.dart' as component_card;
 import 'card_collection.dart'
@@ -76,18 +78,21 @@
 
 class HeartsBoardState extends State<HeartsBoard> {
   Widget build(BuildContext context) {
-    double offscreenDelta = config.isMini ? 5.0 : 2.0;
+    double offscreenDelta = config.isMini ? 5.0 : 1.5;
+
+    Widget boardChild;
+    if (config.game.phase == HeartsPhase.Play) {
+      boardChild =
+          config.isMini ? _buildMiniBoardLayout() : _buildBoardLayout();
+    } else {
+      boardChild = _buildPassLayout();
+    }
 
     return new Container(
         height: config.height,
         width: config.width,
         child: new Stack([
-          new Positioned(
-              top: 0.0,
-              left: 0.0,
-              child: config.isMini
-                  ? _buildMiniBoardLayout()
-                  : _buildBoardLayout()),
+          new Positioned(top: 0.0, left: 0.0, child: boardChild),
           new Positioned(
               top: config.height * (offscreenDelta + 0.5),
               left: (config.width - config.cardWidth) / 2,
@@ -115,6 +120,109 @@
     return (i + config.game.playerNumber) % 4;
   }
 
+  static Map<int, String> passBackgrounds = const <int, String>{
+    0: "images/games/hearts/pass_right.png",
+    1: "images/games/hearts/pass_left.png",
+    2: "images/games/hearts/pass_across.png",
+    3: "",
+  };
+
+  Widget _buildPassLayout() {
+    String passBackground = ""; // It's possible to have no background.
+    if (config.game.phase == HeartsPhase.Pass ||
+        config.game.phase == HeartsPhase.Take) {
+      passBackground = passBackgrounds[config.game.roundNumber % 4];
+    }
+
+    return new Container(
+        height: config.height,
+        width: config.width,
+        child: new Stack([
+          new Positioned(
+              top: 0.0,
+              left: 0.0,
+              child: new AssetImage(
+                  name: passBackground,
+                  height: config.height,
+                  width: config.width)),
+          new Positioned(top: 0.0, left: 0.0, child: _buildPassLayoutInternal())
+        ]));
+  }
+
+  double _rotationAngle(int pNum) {
+    return pNum * math.PI / 2;
+  }
+
+  Widget _rotate(Widget w, int pNum) {
+    return new Transform(
+        child: w,
+        transform:
+            new vector_math.Matrix4.identity().rotateZ(_rotationAngle(pNum)),
+        alignment: new FractionalOffset(0.5, 0.5));
+  }
+
+  Widget _getPass(int playerNumber) {
+    double sizeRatio = 0.10;
+    double cccSize = math.min(sizeRatio * config.width, config.cardWidth * 3.5);
+
+    HeartsGame game = config.game;
+    List<logic_card.Card> cardsToTake = [];
+    int takeTarget = game.getTakeTarget(playerNumber);
+    if (takeTarget != null) {
+      cardsToTake = game.cardCollections[
+          game.getTakeTarget(playerNumber) + HeartsGame.OFFSET_PASS];
+    }
+
+    bool isHorz = playerNumber % 2 == 0;
+    CardCollectionOrientation ori = isHorz
+        ? CardCollectionOrientation.horz
+        : CardCollectionOrientation.vert;
+    return new CardCollectionComponent(cardsToTake, false, ori,
+        backgroundColor: style.transparentColor,
+        width: isHorz ? cccSize : null,
+        height: isHorz ? null : cccSize,
+        widthCard: config.cardWidth,
+        heightCard: config.cardHeight,
+        rotation: playerNumber * math.PI / 2,
+        useKeys: true);
+  }
+
+  Widget _getProfile(int pNum, double sizeFactor) {
+    return new CroupierProfileComponent(
+        settings: config.croupier.settingsFromPlayerNumber(pNum),
+        height: config.height * sizeFactor,
+        width: config.height * sizeFactor * 1.5);
+  }
+
+  Widget _playerProfile(int pNum, double sizeFactor) {
+    return _rotate(_getProfile(pNum, sizeFactor), pNum);
+  }
+
+  Widget _buildPassLayoutInternal() {
+    return new Container(
+        height: config.height,
+        width: config.width,
+        child: new Column([
+          new Flexible(child: _playerProfile(2, 0.2), flex: 0),
+          new Flexible(child: _getPass(2), flex: 0),
+          new Flexible(
+              child: new Row([
+                new Flexible(child: _playerProfile(1, 0.2), flex: 0),
+                new Flexible(child: _getPass(1), flex: 0),
+                new Flexible(child: new Block([]), flex: 1),
+                new Flexible(child: _getPass(3), flex: 0),
+                new Flexible(child: _playerProfile(3, 0.2), flex: 0)
+              ],
+                  alignItems: FlexAlignItems.center,
+                  justifyContent: FlexJustifyContent.spaceAround),
+              flex: 1),
+          new Flexible(child: _getPass(0), flex: 0),
+          new Flexible(child: _playerProfile(0, 0.2), flex: 0)
+        ],
+            alignItems: FlexAlignItems.center,
+            justifyContent: FlexJustifyContent.spaceAround));
+  }
+
   Widget _buildMiniBoardLayout() {
     return new Container(
         height: config.height,
@@ -190,91 +298,43 @@
         child: new Stack(items));
   }
 
+  Widget _showTrickText(int pNum) {
+    HeartsGame game = config.game;
+
+    int numTrickCards =
+        game.cardCollections[HeartsGame.OFFSET_TRICK + pNum].length;
+    int numTricks = numTrickCards ~/ 4;
+
+    String s = numTricks != 1 ? "s" : "";
+
+    return _rotate(new Text("${numTricks} trick${s}"), pNum);
+  }
+
   Widget _buildBoardLayout() {
     return new Container(
         height: config.height,
         width: config.width,
         child: new Column([
-          new Flexible(child: _buildPlayer(2), flex: 5),
+          new Flexible(child: _playerProfile(2, 0.2), flex: 0),
+          new Flexible(child: _showTrickText(2), flex: 0),
           new Flexible(
               child: new Row([
-                new Flexible(child: _buildPlayer(1), flex: 3),
-                new Flexible(child: _buildCenterCards(), flex: 4),
-                new Flexible(child: _buildPlayer(3), flex: 3)
+                new Flexible(child: _playerProfile(1, 0.2), flex: 0),
+                new Flexible(child: _showTrickText(1), flex: 0),
+                new Flexible(child: _buildCenterCards(), flex: 1),
+                new Flexible(child: _showTrickText(3), flex: 0),
+                new Flexible(child: _playerProfile(3, 0.2), flex: 0)
               ],
                   alignItems: FlexAlignItems.center,
                   justifyContent: FlexJustifyContent.spaceAround),
-              flex: 9),
-          new Flexible(child: _buildPlayer(0), flex: 5)
+              flex: 1),
+          new Flexible(child: _showTrickText(0), flex: 0),
+          new Flexible(child: _playerProfile(0, 0.2), flex: 0)
         ],
             alignItems: FlexAlignItems.center,
             justifyContent: FlexJustifyContent.spaceAround));
   }
 
-  Widget _buildPlayer(int playerNumber) {
-    bool wide = (config.width >= config.height);
-
-    List<Widget> widgets = [
-      _getProfile(playerNumber, wide),
-      _getHand(playerNumber),
-      _getPass(playerNumber)
-    ];
-
-    if (playerNumber % 2 == 0) {
-      return new Row(widgets,
-          alignItems: FlexAlignItems.center,
-          justifyContent: FlexJustifyContent.center);
-    } else {
-      return new Column(widgets,
-          alignItems: FlexAlignItems.center,
-          justifyContent: FlexJustifyContent.center);
-    }
-  }
-
-  Widget _getProfile(int playerNumber, bool isWide) {
-    bool isMini = isWide && config.cardHeight * 2 > config.height * 0.25;
-
-    // If cs is null, a placeholder is used instead.
-    CroupierSettings cs =
-        config.croupier.settingsFromPlayerNumber(playerNumber);
-    return new CroupierProfileComponent(
-        settings: cs, height: config.height * 0.15, isMini: isMini);
-  }
-
-  Widget _getHand(int playerNumber) {
-    double sizeRatio = 0.30;
-    double cccSize = sizeRatio * config.width;
-
-    return new CardCollectionComponent(
-        config.game.cardCollections[playerNumber + HeartsGame.OFFSET_HAND],
-        false,
-        CardCollectionOrientation.horz,
-        width: cccSize,
-        widthCard: config.cardWidth,
-        heightCard: config.cardHeight,
-        useKeys: true);
-  }
-
-  Widget _getPass(int playerNumber) {
-    double sizeRatio = 0.10;
-    double cccSize = sizeRatio * config.width;
-
-    HeartsGame game = config.game;
-    List<logic_card.Card> cardsToTake = [];
-    int takeTarget = game.getTakeTarget(playerNumber);
-    if (takeTarget != null) {
-      cardsToTake = game.cardCollections[
-          game.getTakeTarget(playerNumber) + HeartsGame.OFFSET_PASS];
-    }
-    return new CardCollectionComponent(
-        cardsToTake, false, CardCollectionOrientation.horz,
-        backgroundColor: Colors.grey[300],
-        width: cccSize,
-        widthCard: config.cardWidth / 2,
-        heightCard: config.cardHeight / 2,
-        useKeys: true);
-  }
-
   Widget _buildCenterCards() {
     bool wide = (config.width >= config.height);
 
@@ -301,40 +361,62 @@
     }
   }
 
+  double get _centerScaleFactor {
+    return math.min(config.height * 0.6 / (config.cardHeight * 3),
+        config.width - config.height * 0.4 / (config.cardWidth * 3));
+  }
+
   Widget _buildCenterCard(int playerNumber) {
     HeartsGame game = config.game;
     List<logic_card.Card> cards =
         game.cardCollections[playerNumber + HeartsGame.OFFSET_PLAY];
 
-    return new Container(
-        decoration: game.whoseTurn == playerNumber ? style.Box.liveNow : null,
-        child: new CardCollectionComponent(
-            cards, true, CardCollectionOrientation.show1,
-            widthCard: config.cardWidth * 2,
-            heightCard: config.cardHeight * 2,
-            useKeys: true));
+    bool hasPlayed = cards.length > 0;
+    bool isTurn = game.whoseTurn == playerNumber && !hasPlayed;
+
+    return new CardCollectionComponent(
+        cards, true, CardCollectionOrientation.show1,
+        widthCard: config.cardWidth * this._centerScaleFactor,
+        heightCard: config.cardHeight * this._centerScaleFactor,
+        rotation: _rotationAngle(playerNumber),
+        useKeys: true,
+        backgroundColor: isTurn ? style.theme.accentColor : null);
   }
 
+  // The off-screen cards consist of trick cards and play cards.
+  // When the board is mini, the player's play cards are excluded.
   Widget _buildOffScreenCards(int playerNumber) {
     HeartsGame game = config.game;
 
     List<logic_card.Card> cards = new List.from(
         game.cardCollections[playerNumber + HeartsGame.OFFSET_TRICK]);
 
-    double sizeFactor = 2.0;
+    bool isPlay = game.phase == HeartsPhase.Play;
+
+    // Prevent over-expansion of cards until a card has been played.
+    bool alreadyPlaying =
+        (isPlay && (game.numPlayed > 0 || game.trickNumber > 0));
+
+    double sizeFactor = 1.0;
     if (config.isMini) {
-      sizeFactor = 1.0;
       if (playerNumber != game.playerNumber) {
         cards.addAll(
             game.cardCollections[playerNumber + HeartsGame.OFFSET_HAND]);
       }
+    } else {
+      cards.addAll(game.cardCollections[playerNumber + HeartsGame.OFFSET_HAND]);
+
+      if (alreadyPlaying) {
+        sizeFactor = this._centerScaleFactor;
+      }
     }
 
     return new CardCollectionComponent(
-        cards, true, CardCollectionOrientation.show1,
+        cards, isPlay, CardCollectionOrientation.show1,
         widthCard: config.cardWidth * sizeFactor,
         heightCard: config.cardHeight * sizeFactor,
         useKeys: true,
+        rotation: config.isMini ? null : _rotationAngle(playerNumber),
         animationType: component_card.CardAnimationType.LONG);
   }
 }
diff --git a/lib/components/card.dart b/lib/components/card.dart
index 628b40d..9a15f75 100644
--- a/lib/components/card.dart
+++ b/lib/components/card.dart
@@ -53,7 +53,7 @@
         faceUp = dataComponent.faceUp,
         width = dataComponent.width ?? 40.0,
         height = dataComponent.height ?? 40.0,
-        rotation = dataComponent.rotation,
+        rotation = dataComponent.rotation ?? 0.0,
         animationType = dataComponent.animationType,
         z = dataComponent.z;
 
@@ -75,7 +75,7 @@
   Card(logic_card.Card card, this.faceUp,
       {double width,
       double height,
-      this.rotation: 0.0,
+      double rotation,
       bool useKey: false,
       this.visible: true,
       CardAnimationType animationType,
@@ -85,6 +85,7 @@
         card = card,
         width = width ?? 40.0,
         height = height ?? 40.0,
+        rotation = rotation ?? 0.0,
         useKey = useKey,
         super(key: useKey ? new GlobalCardKey(card, CardUIType.CARD) : null);
 
diff --git a/lib/components/hearts/hearts.part.dart b/lib/components/hearts/hearts.part.dart
index 18a09bd..29fff21 100644
--- a/lib/components/hearts/hearts.part.dart
+++ b/lib/components/hearts/hearts.part.dart
@@ -336,8 +336,6 @@
     List<Widget> kids = new List<Widget>();
     switch (config.game.phase) {
       case HeartsPhase.Deal:
-        kids.add(new Text("Waiting for Deal..."));
-        break;
       case HeartsPhase.Pass:
       case HeartsPhase.Take:
       case HeartsPhase.Play:
@@ -450,6 +448,7 @@
               child: new GestureDetector(onTap: () {
                 setState(() {
                   game.takeTrickUI();
+                  game.debugString = null;
                 });
               },
                   child: new Container(
@@ -678,13 +677,14 @@
       List<logic_card.Card> hand,
       AcceptCb cb,
       NoArgCb buttoncb) {
-    bool draggable = (cb != null);
     bool completed = (buttoncb == null);
+    bool draggable = (cb != null) && !completed;
 
     List<Widget> topCardWidgets = new List<Widget>();
-    topCardWidgets.add(_topCardWidget(c1, cb));
-    topCardWidgets.add(_topCardWidget(c2, cb));
-    topCardWidgets.add(_topCardWidget(c3, cb));
+    AcceptCb topCb = completed ? null : cb;
+    topCardWidgets.add(_topCardWidget(c1, topCb));
+    topCardWidgets.add(_topCardWidget(c2, topCb));
+    topCardWidgets.add(_topCardWidget(c3, topCb));
     topCardWidgets.add(_makeButton(name, buttoncb, inactive: completed));
 
     Color bgColor = completed ? Colors.teal[600] : Colors.teal[500];
@@ -714,9 +714,9 @@
         comparator: _compareCards,
         width: config.width,
         acceptCallback: cb,
-        acceptType: cb != null ? DropType.card : null,
+        acceptType: draggable ? DropType.card : null,
         cardTapCallback:
-            cb != null ? (logic_card.Card c) => cb(c, emptyC) : null,
+            draggable ? (logic_card.Card c) => cb(c, emptyC) : null,
         backgroundColor: Colors.grey[500],
         altColor: Colors.grey[700],
         useKeys: true);
diff --git a/lib/logic/hearts/hearts_game.part.dart b/lib/logic/hearts/hearts_game.part.dart
index 1da768c..e4759ed 100644
--- a/lib/logic/hearts/hearts_game.part.dart
+++ b/lib/logic/hearts/hearts_game.part.dart
@@ -101,9 +101,9 @@
     switch (roundNumber % 4) {
       // is a 4-cycle
       case 0:
-        return (playerNumber - 1) % 4; // passLeft
+        return (playerNumber - 1) % 4; // passRight
       case 1:
-        return (playerNumber + 1) % 4; // passRight
+        return (playerNumber + 1) % 4; // passLeft
       case 2:
         return (playerNumber + 2) % 4; // passAcross
       case 3:
diff --git a/lib/src/syncbase/croupier_client.dart b/lib/src/syncbase/croupier_client.dart
index ca5feba..3746850 100644
--- a/lib/src/syncbase/croupier_client.dart
+++ b/lib/src/syncbase/croupier_client.dart
@@ -215,7 +215,7 @@
         }
 
         // 2. Then run through each value in order.
-        watchSequence.forEach((sc.WatchChange _w) async {
+        await Future.forEach(watchSequence, (sc.WatchChange _w) async {
           String key = _w.rowKey;
           String value;
           switch (_w.changeType) {
diff --git a/lib/styles/common.dart b/lib/styles/common.dart
index 5fec982..77caba5 100644
--- a/lib/styles/common.dart
+++ b/lib/styles/common.dart
@@ -52,5 +52,6 @@
 
 Color secondaryTextColor = Colors.grey[500];
 Color errorColor = Colors.red[500];
+Color transparentColor = const Color(0x00000000);
 ThemeData theme = new ThemeData(
     primarySwatch: Colors.blueGrey, accentColor: Colors.orangeAccent[700]);
diff --git a/manifest.yaml b/manifest.yaml
index 98c5ab0..cb3f666 100644
--- a/manifest.yaml
+++ b/manifest.yaml
@@ -249,6 +249,9 @@
   - images/avatars/android.png
   - images/avatars/man.png
   - images/avatars/woman.png
+  - images/games/hearts/pass_across.png
+  - images/games/hearts/pass_left.png
+  - images/games/hearts/pass_right.png
   - images/splash/background.png
   - images/splash/flutter.png
   - images/splash/vanadium.png