[current results ui] Each test is a single row before expanding

Change-Id: I4bec1e3a46952e045e8c19543170575e0030a25c
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/159622
Reviewed-by: Alexander Thomas <athom@google.com>
Reviewed-by: Karl Klose <karlklose@google.com>
diff --git a/ui_current_results/lib/main.dart b/ui_current_results/lib/main.dart
index 5efcdd4..c03a6e8 100644
--- a/ui_current_results/lib/main.dart
+++ b/ui_current_results/lib/main.dart
@@ -72,6 +72,7 @@
             ),
           ),
           persistentFooterButtons: [
+            ApiPortalLink(),
             JsonLink(),
             textPopup(),
           ],
@@ -97,10 +98,30 @@
   }
 }
 
+class ApiPortalLink extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return Consumer<Filter>(
+      builder: (context, Filter filter, child) {
+        return FlatButton(
+          child: Text('API portal'),
+          onPressed: () => html.window.open(
+              'https://endpointsportal.dart-ci-staging.cloud.goog'
+                  '/docs/current-results-rest-zlujsyuhha-uc.a.run.app/g'
+                  '/routes/v1/results/get',
+              '_blank'),
+        );
+      },
+    );
+  }
+}
+
 class JsonLink extends StatelessWidget {
   @override
-  Widget build(BuildContext context) => Consumer<Filter>(
-        builder: (context, filter, child) => FlatButton(
+  Widget build(BuildContext context) {
+    return Consumer<Filter>(
+      builder: (context, filter, child) {
+        return FlatButton(
           child: Text('json'),
           onPressed: () => html.window.open(
               Uri.https(apiHost, 'v1/results', {
@@ -108,8 +129,10 @@
                 'pageSize': '4000'
               }).toString(),
               '_blank'),
-        ),
-      );
+        );
+      },
+    );
+  }
 }
 
 Widget textPopup() {
diff --git a/ui_current_results/lib/query.dart b/ui_current_results/lib/query.dart
index 8e47075..9270fc9 100644
--- a/ui_current_results/lib/query.dart
+++ b/ui_current_results/lib/query.dart
@@ -2,7 +2,6 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
-import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:http/http.dart' as http;
 import 'dart:convert';
@@ -21,6 +20,7 @@
   Filter filter;
   bool showAll = true;
   List<String> names = [];
+  Map<String, Map<String, int>> counts = {};
   Map<String, Map<ChangeInResult, List<Result>>> grouped = {};
   bool partialResults = true;
 
@@ -35,9 +35,19 @@
       ..mergeFromProto3Json(json.decode(resultsResponse.body));
     final results = resultsObject.results;
 
-    grouped = groupBy<Result, String>(results, (Result result) => result.name)
-        .map((String name, List<Result> list) => MapEntry(name,
-            groupBy<Result, ChangeInResult>(list, ChangeInResult.fromResult)));
+    for (final result in results) {
+      grouped
+          .putIfAbsent(result.name, () => <ChangeInResult, List<Result>>{})
+          .putIfAbsent(ChangeInResult(result), () => <Result>[])
+          .add(result);
+    }
+    for (final name in grouped.keys) {
+      final count = counts[name] = <String, int>{};
+      for (final change in grouped[name].keys) {
+        count.putIfAbsent(change.kind, () => 0);
+        count[change.kind] += grouped[name][change].length;
+      }
+    }
     names = grouped.keys.toList()..sort();
     partialResults = results.length == fetchLimit;
     notifyListeners();
@@ -45,25 +55,27 @@
 }
 
 class ChangeInResult {
-  static const Color lightCoral = Color.fromARGB(255, 240, 128, 128);
-  static const Color gold = Color.fromARGB(255, 255, 215, 0);
-
-  String result;
-  String expected;
-  bool flaky;
+  final String result;
+  final String expected;
+  final bool flaky;
+  final String text;
   bool get matches => result == expected;
-  Color get backgroundColor =>
-      flaky ? gold : matches ? Colors.lightGreen : lightCoral;
+  String get kind => flaky ? 'flaky' : matches ? 'pass' : 'fail';
 
-  ChangeInResult._(this.result, this.expected, this.flaky);
-  static ChangeInResult fromResult(Result result) =>
-      ChangeInResult._(result.result, result.expected, result.flaky);
+  ChangeInResult(Result result)
+      : this._(result.result, result.expected, result.flaky);
 
-  String toString() => flaky
-      ? "flaky (latest result $result expected $expected"
-      : "$result (expected $expected)";
-  bool operator ==(Object other) => toString() == other.toString();
-  int get hashCode => toString().hashCode;
+  ChangeInResult._(this.result, this.expected, this.flaky)
+      : text = flaky
+            ? "flaky (latest result $result expected $expected"
+            : "$result (expected $expected)";
+
+  @override
+  String toString() => text;
+  @override
+  bool operator ==(Object other) => text == (other as ChangeInResult)?.text;
+  @override
+  int get hashCode => text.hashCode;
 }
 
 String resultAsCommaSeparated(Result result) => [
diff --git a/ui_current_results/lib/results.dart b/ui_current_results/lib/results.dart
index 6c2fa13..0ead7a5 100644
--- a/ui_current_results/lib/results.dart
+++ b/ui_current_results/lib/results.dart
@@ -5,26 +5,46 @@
 import 'dart:html' as html;
 
 import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
 
 import 'query.dart';
 
-class ResultsPanel extends StatelessWidget {
+class ResultsPanel extends StatefulWidget {
   final QueryResults queryResults;
   final bool showAll;
 
   ResultsPanel(this.queryResults, {this.showAll = true});
 
   @override
+  ResultsPanelState createState() => ResultsPanelState();
+}
+
+class ResultsPanelState extends State<ResultsPanel> {
+  static const Color lightCoral = Color.fromARGB(255, 240, 128, 128);
+  static const Color gold = Color.fromARGB(255, 255, 215, 0);
+  static const resultColors = {
+    'pass': Colors.lightGreen,
+    'flaky': gold,
+    'fail': lightCoral,
+  };
+
+  static const kinds = ['pass', 'fail', 'flaky'];
+  List<bool> expanded = [];
+
+  @override
   Widget build(BuildContext context) {
+    if (expanded.length != widget.queryResults.names.length) {
+      expanded = List<bool>.filled(widget.queryResults.names.length, false);
+    }
     return Align(
       alignment: Alignment.topLeft,
       child: SizedBox(
         width: 800.0,
         child: Container(
-          decoration: BoxDecoration(border: Border.all(color: Colors.black)),
+          // decoration: BoxDecoration(border: Border.all(color: Colors.black)),
           child: ListView.builder(
-            itemCount: queryResults.names.length,
-            itemBuilder: itemBuilder(queryResults),
+            itemCount: widget.queryResults.names.length,
+            itemBuilder: itemBuilder(widget.queryResults),
           ),
         ),
       ),
@@ -35,70 +55,83 @@
     return (BuildContext context, int index) {
       final name = results.names[index];
       final changeGroups = results.grouped[name];
-      if (!showAll && changeGroups.keys.every((change) => change.matches)) {
-        // Inserting this seems to break history icons on remaining tests.
+      final counts = results.counts[name];
+      if (!widget.showAll &&
+          changeGroups.keys.every((change) => change.matches)) {
         return Container(height: 0.0, width: 0.0);
       }
       return Column(
         crossAxisAlignment: CrossAxisAlignment.start,
         children: [
-          SizedOverflowBox(
-            size: Size(600.0, 18.0),
-            alignment: Alignment.topLeft,
-            child: Padding(
-              padding: EdgeInsets.only(top: 0.0, left: 8.0),
-              child: Row(
-                children: [
-                  Flexible(
-                    child: SingleChildScrollView(
-                      scrollDirection: Axis.horizontal,
-                      reverse: true,
-                      child: SelectableText(
-                        results.partialResults
-                            ? '$name (partial results)'
-                            : name,
-                        style: TextStyle(fontSize: 16.0),
-                      ),
+          Container(
+            height: 28.0,
+            padding: EdgeInsets.only(top: 0.0, left: 8.0),
+            child: Row(
+              crossAxisAlignment: CrossAxisAlignment.end,
+              children: [
+                IconButton(
+                  icon: Icon(
+                      expanded[index] ? Icons.expand_less : Icons.expand_more),
+                  onPressed: () =>
+                      setState(() => expanded[index] = !expanded[index]),
+                ),
+                for (final kind in kinds)
+                  Container(
+                    width: 20,
+                    alignment: Alignment.bottomCenter,
+                    color: counts.containsKey(kind)
+                        ? resultColors[kind]
+                        : Colors.white,
+                    child: Text('${counts[kind] ?? ''}',
+                        style: TextStyle(fontSize: 14.0)),
+                  ),
+                Flexible(
+                  child: SingleChildScrollView(
+                    scrollDirection: Axis.horizontal,
+                    reverse: true,
+                    child: SelectableText(
+                      results.partialResults ? '$name (partial results)' : name,
+                      style: TextStyle(fontSize: 16.0),
                     ),
                   ),
-                  IconButton(
+                ),
+                IconButton(
                     icon: Icon(Icons.history),
                     onPressed: () => html.window.open(
                         Uri(
                                 path: '/',
-                                fragment: showAll
+                                fragment: widget.showAll
                                     ? 'showLatestFailures=false&test=$name'
                                     : 'test=$name')
                             .toString(),
-                        '_blank'),
-                  ),
-                ],
-              ),
+                        '_blank')),
+              ],
             ),
           ),
-          for (final change
-              in changeGroups.keys.where((key) => showAll || !key.matches))
-            Container(
-              alignment: Alignment.topLeft,
-              constraints: BoxConstraints.loose(Size.fromWidth(500.0)),
-              child: ExpansionTile(
-                key: Key(name + change.toString()),
-                title: Text(
-                  '$change (${changeGroups[change].length} configurations)',
-                  style: TextStyle(
-                    backgroundColor: change.backgroundColor,
-                    fontSize: 14.0,
-                  ),
+          if (expanded[index])
+            for (final change in changeGroups.keys
+                .where((key) => widget.showAll || !key.matches))
+              Container(
+                alignment: Alignment.topLeft,
+                padding: EdgeInsets.only(left: 48.0),
+                constraints: BoxConstraints.loose(Size.fromWidth(500.0)),
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Container(
+                      padding: EdgeInsets.only(top: 12.0),
+                      child: Text(
+                          '$change (${changeGroups[change].length} configurations)',
+                          style: TextStyle(
+                              backgroundColor: resultColors[change.kind],
+                              fontSize: 16.0)),
+                    ),
+                    for (final result in changeGroups[change])
+                      SelectableText(result.configuration),
+                  ],
                 ),
-                expandedAlignment: Alignment.topLeft,
-                expandedCrossAxisAlignment: CrossAxisAlignment.start,
-                childrenPadding: EdgeInsets.only(left: 48.0, bottom: 4.0),
-                children: [
-                  for (final result in changeGroups[change])
-                    SelectableText(result.configuration),
-                ],
               ),
-            ),
+          if (expanded[index]) SizedBox(height: 12.0),
         ],
       );
     };