[story_shell_labs] Add deja layout and tiler presenter
TESTED=on device,deja_layout_test
Change-Id: Ie921b17f83b1992628f2721ed4f59e15f46cd772
diff --git a/packages/prod/BUILD.gn b/packages/prod/BUILD.gn
index 9822580..31b04cd 100644
--- a/packages/prod/BUILD.gn
+++ b/packages/prod/BUILD.gn
@@ -34,10 +34,10 @@
group("flutter") {
testonly = true
public_deps = [
- "//topaz/packages/prod:flutter_aot_product",
"//topaz/packages/prod:flutter_aot",
- "//topaz/packages/prod:flutter_jit_product",
+ "//topaz/packages/prod:flutter_aot_product",
"//topaz/packages/prod:flutter_jit",
+ "//topaz/packages/prod:flutter_jit_product",
]
}
@@ -55,7 +55,6 @@
"//topaz/packages/prod:dart_jit_runner",
"//topaz/packages/prod:dart_runner",
"//topaz/packages/prod:datetime_settings",
- "//topaz/packages/prod:story_shell_labs",
"//topaz/packages/prod:device_settings",
"//topaz/packages/prod:display_settings",
"//topaz/packages/prod:ermine",
@@ -68,6 +67,7 @@
"//topaz/packages/prod:mondrian",
"//topaz/packages/prod:settings",
"//topaz/packages/prod:skottie_viewer",
+ "//topaz/packages/prod:story_shell_labs",
"//topaz/packages/prod:system_dashboard",
"//topaz/packages/prod:term",
"//topaz/packages/prod:userpicker_base_shell",
@@ -152,8 +152,8 @@
group("userpicker_base_shell") {
testonly = true
public_deps = [
- "//topaz/packages/prod:flutter",
"//topaz/bin/userpicker_base_shell",
+ "//topaz/packages/prod:flutter",
]
}
@@ -167,10 +167,10 @@
group("dart_runner") {
testonly = true
public_deps = [
- "//topaz/packages/prod:dart_aot_runner",
"//topaz/packages/prod:dart_aot_product_runner",
- "//topaz/packages/prod:dart_jit_runner",
+ "//topaz/packages/prod:dart_aot_runner",
"//topaz/packages/prod:dart_jit_product_runner",
+ "//topaz/packages/prod:dart_jit_runner",
]
}
@@ -192,10 +192,10 @@
group("term") {
testonly = true
public_deps = [
- "//garnet/packages/prod:fonts",
"//garnet/bin/fonts:font_provider_tests",
- "//topaz/app/term",
+ "//garnet/packages/prod:fonts",
"//third_party/vulkan_loader_and_validation_layers/loader:vulkan_loader",
+ "//topaz/app/term",
]
}
@@ -246,12 +246,12 @@
group("xi") {
testonly = true
public_deps = [
- "//topaz/bin/xi/xi_mod",
- "//topaz/bin/xi/xi_embeddable",
- "//topaz/bin/xi/xi_session_agent",
- "//topaz/bin/xi/xi_session_demo",
"//garnet/bin/xi_core",
"//topaz/bin/xi",
+ "//topaz/bin/xi/xi_embeddable",
+ "//topaz/bin/xi/xi_mod",
+ "//topaz/bin/xi/xi_session_agent",
+ "//topaz/bin/xi/xi_session_demo",
]
}
@@ -259,12 +259,13 @@
testonly = true
public_deps = [
"//topaz/packages/prod:chromium",
- "//topaz/packages/prod:dart_jit_runner",
"//topaz/packages/prod:dart_jit_product_runner",
+ "//topaz/packages/prod:dart_jit_runner",
"//topaz/packages/prod:datetime_settings",
"//topaz/packages/prod:display_settings",
"//topaz/packages/prod:mondrian",
"//topaz/packages/prod:settings",
+ "//topaz/packages/prod:story_shell_labs",
"//topaz/packages/prod:userpicker_base_shell",
"//topaz/packages/prod:wifi_settings",
"//topaz/shell/ermine:ermine",
diff --git a/packages/tests/BUILD.gn b/packages/tests/BUILD.gn
index f942071..bf0d49a 100644
--- a/packages/tests/BUILD.gn
+++ b/packages/tests/BUILD.gn
@@ -28,6 +28,7 @@
"//topaz/public/dart/fuchsia_scenic_flutter:fuchsia_scenic_flutter_unittests($host_toolchain)",
"//topaz/public/dart/fuchsia_services:fuchsia_services_package_unittests($host_toolchain)",
"//topaz/public/dart/sledge:dart_sledge_tests($host_toolchain)",
+ "//topaz/public/dart/story_shell_labs:layout_unittests($host_toolchain)",
"//topaz/public/dart/widgets:dart_widget_tests($host_toolchain)",
"//topaz/public/lib/app/dart:dart_app_tests($host_toolchain)",
"//topaz/public/lib/display/flutter:display_test($host_toolchain)",
diff --git a/public/dart/story_shell_labs/.gitignore b/public/dart/story_shell_labs/.gitignore
new file mode 100644
index 0000000..49ce72d
--- /dev/null
+++ b/public/dart/story_shell_labs/.gitignore
@@ -0,0 +1,3 @@
+.dart_tool/
+.packages
+pubspec.lock
diff --git a/public/dart/story_shell_labs/BUILD.gn b/public/dart/story_shell_labs/BUILD.gn
new file mode 100644
index 0000000..b6c1144
--- /dev/null
+++ b/public/dart/story_shell_labs/BUILD.gn
@@ -0,0 +1,52 @@
+# Copyright 2019 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/dart/dart_library.gni")
+import("//topaz/runtime/dart/flutter_test.gni")
+
+dart_library("story_shell_labs_lib") {
+ package_name = "story_shell_labs_lib"
+
+ sources = [
+ "layout/deja_layout.dart",
+ "layout/tile_model.dart",
+ "layout/tile_presenter.dart",
+ "src/layout/deja_layout/deja_layout.dart",
+ "src/layout/deja_layout/layout_policy.dart",
+ "src/layout/deja_layout/layout_store.dart",
+ "src/layout/deja_layout/layout_utils.dart",
+ "src/layout/layout.dart",
+ "src/layout/presenter.dart",
+ "src/layout/tile_model/module_info.dart",
+ "src/layout/tile_model/module_info.g.dart",
+ "src/layout/tile_model/tile_layout_model.dart",
+ "src/layout/tile_model/tile_model_serializer.dart",
+ "src/layout/tile_presenter/layout_suggestions_update.dart",
+ "src/layout/tile_presenter/tile_presenter.dart",
+ "src/layout/tile_presenter/tile_presenter_suggestions_widget.dart",
+ "src/layout/tile_presenter/tile_presenter_widget.dart",
+ "src/layout/tile_presenter/widgets/drop_target_widget.dart",
+ "src/layout/tile_presenter/widgets/editing_tile_chrome.dart",
+ ]
+
+ deps = [
+ "//third_party/dart-pkg/git/flutter/packages/flutter",
+ "//third_party/dart-pkg/pub/built_collection",
+ "//third_party/dart-pkg/pub/json_annotation",
+ "//topaz/lib/tiler:tiler",
+ "//topaz/public/dart/fuchsia_scenic_flutter",
+ ]
+}
+
+flutter_test("layout_unittests") {
+ sources = [
+ "deja_layout_test.dart",
+ ]
+
+ deps = [
+ ":story_shell_labs_lib",
+ "//third_party/dart-pkg/pub/mockito",
+ "//third_party/dart-pkg/pub/test",
+ ]
+}
diff --git a/public/dart/story_shell_labs/OWNERS b/public/dart/story_shell_labs/OWNERS
new file mode 100644
index 0000000..c2d31a5
--- /dev/null
+++ b/public/dart/story_shell_labs/OWNERS
@@ -0,0 +1,3 @@
+ahetzroni@google.com
+miguelfrde@google.com
+schilit@google.com
diff --git a/public/dart/story_shell_labs/analysis_options.yaml b/public/dart/story_shell_labs/analysis_options.yaml
new file mode 100644
index 0000000..73da07f
--- /dev/null
+++ b/public/dart/story_shell_labs/analysis_options.yaml
@@ -0,0 +1,5 @@
+# Copyright 2019 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+include: ../analysis_options.yaml
diff --git a/public/dart/story_shell_labs/lib/layout/deja_layout.dart b/public/dart/story_shell_labs/lib/layout/deja_layout.dart
new file mode 100644
index 0000000..cb423c7
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/layout/deja_layout.dart
@@ -0,0 +1,7 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export '../src/layout/deja_layout/deja_layout.dart';
+export '../src/layout/deja_layout/layout_policy.dart';
+export '../src/layout/deja_layout/layout_store.dart';
diff --git a/public/dart/story_shell_labs/lib/layout/tile_model.dart b/public/dart/story_shell_labs/lib/layout/tile_model.dart
new file mode 100644
index 0000000..865c552
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/layout/tile_model.dart
@@ -0,0 +1,7 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export '../src/layout/tile_model/module_info.dart';
+export '../src/layout/tile_model/tile_layout_model.dart';
+export '../src/layout/tile_model/tile_model_serializer.dart';
diff --git a/public/dart/story_shell_labs/lib/layout/tile_presenter.dart b/public/dart/story_shell_labs/lib/layout/tile_presenter.dart
new file mode 100644
index 0000000..eb10654
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/layout/tile_presenter.dart
@@ -0,0 +1,7 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export '../src/layout/tile_presenter/tile_presenter.dart';
+export '../src/layout/tile_presenter/tile_presenter_suggestions_widget.dart';
+export '../src/layout/tile_presenter/tile_presenter_widget.dart';
diff --git a/public/dart/story_shell_labs/lib/src/layout/deja_layout/deja_layout.dart b/public/dart/story_shell_labs/lib/src/layout/deja_layout/deja_layout.dart
new file mode 100644
index 0000000..34c1aa6
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/deja_layout/deja_layout.dart
@@ -0,0 +1,112 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'package:built_collection/built_collection.dart';
+import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
+import 'package:tiler/tiler.dart';
+
+import '../layout.dart';
+import '../tile_model/module_info.dart';
+import '../tile_model/tile_layout_model.dart';
+import '../tile_presenter/tile_presenter.dart';
+import 'layout_policy.dart';
+import 'layout_store.dart';
+import 'layout_utils.dart';
+
+// The user has requested a different layout.
+typedef UserLayoutRequestCallback = void Function(TilerModel<ModuleInfo>);
+
+/// The layout strategy manages a model of the layout that is shared with the
+/// Presenter through the TileLayoutModel.
+class DejaLayout extends Layout<TileLayoutModel> {
+ var _tilerModel = TilerModel<ModuleInfo>();
+ List<TilerModel<ModuleInfo>> _tilerModelSuggestions = [];
+ final _connections = <String, ChildViewConnection>{};
+ final _layoutPolicy = LayoutPolicy(layoutStore: LayoutStore());
+
+ /// Tiling layout presenter
+ TilePresenter presenter;
+
+ /// Construct a Layout Manage that uses the Deja layout algorithm.
+ /// Deja stores past layouts and matches them for the
+ /// new layout when views are added or removed.
+ DejaLayout({
+ RemoveSurfaceCallback removeSurface,
+ FocusChangeCallback changeFocus,
+ }) : super(
+ removeSurface: removeSurface,
+ changeFocus: changeFocus,
+ ) {
+ presenter = TilePresenter(
+ removeSurfaceCallback: removeSurface,
+ changeFocusCallback: changeFocus,
+ requestLayoutCallback: _userLayoutRequest);
+ }
+
+ /// Called by Modular
+ @override
+ void addSurface({
+ String surfaceId,
+ String intent,
+ ChildViewConnection view,
+ UnmodifiableListView<String> parameters,
+ }) {
+ final content = ModuleInfo(
+ modName: surfaceId,
+ intent: intent,
+ parameters: parameters,
+ );
+
+ // Find the largest tile in the _tilerModel and have our content split
+ // and insert itself there.
+ splitLargestContent(_tilerModel, content);
+ final tilerModels = _layoutPolicy.getLayout(_tilerModel);
+ _tilerModel = tilerModels.first;
+ _tilerModelSuggestions = tilerModels.take(4).toList();
+ _connections[surfaceId] = view;
+ _onLayoutChange();
+ presenter.onSuggestionChange(_tilerModelSuggestions);
+ }
+
+ /// Called by Modular
+ @override
+ void deleteSurface(String surfaceId) {
+ final tile = findContent(_tilerModel, surfaceId);
+ if (tile != null) {
+ _tilerModel.remove(tile);
+ }
+ _connections.remove(surfaceId);
+ // Regenerate Layout Suggestions.
+ final tilerModels = _layoutPolicy.getLayout(_tilerModel);
+ _tilerModel = tilerModels.first;
+ _tilerModelSuggestions = tilerModels.take(4).toList();
+ _onLayoutChange();
+ presenter.onSuggestionChange(_tilerModelSuggestions);
+ }
+
+ /// A utility to send the layout to the presenter
+ void _onLayoutChange() {
+ presenter.onLayoutChange(TileLayoutModel(
+ model: _tilerModel, connections: BuiltMap(_connections)));
+ }
+
+ /// Presenter is asking that this new layout be made active. This follows some
+ /// user editing or re-arranging.
+ void _userLayoutRequest(TilerModel<ModuleInfo> model) {
+ final modsToRemove = getModsDifference(_tilerModel, model);
+ _tilerModel = model;
+ removeSurface(modsToRemove);
+ if (modsToRemove.isNotEmpty) {
+ // Regenerate Layout Suggestions.
+ final tilerModels = _layoutPolicy.getLayout(_tilerModel);
+ _tilerModel = tilerModels.first;
+ _tilerModelSuggestions = tilerModels.take(4).toList();
+ presenter.onSuggestionChange(_tilerModelSuggestions);
+ }
+ _onLayoutChange();
+ _layoutPolicy.write(_tilerModel);
+ }
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_policy.dart b/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_policy.dart
new file mode 100644
index 0000000..ee79dfa
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_policy.dart
@@ -0,0 +1,110 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:tiler/tiler.dart';
+
+import '../tile_model/module_info.dart';
+import 'layout_store.dart';
+import 'layout_utils.dart';
+
+const _kGroups = 4;
+
+/// Layout Policy for adding new content to a [TilerModel].
+///
+/// The strategy is to first find prior [TilerModel]s that have
+/// the same intents. Then group these candidates by equivalent layout
+/// geometry (ignoring flex). Proceed by taking the largest group and picking
+/// the most popular dimensional layout (looking at flex) within that group.
+///
+/// Second and third choice layouts are found in the smaller equivalent
+/// geometry groups.
+///
+/// If there are no prior examples with similar intents then the policy
+/// uses "split largest."
+class LayoutPolicy {
+ /// Storage for layout history data.
+ final LayoutStore layoutStore;
+
+ /// A tiling layout policy.
+ LayoutPolicy({this.layoutStore});
+
+ /// Returns new layouts suggestions for either add mod or remove mod.
+ List<TilerModel<ModuleInfo>> getLayout(TilerModel<ModuleInfo> a) {
+ var candidates = layoutStore.listSync();
+
+ // Collect the trees with same intents
+ candidates = _intentReduce(a, candidates);
+ print('Candidates intent count ${candidates.length}');
+ if (candidates.isEmpty) {
+ return [a];
+ }
+ // Group into lists of trees with the equivalent geometry
+ final gGroups =
+ _hashGroup(layoutFiles: candidates, includeFlex: false, n: _kGroups);
+ print('Geometry group ${gGroups.length}');
+ if (gGroups.isEmpty) {
+ return [a];
+ }
+ // For the top N geometry groups, find a single "popular" tree to return
+ final result = <TilerModel<ModuleInfo>>[];
+ for (int i = 0; i < min(gGroups.length, _kGroups); i++) {
+ // Group the trees having equivalent geometry and flex (trees are the same)
+ final fGroups =
+ _hashGroup(layoutFiles: gGroups[i], includeFlex: true, n: 1);
+ print('Flex group count ${fGroups.length}');
+ // THe result is the most popular layout with flex
+ result.add(layoutStore.read(fGroups.first.first));
+ }
+ // A list of trees, each with a unique geometry.
+ // Update the modName references and return.
+ for (final tileModel in result) {
+ updateModNames(a, tileModel);
+ }
+ return result;
+ }
+
+ /// Write a model to the storage.
+ void write(TilerModel a) {
+ layoutStore.write(a);
+ }
+
+ /// Reduce the candidate layouts to ones where the intents match.
+ List<File> _intentReduce(TilerModel<ModuleInfo> a, List<File> candidates) {
+ return candidates
+ .where((file) => compareIntents(a, layoutStore.read(file)))
+ .toList();
+ }
+
+ /// Return N largest hash equivalent groups among the layouts files.
+ ///
+ /// The input is a list of layout files, and the output is a
+ /// list of hash equivalent groups in length order.
+ ///
+ /// For example, can return the layout [File]s for the N most popular
+ /// geometrically equivalent layouts.
+ List<List<File>> _hashGroup(
+ {List<File> layoutFiles, bool includeFlex, int n}) {
+ if (layoutFiles.length <= 1) {
+ return [layoutFiles];
+ }
+ final map = HashMap<int, List<File>>();
+ // Accumulate the files for each group
+ for (final file in layoutFiles) {
+ int hashCode = treeHashCode(
+ model: layoutStore.read(file), includeFlex: includeFlex);
+ // Self initializing hash table idiom
+ final value = map[hashCode];
+ map[hashCode] = (value == null) ? [file] : value + [file];
+ }
+
+ // Sort by descending occurence count.
+ return map.values.toList()
+ ..sort((b, a) => a.length.compareTo(b.length))
+ ..take(n);
+ }
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_store.dart b/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_store.dart
new file mode 100644
index 0000000..26e5a21
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_store.dart
@@ -0,0 +1,84 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as path;
+import 'package:tiler/tiler.dart';
+
+import '../tile_model/module_info.dart';
+import '../tile_model/tile_model_serializer.dart' show fromJson, toJson;
+
+/// Persistent storage for layouts
+class LayoutStore {
+ /// Directory path where the data will be persisted.
+ final String directory;
+
+ /// Maximum amount of files to store.
+ final int size;
+
+ /// Constructor for layout storage.
+ LayoutStore({this.directory = '/data/layouts', this.size = 100});
+
+ /// Clears the storage.
+ ///
+ /// Synchronously deletes all the [File]s in the layout storage.
+ void deleteSync() {
+ try {
+ print('LayoutStore deleting $directory');
+ File(directory).deleteSync(recursive: true);
+ } on FileSystemException catch (e) {
+ print('LayoutStore Failed to delete $directory: $e');
+ }
+ }
+
+ /// Get a list of file names in the storage containg [TilerModel]s.
+ ///
+ /// Returns a List containing [File] objects.
+ List<File> listSync() {
+ try {
+ final result = Directory(directory)
+ .listSync()
+ .whereType<File>()
+ .cast<File>()
+ .toList();
+ print('listSync: ${result.length} ${result.map((f) => f.path)}');
+ return result;
+ } on FileSystemException catch (_) {
+ // No such file or directory
+ return [];
+ }
+ }
+
+ /// Write the [TilerModel] to persistent storage.
+ void write(TilerModel<ModuleInfo> a) {
+ if (a.root != null) {
+ String now = DateTime.now().toIso8601String();
+ File(path.join(directory, now))
+ ..createSync(recursive: true)
+ ..writeAsString(json.encode(toJson(a)));
+ _prune();
+ }
+ }
+
+ /// Read the [TilerModel] from persistent storage.
+ TilerModel<ModuleInfo> read(File f) {
+ String s = f.readAsStringSync();
+ return fromJson(jsonDecode(s));
+ }
+
+ // Prune old files from the storage.
+ void _prune() {
+ int _compare(File a, File b) =>
+ a.lastModifiedSync().compareTo(b.lastModifiedSync());
+
+ final files = listSync();
+ print('Pruning file(s): $files');
+ files.sort(_compare);
+ for (final file in files.skip(size)) {
+ file.deleteSync();
+ }
+ }
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_utils.dart b/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_utils.dart
new file mode 100644
index 0000000..cabe5ba
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/deja_layout/layout_utils.dart
@@ -0,0 +1,234 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'package:tiler/tiler.dart';
+import '../tile_model/module_info.dart';
+
+/// Utility for storing the size of a model.
+class TileModelSize {
+ /// The tiling model.
+ final TileModel tile;
+
+ /// Height ratio.
+ final double heightFlex;
+
+ /// Width ratio.
+ final double widthFlex;
+
+ /// Area of the til.
+ double get area => heightFlex * widthFlex;
+
+ /// Construtor
+ TileModelSize(this.tile, this.heightFlex, this.widthFlex);
+
+ @override
+ String toString() => '$tile, $widthFlex, $heightFlex';
+}
+
+/// Find the [TileMode] that is holding the content or null if none.
+TileModel findContent(TilerModel<ModuleInfo> a, String modName) {
+ TileModel _recurse(TileModel<ModuleInfo> t) {
+ if (t.type == TileType.content && t.content.modName == modName) {
+ return t;
+ } else {
+ for (var child in t.tiles) {
+ final t = _recurse(child);
+ if (t != null) {
+ return t;
+ }
+ }
+ }
+ return null;
+ }
+
+ return a.root != null ? _recurse(a.root) : null;
+}
+
+/// Find the largest tile by area in the tree
+TileModel findLargestContent(TilerModel a) {
+ TileModelSize _recurse(TileModelSize tms) {
+ final tile = tms.tile;
+ if (tile.type == TileType.content) {
+ return tms;
+ }
+ double maxArea = 0.0;
+ TileModelSize maxTms;
+ // flex values for children need to be normalized by flex of all children.
+ final sumFlex =
+ tile.tiles.fold<double>(0.0, (total, child) => total + child.flex);
+ for (final child in tile.tiles) {
+ TileModelSize childTms;
+ if (tile.type == TileType.row) {
+ childTms = TileModelSize(
+ child, tms.heightFlex, tms.widthFlex * child.flex / sumFlex);
+ } else if (tile.type == TileType.column) {
+ childTms = TileModelSize(
+ child, tms.heightFlex * child.flex / sumFlex, tms.widthFlex);
+ }
+ final maxChildTms = _recurse(childTms);
+ if (maxArea < maxChildTms.area) {
+ maxTms = maxChildTms;
+ maxArea = maxChildTms.area;
+ }
+ }
+ return maxTms;
+ }
+
+ if (a.root.isEmpty) {
+ return null;
+ }
+
+ final ts = _recurse(TileModelSize(a.root, 1.0, 1.0));
+ return ts?.tile;
+}
+
+/// Returns a new tile after splitting the largest [tile] in the tree.
+void splitLargestContent(TilerModel<ModuleInfo> a, ModuleInfo content) {
+ if (a.root.isEmpty) {
+ a.add(content: content);
+ } else {
+ final tile = findLargestContent(a);
+ print('splitLargestContent - $tile - $content');
+ a.split(tile: findLargestContent(a), content: content);
+ }
+ print('tiles are ${a.root}');
+}
+
+/// Returns a hashCode for the [TilerModel] with or without flex values
+///
+/// Note that dart does not include a hash combiner function so hashing of
+/// objects is achieved by converting to a [String] which
+/// has a content (not address) based [hashCode].
+int treeHashCode({TilerModel<ModuleInfo> model, bool includeFlex}) {
+ void _recurse(TileModel<ModuleInfo> tile, StringBuffer sb) {
+ // Hash the type
+ sb.write('t:${tile.type.index}');
+ if (includeFlex) {
+ sb.write('f:${tile.flex}');
+ }
+ if (tile.type == TileType.content) {
+ // do not hash the module name
+ sb.write('c:${tile.content.intent}');
+ } else {
+ sb.write('[');
+ for (final t in tile.tiles) {
+ _recurse(t, sb);
+ }
+ sb.write(']');
+ }
+ }
+
+ final strBuffer = StringBuffer();
+ _recurse(model.root, strBuffer);
+ // String uses content for the hash
+ return strBuffer.toString().hashCode;
+}
+
+/// Determine if two trees have the same geometry, ignoring flex.
+bool compareGeometry(TileModel<ModuleInfo> a, TileModel<ModuleInfo> b) {
+ if (a.type != b.type) {
+ return false;
+ } else if (a.type == TileType.content) {
+ return true;
+ } else {
+ if (a.tiles.length != b.tiles.length) {
+ return false;
+ }
+ for (int i = 0; i < a.tiles.length; i++) {
+ if (!compareGeometry(a.tiles[i], b.tiles[i])) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+/// Determine if two trees have the same geometry and flex.
+bool compareFlex(TilerModel<ModuleInfo> a, TilerModel<ModuleInfo> b) {
+ bool _recurse(TileModel<ModuleInfo> a, TileModel<ModuleInfo> b) {
+ if (a.type != b.type) {
+ return false;
+ }
+ if (a.type == TileType.content) {
+ return true; // possibly a.flex == b.flex
+ }
+ if (a.flex != b.flex || a.tiles.length != b.tiles.length) {
+ return false;
+ }
+ for (int i = 0; i < a.tiles.length; i++) {
+ if (!_recurse(a.tiles[i], b.tiles[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if (!compareGeometry(a.root, b.root)) {
+ return false;
+ }
+ return _recurse(a.root, b.root);
+}
+
+/// Determine if two trees have the same intents.
+bool compareIntents(TilerModel<ModuleInfo> a, TilerModel<ModuleInfo> b) {
+ final aIntents = _getIntents(a);
+ final bIntents = _getIntents(b);
+ if (aIntents.length != bIntents.length) {
+ return false;
+ }
+ aIntents.forEach(bIntents.remove);
+ return bIntents.isEmpty;
+}
+
+/// Gets the mods that have been removed from the old TileModel.
+/// In editing mode, mods cannot be added.
+Set<String> getModsDifference(
+ TilerModel<ModuleInfo> oldTree, TilerModel<ModuleInfo> newTree) =>
+ _getMods(oldTree).difference(_getMods(newTree));
+
+/// Update modNames [from] [to], using intent as key and updating modName
+void updateModNames(TilerModel<ModuleInfo> from, TilerModel<ModuleInfo> to) {
+ final toTiles = getTileContent(to);
+ final fromTiles = getTileContent(from);
+ for (final toTile in toTiles) {
+ final toIntent = toTile.content.intent;
+ final fromTile = fromTiles.firstWhere((t) => t.content.intent == toIntent);
+ fromTiles.remove(fromTile);
+ final modName = fromTile.content.modName;
+ final parameters = fromTile.content.parameters;
+ toTile.content = ModuleInfo(
+ intent: toIntent,
+ modName: modName,
+ parameters: parameters,
+ );
+ }
+}
+
+/// A co-routine to iterate over the TilerModel tree.
+Iterable<TileModel> tilerWalker(TilerModel a) sync* {
+ final nodes = ListQueue<TileModel>()..add(a.root);
+ while (nodes.isNotEmpty) {
+ nodes.addAll(nodes.first.tiles);
+ yield nodes.removeFirst();
+ }
+}
+
+/// Get the content nodes of a layout tree.
+List<TileModel<ModuleInfo>> getTileContent(TilerModel<ModuleInfo> a) =>
+ tilerWalker(a)
+ .where((t) => t.type == TileType.content)
+ .fold(<TileModel<ModuleInfo>>[], (l, t) => l..add(t));
+
+// Get the modNames for the mods in the layout tree aka TileModel.
+Set<String> _getMods(TilerModel<ModuleInfo> a) => tilerWalker(a)
+ .where((t) => t.type == TileType.content)
+ .fold(<String>{}, (s, t) => s..add(t.content.modName));
+
+// Get the intents in a layout tree as an ordered list.
+List<String> _getIntents(TilerModel<ModuleInfo> a) => tilerWalker(a)
+ .where((t) => t.type == TileType.content)
+ .fold(<String>[], (l, t) => l..add(t.content.intent))
+ ..sort();
diff --git a/public/dart/story_shell_labs/lib/src/layout/layout.dart b/public/dart/story_shell_labs/lib/src/layout/layout.dart
new file mode 100644
index 0000000..cb959bc
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/layout.dart
@@ -0,0 +1,42 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
+
+/// Allow presenters to request removal of surfaces.
+typedef RemoveSurfaceCallback = void Function(Iterable<String>);
+
+/// Allow presenters to notify changes on focused surfaces to modular for purposes
+/// of ranking in context and suggestions.
+typedef FocusChangeCallback = void Function(String, bool);
+
+/// The layout strategy manages a model of the layout that is shared with the
+/// Presenter through the LayoutModel.
+abstract class Layout<T> {
+ /// Called when a surface is removed
+ RemoveSurfaceCallback removeSurface;
+
+ /// Called when the focus of a surface changes.
+ FocusChangeCallback changeFocus;
+
+ /// Constructor for a layout strategy.
+ Layout({
+ this.removeSurface,
+ this.changeFocus,
+ });
+
+ /// These fields depend on the host environment. If this is used
+ /// outside of Fuchsia, change ChildViewConnection to flutter Widget.
+ void addSurface({
+ String surfaceId,
+ String intent,
+ ChildViewConnection view,
+ UnmodifiableListView<String> parameters,
+ });
+
+ /// Instructs to delete a surface.
+ void deleteSurface(String surfaceId);
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/presenter.dart b/public/dart/story_shell_labs/lib/src/layout/presenter.dart
new file mode 100644
index 0000000..031b2f5
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/presenter.dart
@@ -0,0 +1,32 @@
+// Depends on the implementation of the Layout and the Presenter.
+// Owned by the Presenter because multiple layouts can use the same
+// Presenter.
+
+import 'layout.dart';
+
+/// Renders the layout. Can be used by multiple strategies that use the same
+/// PresentationModel.
+abstract class Presenter<T> {
+ /// Called when a surface is removed.
+ RemoveSurfaceCallback removeSurfaceCallback;
+
+ /// Called when the focus changes.
+ FocusChangeCallback changeFocusCallback;
+
+ /// Constructor for a presenter.
+ Presenter({
+ this.removeSurfaceCallback,
+ this.changeFocusCallback,
+ });
+
+ /// Notify the presenter of a layout change.
+ void onLayoutChange(T layoutModer);
+
+ /// Instructs to remove a surface.
+ void removeSurface(Iterable<String> surfaces) =>
+ removeSurfaceCallback(surfaces);
+
+ /// Instructs to change the focus of a surface.
+ void changeFocus(String surface, {bool focus = false}) =>
+ changeFocusCallback(surface, focus);
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_model/module_info.dart b/public/dart/story_shell_labs/lib/src/layout/tile_model/module_info.dart
new file mode 100644
index 0000000..7f52e1d
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_model/module_info.dart
@@ -0,0 +1,51 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'package:json_annotation/json_annotation.dart';
+import 'package:meta/meta.dart';
+
+part 'module_info.g.dart';
+
+/// A class that stores information about a module.
+@JsonSerializable()
+class ModuleInfo {
+ /// The surface id.
+ final String modName;
+
+ /// The intent that triggered the creation of this mod.
+ final String intent;
+
+ /// List of parameter ids input to this mod.
+ @JsonKey(
+ fromJson: _unmodifiableListViewFromJson,
+ toJson: _unmodifiableListViewToJson)
+ final UnmodifiableListView<String> parameters;
+
+ /// Constructor for the module info information object.
+ ModuleInfo({
+ @required this.modName,
+ @required this.intent,
+ @required this.parameters,
+ });
+
+ static UnmodifiableListView<String> _unmodifiableListViewFromJson(
+ List<dynamic> parameters) =>
+ UnmodifiableListView<String>(parameters.cast<String>());
+
+ static List<String> _unmodifiableListViewToJson(
+ UnmodifiableListView<String> parameters) =>
+ parameters.toList();
+
+ @override
+ String toString() => 'modName: $modName, intent: $intent';
+
+ /// Load this model from a json object.
+ factory ModuleInfo.fromJson(Map<String, dynamic> json) =>
+ _$ModuleInfoFromJson(json);
+
+ /// Serialize this model as json
+ Map<String, dynamic> toJson() => _$ModuleInfoToJson(this);
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_model/module_info.g.dart b/public/dart/story_shell_labs/lib/src/layout/tile_model/module_info.g.dart
new file mode 100644
index 0000000..83a8bae
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_model/module_info.g.dart
@@ -0,0 +1,27 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: avoid_as
+
+part of 'module_info.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+ModuleInfo _$ModuleInfoFromJson(Map<String, dynamic> json) {
+ return ModuleInfo(
+ modName: json['modName'] as String,
+ intent: json['intent'] as String,
+ parameters: json['parameters'] == null
+ ? null
+ : ModuleInfo._unmodifiableListViewFromJson(
+ json['parameters'] as List));
+}
+
+Map<String, dynamic> _$ModuleInfoToJson(ModuleInfo instance) =>
+ <String, dynamic>{
+ 'modName': instance.modName,
+ 'intent': instance.intent,
+ 'parameters': instance.parameters == null
+ ? null
+ : ModuleInfo._unmodifiableListViewToJson(instance.parameters)
+ };
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_model/tile_layout_model.dart b/public/dart/story_shell_labs/lib/src/layout/tile_model/tile_layout_model.dart
new file mode 100644
index 0000000..9a84b0f
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_model/tile_layout_model.dart
@@ -0,0 +1,24 @@
+// Copyright 2019 The Fuchsia Authors. 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:fuchsia_scenic_flutter/child_view_connection.dart'
+ show ChildViewConnection;
+import 'package:built_collection/built_collection.dart';
+import 'package:meta/meta.dart';
+import 'package:tiler/tiler.dart';
+import 'module_info.dart';
+
+/// Depends on the implementation of the Layout and the Presenter.
+/// Declared by the Presenter because multiple layouts can use the same
+/// Presenter.
+class TileLayoutModel {
+ /// The tiling layout model.
+ final TilerModel<ModuleInfo> model;
+
+ /// Maps a surface id to its view.
+ final BuiltMap<String, ChildViewConnection> connections;
+
+ /// Constructor for a tiling layout model.
+ TileLayoutModel({@required this.model, @required this.connections});
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_model/tile_model_serializer.dart b/public/dart/story_shell_labs/lib/src/layout/tile_model/tile_model_serializer.dart
new file mode 100644
index 0000000..e59a811
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_model/tile_model_serializer.dart
@@ -0,0 +1,62 @@
+// Copyright 2019 The Fuchsia Authors. 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:tiler/tiler.dart';
+
+import 'module_info.dart';
+
+/// Convert the tiler model to json.
+Map<String, dynamic> toJson(TilerModel<ModuleInfo> model) => {
+ 'root': model.root == null ? null : _tileToJson(model.root),
+ };
+
+/// Parse the given JSON into a tiler model.
+TilerModel<ModuleInfo> fromJson(Map<String, dynamic> json) =>
+ TilerModel(root: _tileFromJson(json['root']));
+
+Map<String, dynamic> _tileToJson(TileModel model) => {
+ 'content': model.content?.toJson(),
+ 'type': model.type.index,
+ 'flex': model.flex,
+ 'tiles': model.tiles.map(_tileToJson).toList(),
+ };
+
+TileModel<ModuleInfo> _tileFromJson(
+ Map<String, dynamic> json, {
+ TileModel parent,
+}) {
+ return TileModel(
+ parent: parent,
+ type: TileType.values[json['type']],
+ content:
+ (json['content'] == null) ? null : ModuleInfo.fromJson(json['content']),
+ flex: json['flex'],
+ tiles: _listTileFromJson(json['tiles']),
+ );
+}
+
+List<TileModel<ModuleInfo>> _listTileFromJson(
+ List<dynamic> json, {
+ TileModel<ModuleInfo> parent,
+}) {
+ if (json != null) {
+ return json.map((data) => _tileFromJson(data, parent: parent)).toList();
+ }
+ return [];
+}
+
+/// Creates a copy of the given tiler model.
+TilerModel<ModuleInfo> cloneTiler(TilerModel<ModuleInfo> model) =>
+ TilerModel<ModuleInfo>(
+ root: _cloneTile(model.root),
+ );
+
+TileModel<ModuleInfo> _cloneTile(TileModel<ModuleInfo> model) => model == null
+ ? null
+ : TileModel(
+ content: model.content,
+ type: model.type,
+ flex: model.flex,
+ tiles: model.tiles.map(_cloneTile).toList(),
+ );
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_presenter/layout_suggestions_update.dart b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/layout_suggestions_update.dart
new file mode 100644
index 0000000..0a31812
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/layout_suggestions_update.dart
@@ -0,0 +1,18 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'package:meta/meta.dart';
+import 'package:tiler/tiler.dart';
+import '../tile_model/module_info.dart';
+
+/// Recommend alternative layouts
+class LayoutSuggestionUpdate {
+ /// List of suggestions for new [TilerModel]s
+ final UnmodifiableListView<TilerModel<ModuleInfo>> models;
+
+ /// Constructor for a layout suggestions update.
+ LayoutSuggestionUpdate({@required this.models});
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter.dart b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter.dart
new file mode 100644
index 0000000..598d891
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter.dart
@@ -0,0 +1,91 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:collection';
+
+import 'package:built_collection/built_collection.dart';
+import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
+import 'package:tiler/tiler.dart';
+
+import '../deja_layout/deja_layout.dart';
+import '../layout.dart';
+import '../presenter.dart';
+import '../tile_model/module_info.dart';
+import '../tile_model/tile_layout_model.dart';
+import 'layout_suggestions_update.dart';
+
+/// This class is part of a flutter-based presenter.
+/// It converts callbacks from [Layout] into broadcast streams which are
+/// easier for Flutter Widgets to work with.
+class TilePresenter extends Presenter<TileLayoutModel> {
+ /// Called when a layout is requested.
+ UserLayoutRequestCallback requestLayoutCallback;
+
+ TileLayoutModel _current = TileLayoutModel(
+ model: TilerModel<ModuleInfo>(),
+ connections: BuiltMap(<String, ChildViewConnection>{}));
+ List<TilerModel<ModuleInfo>> _suggestions = [];
+
+ // Stream controllers
+ final _layoutSuggestionController =
+ StreamController<LayoutSuggestionUpdate>.broadcast();
+ final _updateController = StreamController<TileLayoutModel>();
+
+ /// Streams the current layout.
+ Stream<TileLayoutModel> get update => _updateController.stream;
+
+ /// Get the current layout.
+ TileLayoutModel get currentState => _current;
+
+ /// Constructor for a tiling presenter.
+ TilePresenter({
+ RemoveSurfaceCallback removeSurfaceCallback,
+ FocusChangeCallback changeFocusCallback,
+ this.requestLayoutCallback,
+ }) : super(
+ removeSurfaceCallback: removeSurfaceCallback,
+ changeFocusCallback: changeFocusCallback);
+
+ /// Call when the presenter is no longer needed to close streams.
+ void dispose() {
+ _updateController.close();
+ }
+
+ /// Streams layout suggestion updates.
+ Stream<LayoutSuggestionUpdate> get suggestionsUpdate =>
+ _layoutSuggestionController.stream;
+
+ /// Get current layoutsuggestions.
+ LayoutSuggestionUpdate get currentSuggestionsState => LayoutSuggestionUpdate(
+ models: UnmodifiableListView(_suggestions),
+ );
+
+ @override
+ void onLayoutChange(TileLayoutModel layoutModel) {
+ // publish on stream for ease of use on Flutter side
+ _current = layoutModel;
+ _updateController.add(layoutModel);
+ }
+
+ /// Called with new layout suggestions.
+ void onSuggestionChange(Iterable<TilerModel<ModuleInfo>> models) {
+ _suggestions = models;
+ // publish on stream for ease of use on Flutter side
+ _layoutSuggestionController
+ .add(LayoutSuggestionUpdate(models: UnmodifiableListView(models)));
+ }
+
+ /// Request a layout.
+ void requestLayout(TilerModel<ModuleInfo> model) =>
+ requestLayoutCallback(model);
+
+ @override
+ void changeFocus(String modName, {bool focus = false}) =>
+ changeFocusCallback(modName, focus);
+
+ @override
+ void removeSurface(Iterable<String> surfaces) =>
+ removeSurfaceCallback(surfaces);
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter_suggestions_widget.dart b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter_suggestions_widget.dart
new file mode 100644
index 0000000..df7a06d
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter_suggestions_widget.dart
@@ -0,0 +1,87 @@
+// Copyright 2019 The Fuchsia Authors. 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:flutter/material.dart';
+import 'package:tiler/tiler.dart';
+
+import '../tile_model/module_info.dart';
+import 'layout_suggestions_update.dart';
+import 'tile_presenter.dart';
+
+/// Widget for displaying layout suggestions.
+@immutable
+class LayoutSuggestionsWidget extends StatelessWidget {
+ /// Presenter for a tile.
+ final TilePresenter presenter;
+
+ /// Returns border color for a given surface id.
+ final Color Function(String modName) colorForMod;
+
+ /// Called when a suggestion is selected.
+ final void Function(TilerModel) onSelect;
+
+ /// Constructor for a layout suggestions widget.
+ const LayoutSuggestionsWidget({
+ @required this.presenter,
+ @required this.onSelect,
+ @required this.colorForMod,
+ });
+
+ @override
+ Widget build(BuildContext context) => StreamBuilder<LayoutSuggestionUpdate>(
+ stream: presenter.suggestionsUpdate,
+ initialData: presenter.currentSuggestionsState,
+ builder: (context, snapshot) {
+ print('suggested models: ${snapshot.data.models}');
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: snapshot.data.models.map(_buildSuggestion).toList(),
+ );
+ },
+ );
+
+ Widget _buildSuggestion(TilerModel<ModuleInfo> model) {
+ return Padding(
+ padding: EdgeInsets.symmetric(horizontal: 16.0),
+ child: Material(
+ elevation: 24,
+ color: Colors.white,
+ child: AspectRatio(
+ aspectRatio: 4.0 / 3.0,
+ child: Stack(
+ children: <Widget>[
+ Container(
+ color: Color(0xFFFAFAFA),
+ margin: EdgeInsets.all(1.0),
+ padding: EdgeInsets.all(1.0),
+ child: Tiler(
+ sizerThickness: 0,
+ model: model,
+ chromeBuilder: (BuildContext context, TileModel tile) {
+ return Padding(
+ padding: EdgeInsets.all(1),
+ child: Container(
+ color: colorForMod(tile.content.modName),
+ ),
+ );
+ },
+ ),
+ ),
+ Positioned.fill(
+ child: Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: () {
+ onSelect(model);
+ },
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter_widget.dart b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter_widget.dart
new file mode 100644
index 0000000..ddcca40
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/tile_presenter_widget.dart
@@ -0,0 +1,188 @@
+// Copyright 2019 The Fuchsia Authors. 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:built_collection/built_collection.dart';
+import 'package:flutter/material.dart';
+import 'package:fuchsia_scenic_flutter/child_view.dart' show ChildView;
+import 'package:fuchsia_scenic_flutter/child_view_connection.dart'
+ show ChildViewConnection;
+import 'package:tiler/tiler.dart';
+
+import '../tile_model/module_info.dart';
+import 'widgets/drop_target_widget.dart';
+import 'widgets/editing_tile_chrome.dart';
+
+const _kSizerThickness = 24.0;
+const _kSizerHandleThickness = 4.0;
+
+final _kSizerHandleDecoration = BoxDecoration(
+ border: Border.all(color: Color(0xFFBDBDBD)),
+ borderRadius: BorderRadius.circular(2),
+);
+
+/// Tiling layout Layout presenter widget.
+@immutable
+class LayoutPresenter extends StatelessWidget {
+ /// The model being rendered.
+ final TilerModel tilerModel;
+
+ /// Whether edit mode is on or not.
+ final bool isEditing;
+
+ /// Maps a surface id to the view.
+ final BuiltMap<String, ChildViewConnection> connections;
+
+ /// Border color for the mod.
+ final Color Function(String modName) colorForMod;
+
+ /// Maps a parameter id to a color.
+ final Map<String, Color> parametersToColors;
+
+ /// Currently focused mod.
+ final ValueNotifier focusedMod;
+
+ /// Constructor for a tiling layout presenter.
+ const LayoutPresenter({
+ @required this.tilerModel,
+ @required this.isEditing,
+ @required this.connections,
+ @required this.colorForMod,
+ @required this.parametersToColors,
+ @required this.focusedMod,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final tiler = Tiler(
+ model: tilerModel,
+ sizerThickness: isEditing ? _kSizerThickness : 0,
+ sizerBuilder: isEditing ? _sizerBuilder : null,
+ chromeBuilder: _buildChrome,
+ );
+
+ if (!isEditing) {
+ return tiler;
+ }
+
+ return AnimatedBuilder(
+ animation: tilerModel,
+ builder: (context, child) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: <Widget>[
+ _addTarget(tileAfter: _getRoot, axis: Axis.horizontal),
+ Expanded(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: <Widget>[
+ _addTarget(tileAfter: _getRoot, axis: Axis.vertical),
+ child,
+ _addTarget(tileBefore: _getRoot, axis: Axis.vertical),
+ ],
+ ),
+ ),
+ _addTarget(tileBefore: _getRoot, axis: Axis.horizontal),
+ ],
+ );
+ },
+ child: Expanded(child: tiler),
+ );
+ }
+
+ TileModel _getRoot() => tilerModel.root;
+
+ Widget _sizerBuilder(
+ BuildContext context,
+ Axis axis,
+ TileModel tileBefore,
+ TileModel tileAfter,
+ ) =>
+ Stack(
+ children: <Widget>[
+ Container(
+ color: Colors.transparent,
+ // TODO(ahetzroni): wrap in AnimatedSize when variably sized sizers becomes available and adding drop targets
+ child: SizedBox(
+ width: axis == Axis.horizontal ? null : _kSizerThickness,
+ height: axis == Axis.vertical ? null : _kSizerThickness,
+ child: Center(
+ child: Container(
+ width: axis == Axis.horizontal
+ ? _kSizerThickness
+ : _kSizerHandleThickness,
+ height: axis == Axis.vertical
+ ? _kSizerThickness
+ : _kSizerHandleThickness,
+ decoration: _kSizerHandleDecoration,
+ ),
+ ),
+ ),
+ ),
+ Positioned.fill(
+ child: _addTarget(
+ tileBefore: () => tileBefore,
+ tileAfter: () => tileAfter,
+ axis: axis,
+ ),
+ ),
+ ],
+ );
+
+ Widget _buildChrome(BuildContext context, TileModel tile) {
+ ModuleInfo content = tile.content;
+ final modName = content.modName;
+ final connection = connections[modName];
+
+ if (!isEditing) {
+ return ChildView(connection: connection);
+ }
+
+ return LayoutBuilder(
+ builder: (context, constraints) => EditingTileChrome(
+ focusedMod: focusedMod,
+ borderColor: colorForMod(modName),
+ parameterColors:
+ content.parameters.map((p) => parametersToColors[p]),
+ tilerModel: tilerModel,
+ tile: tile,
+ modName: modName,
+ childView: ChildView(
+ focusable: false,
+ hitTestable: false,
+ connection: connection,
+ ),
+ editingSize: constraints.biggest,
+ originalSize: constraints.biggest +
+ Offset(_kSizerThickness, _kSizerThickness),
+ ));
+ }
+
+ Widget _addTarget({
+ TileModel Function() tileBefore,
+ TileModel Function() tileAfter,
+ Axis axis,
+ }) =>
+ DropTargetWidget(
+ onAccept: (tile) {
+ tilerModel
+ ..remove(tile)
+ ..add(
+ content: tile.content,
+ nearTile: (tileBefore ?? tileAfter)(),
+ direction: tileBefore != null
+ ? (axis == Axis.horizontal
+ ? AxisDirection.down
+ : AxisDirection.right)
+ : (axis == Axis.horizontal
+ ? AxisDirection.up
+ : AxisDirection.left),
+ );
+ },
+ onWillAccept: (tile) =>
+ tile != tileBefore?.call() && tile != tileAfter?.call(),
+ axis: axis == Axis.horizontal ? Axis.vertical : Axis.horizontal,
+ baseSize: _kSizerThickness,
+ hoverSize: _kSizerThickness,
+ );
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_presenter/widgets/drop_target_widget.dart b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/widgets/drop_target_widget.dart
new file mode 100644
index 0000000..09251fc
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/widgets/drop_target_widget.dart
@@ -0,0 +1,65 @@
+// Copyright 2019 The Fuchsia Authors. 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:flutter/material.dart';
+import 'package:tiler/tiler.dart';
+
+/// Widget for drag and drop target.
+class DropTargetWidget extends StatefulWidget {
+ /// COnstructor for the widget.
+ const DropTargetWidget({
+ @required this.onAccept,
+ @required this.onWillAccept,
+ @required this.axis,
+ @required this.baseSize,
+ @required this.hoverSize,
+ });
+
+ /// Axis
+ final Axis axis;
+
+ /// On drop accept callback
+ final DragTargetAccept<TileModel> onAccept;
+
+ /// On drop will accept callback
+ final DragTargetWillAccept<TileModel> onWillAccept;
+
+ /// Base size
+ final double baseSize;
+
+ /// Hover size
+ final double hoverSize;
+
+ @override
+ _DropTargetWidgetState createState() => _DropTargetWidgetState();
+}
+
+class _DropTargetWidgetState extends State<DropTargetWidget>
+ with SingleTickerProviderStateMixin {
+ @override
+ Widget build(BuildContext context) {
+ return DragTarget<TileModel>(
+ builder: (context, candidateData, rejectedData) {
+ final hovering = candidateData.isNotEmpty;
+ final size = hovering ? widget.hoverSize : widget.baseSize;
+ final color = hovering ? Colors.purple : Colors.transparent;
+ return AnimatedSize(
+ duration: Duration(milliseconds: 200),
+ curve: Curves.ease,
+ vsync: this,
+ child: Container(
+ width: widget.axis == Axis.horizontal ? size : null,
+ height: widget.axis == Axis.vertical ? size : null,
+ child: Material(
+ elevation: 8,
+ color: color,
+ ),
+ ),
+ );
+ },
+ onWillAccept: widget.onWillAccept,
+ onAccept: widget.onAccept,
+ );
+ }
+}
diff --git a/public/dart/story_shell_labs/lib/src/layout/tile_presenter/widgets/editing_tile_chrome.dart b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/widgets/editing_tile_chrome.dart
new file mode 100644
index 0000000..8bdcd11
--- /dev/null
+++ b/public/dart/story_shell_labs/lib/src/layout/tile_presenter/widgets/editing_tile_chrome.dart
@@ -0,0 +1,257 @@
+// Copyright 2019 The Fuchsia Authors. 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:flutter/material.dart';
+import 'package:tiler/tiler.dart';
+import 'drop_target_widget.dart';
+
+const _kHighlightedBorderWidth = 3.0;
+const _kBorderWidth = 1.0;
+const _kBorderWidthDiff = _kHighlightedBorderWidth - _kBorderWidth;
+
+const _kTilePlaceholderWhenDragging = DecoratedBox(
+ decoration: BoxDecoration(color: Color(0xFFFAFAFA)),
+);
+
+/// Chrome for a tiling layout presenter.
+class EditingTileChrome extends StatefulWidget {
+ /// Constructor for a tiling layout presenter.
+ const EditingTileChrome({
+ @required this.focusedMod,
+ @required this.borderColor,
+ @required this.parameterColors,
+ @required this.tilerModel,
+ @required this.tile,
+ @required this.childView,
+ @required this.modName,
+ @required this.originalSize,
+ @required this.editingSize,
+ });
+
+ /// Currently focused mod.
+ final ValueNotifier focusedMod;
+
+ /// Chrome border color.
+ final Color borderColor;
+
+ /// Intent parameter circle colors.
+ final Iterable<Color> parameterColors;
+
+ /// The model currently being displayed.
+ final TilerModel tilerModel;
+
+ /// The tile being showed on this chrome.
+ final TileModel tile;
+
+ /// Content of the chrome.
+ final Widget childView;
+
+ /// Surface id of the view displayed here.
+ final String modName;
+
+ /// Original size
+ final Size originalSize;
+
+ /// Eiditing size
+ final Size editingSize;
+
+ @override
+ _EditingTileChromeState createState() => _EditingTileChromeState();
+}
+
+class _EditingTileChromeState extends State<EditingTileChrome> {
+ final _isDragging = ValueNotifier(false);
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: AspectRatio(
+ aspectRatio:
+ (widget.originalSize + Offset(_kBorderWidth, _kBorderWidth) * 2.0)
+ .aspectRatio,
+ child: Stack(
+ children: <Widget>[
+ Positioned.fill(
+ child: Draggable(
+ onDragStarted: () {
+ widget.focusedMod.value = widget.modName;
+ _isDragging.value = true;
+ },
+ onDragEnd: (_) {
+ _isDragging.value = false;
+ },
+ key: Key(widget.modName),
+ data: widget.tile,
+ feedback: _buildFeedback(),
+ childWhenDragging: _kTilePlaceholderWhenDragging,
+ child: Container(
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: widget.borderColor,
+ width: _kBorderWidth,
+ ),
+ ),
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: FittedBox(
+ fit: BoxFit.contain,
+ child: SizedBox.fromSize(
+ size: widget.originalSize,
+ child: widget.childView,
+ ),
+ ),
+ )
+ ]..addAll(_buildSplitTargets(widget.editingSize)),
+ ),
+ ),
+ ),
+ ),
+ _buildCornerItems(),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildFeedback() {
+ // to get the offset between the draggable object and the feeback
+
+ // we need the difference between the border width of the original widget and the feedback
+ final borderWidthDifference =
+ Offset(-_kBorderWidthDiff, -_kBorderWidthDiff);
+ // and half the space lost to containing the original size within the editing size (the tile after reducing sizers)
+ final boxFitDifference = (widget.editingSize * .5 -
+ applyBoxFit(BoxFit.contain, widget.originalSize, widget.editingSize)
+ .destination *
+ 0.5);
+
+ return Transform.translate(
+ offset: borderWidthDifference - boxFitDifference,
+ child: SizedBox.fromSize(
+ size: widget.editingSize +
+ Offset(_kBorderWidthDiff, _kBorderWidthDiff) * 2,
+ child: Center(
+ child: AspectRatio(
+ // aspect ratio of original box plus highlighted border
+ aspectRatio: (widget.originalSize +
+ Offset(_kHighlightedBorderWidth, _kHighlightedBorderWidth) *
+ 2.0)
+ .aspectRatio,
+ child: Material(
+ elevation: 16.0,
+ child: Container(
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: widget.borderColor,
+ width: _kHighlightedBorderWidth,
+ ),
+ ),
+ child: FittedBox(
+ fit: BoxFit.contain,
+ child: SizedBox.fromSize(
+ size: widget.originalSize,
+ child: widget.childView,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildCornerItems() {
+ final parameterIndicators = Row(
+ children: widget.parameterColors
+ .expand((color) => [
+ Material(
+ elevation: 4.0,
+ clipBehavior: Clip.antiAlias,
+ shape: CircleBorder(),
+ color: color,
+ child: SizedBox(width: 24, height: 24),
+ ),
+ SizedBox(width: 8.0),
+ ])
+ .toList(),
+ );
+
+ return Positioned(
+ top: 8,
+ right: 8,
+ child: AnimatedBuilder(
+ animation: _isDragging,
+ builder: (_, child) =>
+ Offstage(offstage: _isDragging.value, child: child),
+ child: Row(
+ children: <Widget>[
+ parameterIndicators,
+ Material(
+ elevation: 4.0,
+ clipBehavior: Clip.antiAlias,
+ shape: CircleBorder(),
+ child: InkWell(
+ onTap: () {
+ widget.tilerModel.remove(widget.tile);
+ },
+ child: Icon(Icons.close),
+ ),
+ )
+ ],
+ ),
+ ),
+ );
+ }
+
+ List<Widget> _buildSplitTargets(Size size) => <Widget>[
+ _splitTarget(
+ nearTile: widget.tile,
+ direction: AxisDirection.up,
+ parentSizeOnAxis: size.height,
+ ),
+ _splitTarget(
+ nearTile: widget.tile,
+ direction: AxisDirection.down,
+ parentSizeOnAxis: size.height,
+ ),
+ _splitTarget(
+ nearTile: widget.tile,
+ direction: AxisDirection.left,
+ parentSizeOnAxis: size.width,
+ ),
+ _splitTarget(
+ nearTile: widget.tile,
+ direction: AxisDirection.right,
+ parentSizeOnAxis: size.width,
+ ),
+ ];
+
+ Widget _splitTarget({
+ TileModel nearTile,
+ AxisDirection direction,
+ double parentSizeOnAxis,
+ }) =>
+ Positioned(
+ top: direction == AxisDirection.down ? null : 0,
+ bottom: direction == AxisDirection.up ? null : 0,
+ left: direction == AxisDirection.right ? null : 0,
+ right: direction == AxisDirection.left ? null : 0,
+ child: DropTargetWidget(
+ onAccept: (tile) {
+ widget.tilerModel.remove(tile);
+ widget.tilerModel.split(
+ content: tile.content,
+ direction: direction,
+ tile: nearTile,
+ );
+ },
+ onWillAccept: (_) => true,
+ axis: axisDirectionToAxis(direction),
+ baseSize: 50.0,
+ hoverSize: parentSizeOnAxis * .33,
+ ),
+ );
+}
diff --git a/public/dart/story_shell_labs/pubspec.yaml b/public/dart/story_shell_labs/pubspec.yaml
new file mode 100644
index 0000000..754277a
--- /dev/null
+++ b/public/dart/story_shell_labs/pubspec.yaml
@@ -0,0 +1,15 @@
+# Copyright 2019 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+name: layout
+description: Library for story shell labs
+
+dependencies:
+ flutter:
+ sdk: flutter
+ json_annotation: ^2.0.0
+
+dev_dependencies:
+ json_serializable: ^2.0.0
+ build_runner: ^1.0.0
diff --git a/public/dart/story_shell_labs/test/deja_layout_test.dart b/public/dart/story_shell_labs/test/deja_layout_test.dart
new file mode 100644
index 0000000..61e2349
--- /dev/null
+++ b/public/dart/story_shell_labs/test/deja_layout_test.dart
@@ -0,0 +1,520 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:story_shell_labs_lib/layout/deja_layout.dart';
+import 'package:story_shell_labs_lib/layout/tile_model.dart';
+import 'package:meta/meta.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+import 'package:tiler/tiler.dart';
+
+class MockFile extends Mock implements File {}
+
+class MockLayoutStore extends Mock implements LayoutStore {}
+
+final _kViewBookContent = ModuleInfo(
+ modName: 'books_mod',
+ intent: 'VIEW_BOOK',
+ parameters: UnmodifiableListView<String>([]),
+);
+
+final _kViewCollectionContent = ModuleInfo(
+ modName: 'collections_mod',
+ intent: 'VIEW_COLLECTION',
+ parameters: UnmodifiableListView<String>([]),
+);
+
+final _kNonMatchingIntentContent = ModuleInfo(
+ modName: 'misc_mod',
+ intent: 'NON_MATCHING_INTENT',
+ parameters: UnmodifiableListView<String>([]),
+);
+
+MockFile _createLayoutFile(TilerModel<ModuleInfo> layout) {
+ final layoutFile = MockFile();
+
+ when(layoutFile.readAsString())
+ .thenAnswer((_) => Future.value(json.encode(toJson(layout))));
+ when(layoutFile.readAsStringSync())
+ .thenAnswer((_) => json.encode(toJson(layout)));
+
+ return layoutFile;
+}
+
+// *
+// / \
+// node1 node2
+TilerModel<ModuleInfo> _genLayout2Mods({
+ @required Map node1,
+ @required Map node2,
+}) {
+ return TilerModel<ModuleInfo>(
+ root: TileModel<ModuleInfo>(
+ type: TileType.column,
+ tiles: [
+ TileModel<ModuleInfo>(
+ flex: node1['flex'],
+ type: TileType.content,
+ content: node1['content'] ?? _kViewBookContent),
+ TileModel<ModuleInfo>(
+ flex: node2['flex'],
+ type: TileType.content,
+ content: node2['content'] ?? _kViewBookContent),
+ ],
+ ),
+ );
+}
+
+// *
+// / \
+// node1 *
+// / \
+// node2 node3
+TilerModel<ModuleInfo> _genLayout3ModsA({
+ @required Map node1,
+ @required Map node2,
+ @required Map node3,
+}) {
+ return TilerModel<ModuleInfo>(
+ root: TileModel<ModuleInfo>(
+ type: TileType.row,
+ tiles: [
+ TileModel<ModuleInfo>(
+ flex: node1['flex'],
+ type: TileType.content,
+ content: node1['content'] ?? _kViewCollectionContent),
+ TileModel<ModuleInfo>(
+ type: TileType.column,
+ tiles: [
+ TileModel<ModuleInfo>(
+ flex: node2['flex'],
+ type: TileType.content,
+ content: node2['content'] ?? _kViewCollectionContent),
+ TileModel<ModuleInfo>(
+ flex: node3['flex'],
+ type: TileType.content,
+ content: node3['content'] ?? _kViewBookContent),
+ ],
+ )
+ ],
+ ),
+ );
+}
+
+// *
+// / \
+// node1 *
+// / \
+// node2 node3
+TilerModel<ModuleInfo> _genLayout3ModsB({
+ @required Map node1,
+ @required Map node2,
+ @required Map node3,
+}) {
+ return TilerModel<ModuleInfo>(
+ root: TileModel<ModuleInfo>(
+ type: TileType.column,
+ tiles: [
+ TileModel<ModuleInfo>(
+ flex: node1['flex'],
+ type: TileType.content,
+ content: node1['content'] ?? _kViewCollectionContent),
+ TileModel<ModuleInfo>(
+ type: TileType.row,
+ tiles: [
+ TileModel<ModuleInfo>(
+ flex: node2['flex'],
+ type: TileType.content,
+ content: node2['content'] ?? _kViewCollectionContent),
+ TileModel<ModuleInfo>(
+ flex: node3['flex'],
+ type: TileType.content,
+ content: node3['content'] ?? _kViewBookContent),
+ ],
+ )
+ ],
+ ),
+ );
+}
+
+// *
+// / \
+// * node1
+// / \
+// node2 node3
+TilerModel<ModuleInfo> _genLayout3ModsC({
+ @required Map node1,
+ @required Map node2,
+ @required Map node3,
+}) {
+ return TilerModel<ModuleInfo>(
+ root: TileModel<ModuleInfo>(
+ type: TileType.row,
+ tiles: [
+ TileModel<ModuleInfo>(
+ type: TileType.column,
+ tiles: [
+ TileModel<ModuleInfo>(
+ flex: node2['flex'],
+ type: TileType.content,
+ content: node2['content'] ?? _kViewCollectionContent),
+ TileModel<ModuleInfo>(
+ flex: node3['flex'],
+ type: TileType.content,
+ content: node3['content'] ?? _kViewBookContent),
+ ],
+ ),
+ TileModel<ModuleInfo>(
+ flex: node1['flex'],
+ type: TileType.content,
+ content: node1['content'] ?? _kViewCollectionContent),
+ ],
+ ),
+ );
+}
+
+// *
+// / \
+// * node1
+// / \
+// node2 node3
+TilerModel<ModuleInfo> _genLayout3ModsD({
+ @required Map node1,
+ @required Map node2,
+ @required Map node3,
+}) {
+ return TilerModel<ModuleInfo>(
+ root: TileModel<ModuleInfo>(
+ type: TileType.column,
+ tiles: [
+ TileModel<ModuleInfo>(
+ type: TileType.row,
+ tiles: [
+ TileModel<ModuleInfo>(
+ flex: node2['flex'],
+ type: TileType.content,
+ content: node2['content'] ?? _kViewBookContent),
+ TileModel<ModuleInfo>(
+ flex: node3['flex'],
+ type: TileType.content,
+ content: node3['content'] ?? _kViewCollectionContent),
+ ],
+ ),
+ TileModel<ModuleInfo>(
+ flex: node1['flex'],
+ type: TileType.content,
+ content: node1['content'] ?? _kViewCollectionContent),
+ ],
+ ),
+ );
+}
+
+List<MockFile> _fillLayoutFiles(
+ List<int> fills, List<TilerModel<ModuleInfo>> layouts) {
+ assert(fills.length == layouts.length);
+
+ final layoutFiles = <MockFile>[];
+ for (int i = 0; i < fills.length; i++) {
+ layoutFiles
+ .addAll(List.filled(fills[i], layouts[i]).map(_createLayoutFile));
+ }
+
+ layoutFiles.shuffle();
+ return layoutFiles;
+}
+
+// Layout Suggestions are sorted descending by occurrence count.
+// Each layout suggestion is different geometric layout.
+//
+// Top N suggestions are determined by:
+// 1. Matching Intents
+// 2. Group by equivalent geometric layout.
+// Sort this Group by occurrence count.
+//. 3. In a geometric layout group. Further Group by equivalent flex
+// layouts. Sort this Group by occurrence count.
+//
+//
+// Additional Details: Geometric layout.
+// Flex is not a property that changes the geometric layout.
+//
+// A geometric layout could not be equivalent because of different layout tree
+// structure
+// * *
+// / \ / \
+// t * * t
+// / \ / \
+// t t t t
+//
+// Or a geometric layout could be different because of the Tile orientation
+// TileType.row, TileType.column. But the tree layout structure could be
+// the same.
+//
+// * (TileType.row) * (TileType.column)
+// / \ / \
+// t * (TileType.column) t * (TileType.row)
+// / \ / \
+// t t t t
+//
+void main() {
+ MockLayoutStore mockLayoutStore;
+
+ setUp(() {
+ mockLayoutStore = MockLayoutStore();
+ when(mockLayoutStore.read(any)).thenAnswer((Invocation i) {
+ final MockFile f = i.positionalArguments[0];
+ final s = f.readAsStringSync();
+ return fromJson(jsonDecode(s));
+ });
+ });
+
+ test(
+ 'Expect current layout as the only suggestion'
+ ' if no stored matching geometric layouts.', () {
+ final layouts = [
+ _genLayout3ModsA(
+ node1: {'flex': 0.5},
+ node2: {'flex': 0.5},
+ node3: {'flex': 0.5},
+ ), // appears 3 times
+ _genLayout3ModsA(
+ node1: {'flex': 0.2},
+ node2: {'flex': 0.4},
+ node3: {'flex': 0.6},
+ ), // appears 2 times
+ ];
+
+ final layoutFiles = _fillLayoutFiles([3, 2], layouts);
+ when(mockLayoutStore.listSync()).thenReturn(layoutFiles);
+ final layoutPolicy = LayoutPolicy(layoutStore: mockLayoutStore);
+
+ final currentLayout = _genLayout2Mods(
+ node1: {'flex': 0.5},
+ node2: {'flex': 0.5},
+ );
+
+ final tilerModelsSuggestions = layoutPolicy.getLayout(currentLayout);
+ expect(tilerModelsSuggestions.length, 1);
+ expect(tilerModelsSuggestions[0], currentLayout);
+ });
+
+ test(
+ 'Expect current layout as the only suggestion'
+ ' if no stored layouts with matching intents.', () {
+ final layouts = [
+ _genLayout3ModsA(
+ node1: {'flex': 0.5, 'content': _kNonMatchingIntentContent},
+ node2: {'flex': 0.5, 'content': _kNonMatchingIntentContent},
+ node3: {'flex': 0.5, 'content': _kNonMatchingIntentContent},
+ ), // appears 3 times
+ _genLayout3ModsA(
+ node1: {'flex': 0.2, 'content': _kNonMatchingIntentContent},
+ node2: {'flex': 0.4, 'content': _kNonMatchingIntentContent},
+ node3: {'flex': 0.6, 'content': _kNonMatchingIntentContent},
+ ), // appears 2 times
+ ];
+
+ final layoutFiles = _fillLayoutFiles([3, 2], layouts);
+ when(mockLayoutStore.listSync()).thenReturn(layoutFiles);
+ final layoutPolicy = LayoutPolicy(layoutStore: mockLayoutStore);
+
+ final currentLayout = _genLayout3ModsB(
+ node1: {'flex': 0.5},
+ node2: {'flex': 0.5},
+ node3: {'flex': 0.5},
+ );
+
+ final tilerModelsSuggestions = layoutPolicy.getLayout(currentLayout);
+ expect(tilerModelsSuggestions.length, 1);
+ expect(tilerModelsSuggestions[0], currentLayout);
+ });
+
+ test(
+ 'Expect current layout is not part of the suggestions if there are'
+ ' any stored layouts with matching geometry and intents', () {
+ final layouts = [
+ _genLayout3ModsA(
+ node1: {'flex': 0.2},
+ node2: {'flex': 0.3},
+ node3: {'flex': 0.7},
+ ),
+ ];
+
+ final layoutFiles = [_createLayoutFile(layouts[0])];
+ when(mockLayoutStore.listSync()).thenReturn(layoutFiles);
+ final layoutPolicy = LayoutPolicy(layoutStore: mockLayoutStore);
+
+ final currentLayout = _genLayout3ModsD(
+ node1: {'flex': 0.8},
+ node2: {'flex': 0.2},
+ node3: {'flex': 0.8},
+ );
+
+ final tilerModelsSuggestions = layoutPolicy.getLayout(currentLayout);
+ expect(tilerModelsSuggestions.length, 1);
+ expect(toJson(tilerModelsSuggestions[0]), toJson(layouts[0]));
+ });
+
+ test(
+ 'Expect only 1 suggestion generated if all stored layouts are'
+ ' geometric equivalent even if flex amounts differ', () {
+ // All stored layouts below are all geometrically equivalent.
+ // Therefore only 1 layout suggestion is generated since layout
+ // suggestion must each be a different geometry.
+ final layouts = [
+ // Top 1 layout is the one below since it occurs most often.
+ _genLayout3ModsA(
+ node1: {'flex': 0.5},
+ node2: {'flex': 0.5},
+ node3: {'flex': 0.5},
+ ), // appears 6 times
+ _genLayout3ModsA(
+ node1: {'flex': 0.2},
+ node2: {'flex': 0.3},
+ node3: {'flex': 0.7},
+ ), // appears 5 times
+ _genLayout3ModsA(
+ node1: {'flex': 0.3},
+ node2: {'flex': 0.4},
+ node3: {'flex': 0.6},
+ ), // appears 4 times
+ _genLayout3ModsA(
+ node1: {'flex': 0.4},
+ node2: {'flex': 0.8},
+ node3: {'flex': 0.2},
+ ), // appears 3 times
+ ];
+
+ final layoutFiles = _fillLayoutFiles([6, 5, 4, 3], layouts);
+ when(mockLayoutStore.listSync()).thenReturn(layoutFiles);
+ final layoutPolicy = LayoutPolicy(layoutStore: mockLayoutStore);
+
+ final currentLayout = _genLayout3ModsA(
+ node1: {'flex': 0.3},
+ node2: {'flex': 0.2},
+ node3: {'flex': 0.8},
+ );
+
+ final tilerModelsSuggestions = layoutPolicy.getLayout(currentLayout);
+ expect(tilerModelsSuggestions.length, 1);
+ expect(toJson(tilerModelsSuggestions[0]), toJson(layouts[0]));
+ });
+
+ test(
+ 'Expect top N layouts sorted by occurence for geometric and flex.'
+ ' Each suggestion is a different geometric layout with'
+ ' matching intents', () {
+ final layouts = [
+ // Top 4 layouts below.
+ _genLayout3ModsA(
+ node1: {'flex': 0.5},
+ node2: {'flex': 0.5},
+ node3: {'flex': 0.5},
+ ), // appears 9 times
+ _genLayout3ModsB(
+ node1: {'flex': 0.2},
+ node2: {'flex': 0.4},
+ node3: {'flex': 0.6},
+ ), // appears 8 times
+ _genLayout3ModsC(
+ node1: {'flex': 0.1},
+ node2: {'flex': 0.3},
+ node3: {'flex': 0.7},
+ ), // appears 7 times
+ _genLayout3ModsD(
+ node1: {'flex': 0.1},
+ node2: {'flex': 0.3},
+ node3: {'flex': 0.7},
+ ), // appears 6 times
+ // The layouts below are layouts that are geometrically equivalent to
+ // the top ranking layouts, but their flex amounts differ. The number of
+ // times these layouts occurs also differs.
+ _genLayout3ModsA(
+ node1: {'flex': 0.1},
+ node2: {'flex': 0.1},
+ node3: {'flex': 0.9},
+ ), // appears 5 times
+ _genLayout3ModsB(
+ node1: {'flex': 0.2},
+ node2: {'flex': 0.2},
+ node3: {'flex': 0.8},
+ ), // appears 4 times
+ _genLayout3ModsC(
+ node1: {'flex': 0.3},
+ node2: {'flex': 0.8},
+ node3: {'flex': 0.2},
+ ), // appears 3 times
+ _genLayout3ModsD(
+ node1: {'flex': 0.4},
+ node2: {'flex': 0.2},
+ node3: {'flex': 0.8},
+ ), // appears 2 times
+ ];
+
+ final layoutFiles = _fillLayoutFiles([9, 8, 7, 6, 5, 4, 3, 2], layouts);
+ when(mockLayoutStore.listSync()).thenReturn(layoutFiles);
+ final layoutPolicy = LayoutPolicy(layoutStore: mockLayoutStore);
+
+ final currentLayout = _genLayout3ModsA(
+ node1: {'flex': 0.7},
+ node2: {'flex': 0.2},
+ node3: {'flex': 0.8},
+ );
+
+ final top4layouts = layouts.sublist(0, 4);
+ final tilerModelsSuggestions = layoutPolicy.getLayout(currentLayout);
+ expect(tilerModelsSuggestions.length, 4);
+ expect(tilerModelsSuggestions.map(toJson), top4layouts.map(toJson));
+ });
+
+ test('Expect only matching intents layouts to appear in top N layouts', () {
+ final layouts = [
+ // Top 2 layouts below.
+ _genLayout3ModsA(
+ node1: {'flex': 0.5},
+ node2: {'flex': 0.5},
+ node3: {'flex': 0.5},
+ ), // appears 4 times
+ _genLayout3ModsB(
+ node1: {'flex': 0.2},
+ node2: {'flex': 0.4},
+ node3: {'flex': 0.6},
+ ), // appears 3 times
+ // Layouts below are those with non-matching intents to current layout.
+ _genLayout3ModsD(
+ node1: {'flex': 0.6, 'content': _kNonMatchingIntentContent},
+ node2: {'flex': 0.1, 'content': _kNonMatchingIntentContent},
+ node3: {'flex': 0.9, 'content': _kNonMatchingIntentContent},
+ ), // appears 7 times
+ _genLayout3ModsC(
+ node1: {'flex': 0.8, 'content': _kNonMatchingIntentContent},
+ node2: {'flex': 0.8, 'content': _kNonMatchingIntentContent},
+ node3: {'flex': 0.2, 'content': _kNonMatchingIntentContent},
+ ), // appears 6 times
+ _genLayout3ModsB(
+ node1: {'flex': 0.7, 'content': _kNonMatchingIntentContent},
+ node2: {'flex': 0.4, 'content': _kNonMatchingIntentContent},
+ node3: {'flex': 0.6, 'content': _kNonMatchingIntentContent},
+ ), // appears 5 times
+ ];
+
+ final layoutFiles = _fillLayoutFiles([4, 3, 7, 6, 5], layouts);
+ when(mockLayoutStore.listSync()).thenReturn(layoutFiles);
+ final layoutPolicy = LayoutPolicy(layoutStore: mockLayoutStore);
+
+ final currentLayout = _genLayout3ModsA(
+ node1: {'flex': 0.3},
+ node2: {'flex': 0.2},
+ node3: {'flex': 0.8},
+ );
+
+ final top2layouts = layouts.sublist(0, 2);
+ final tilerModelsSuggestions = layoutPolicy.getLayout(currentLayout);
+ expect(tilerModelsSuggestions.length, 2);
+ expect(tilerModelsSuggestions.map(toJson), top2layouts.map(toJson));
+ });
+}
diff --git a/shell/story_shell_labs/BUILD.gn b/shell/story_shell_labs/BUILD.gn
index 7477cd0..561eb2b 100644
--- a/shell/story_shell_labs/BUILD.gn
+++ b/shell/story_shell_labs/BUILD.gn
@@ -24,8 +24,8 @@
"//sdk/fidl/fuchsia.modular",
"//third_party/dart-pkg/git/flutter/packages/flutter",
"//topaz/public/dart/fidl",
- "//topaz/public/dart/fuchsia_modular",
"//topaz/public/dart/fuchsia_logger",
+ "//topaz/public/dart/fuchsia_modular",
"//topaz/public/dart/fuchsia_services",
]
}