You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@beam.apache.org by da...@apache.org on 2022/11/16 16:34:00 UTC

[beam] branch master updated: [Tour of Beam] [Frontend] Content tree URLs (#23776)

This is an automated email from the ASF dual-hosted git repository.

damccorm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 6e9187e67e1 [Tour of Beam] [Frontend] Content tree URLs (#23776)
6e9187e67e1 is described below

commit 6e9187e67e1bd8f73997f437f0ed4c29880ed73b
Author: Darkhan Nausharipov <31...@users.noreply.github.com>
AuthorDate: Wed Nov 16 22:33:50 2022 +0600

    [Tour of Beam] [Frontend] Content tree URLs (#23776)
    
    * Content tree navigation (#23593)
    
    Unit content navigation (#23593)
    
    Update URL on node click (#23593)
    
    Active unit color (#23593)
    
    removeListener in unit (#23593)
    
    First unit is opened on group title click (#23593)
    
    WIP by Alexey Inkin (#23593)
    
    selectedUnitColor (#23593)
    
    Unit borderRadius (#23593)
    
    RegExp todo (#23593)
    
    added referenced collection package to remove warning (#23593)
    
    small refinement (#23593)
    
    expand on group tap, padding, openNode (#23593)
    
    group expansion bug fix (#23593)
    
    selected & unselected progress indicators (#23593)
    
    * AnimatedBuilders instead of StatefulWidgets in unit & group (#23593)
    
    * fixed _getNodeAncestors (#23593)
    
    * get sdkId (#23593)
    
    * addressing comments (#23593)
    
    * sdkId getter & StatelessExpansionTile (#23593)
    
    * expand & collapse group (#23593)
    
    * StatelessExpansionTile (#23593)
    
    * license (#23593)
    
    * ValueChanged and ValueKey in StatelessExpansionTile (#23593)
    
    Co-authored-by: darkhan.nausharipov <da...@kzn.akvelon.com>
    Co-authored-by: Alexey Inkin <al...@akvelon.com>
---
 .../frontend/lib/components/filler_text.dart       | 29 ---------
 .../frontend/lib/models/content_tree.dart          | 20 ++++--
 .../tour-of-beam/frontend/lib/models/group.dart    | 29 ++++++---
 .../tour-of-beam/frontend/lib/models/module.dart   | 29 ++++++---
 .../tour-of-beam/frontend/lib/models/node.dart     | 20 ++++--
 .../frontend/lib/models/parent_node.dart           | 18 ++++++
 .../tour-of-beam/frontend/lib/models/unit.dart     | 15 ++++-
 .../lib/pages/tour/controllers/content_tree.dart   | 71 ++++++++++++++++++++--
 .../tour-of-beam/frontend/lib/pages/tour/path.dart |  9 ++-
 .../frontend/lib/pages/tour/state.dart             |  2 +
 .../frontend/lib/pages/tour/widgets/group.dart     | 40 +++++++-----
 .../lib/pages/tour/widgets/group_title.dart        |  5 +-
 .../frontend/lib/pages/tour/widgets/module.dart    |  2 +-
 .../{group.dart => stateless_expansion_tile.dart}  | 35 +++++------
 .../tour/widgets/tour_progress_indicator.dart      | 16 ++++-
 .../frontend/lib/pages/tour/widgets/unit.dart      | 38 ++++++++----
 .../frontend/lib/pages/welcome/screen.dart         |  3 -
 learning/tour-of-beam/frontend/pubspec.lock        |  4 +-
 learning/tour-of-beam/frontend/pubspec.yaml        |  1 +
 .../lib/src/constants/colors.dart                  | 46 ++++++++------
 .../playground_components/lib/src/theme/theme.dart | 26 ++++++++
 21 files changed, 317 insertions(+), 141 deletions(-)

diff --git a/learning/tour-of-beam/frontend/lib/components/filler_text.dart b/learning/tour-of-beam/frontend/lib/components/filler_text.dart
deleted file mode 100644
index ca6099e6d9d..00000000000
--- a/learning/tour-of-beam/frontend/lib/components/filler_text.dart
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import 'package:flutter/material.dart';
-
-class FillerText extends StatelessWidget {
-  final int width;
-  const FillerText({required this.width});
-
-  @override
-  Widget build(BuildContext context) {
-    return Text(''.padRight(width, 'Just a filler text. '));
-  }
-}
diff --git a/learning/tour-of-beam/frontend/lib/models/content_tree.dart b/learning/tour-of-beam/frontend/lib/models/content_tree.dart
index 4c3ba29378a..471bb6734fe 100644
--- a/learning/tour-of-beam/frontend/lib/models/content_tree.dart
+++ b/learning/tour-of-beam/frontend/lib/models/content_tree.dart
@@ -18,19 +18,29 @@
 
 import '../repositories/models/get_content_tree_response.dart';
 import 'module.dart';
+import 'node.dart';
+import 'parent_node.dart';
 
-class ContentTreeModel {
-  final String sdkId;
+class ContentTreeModel extends ParentNodeModel {
   final List<ModuleModel> modules;
 
+  String get sdkId => id;
+
+  @override
+  List<NodeModel> get nodes => modules;
+
   const ContentTreeModel({
-    required this.sdkId,
+    required super.id,
     required this.modules,
-  });
+  }) : super(
+          parent: null,
+          title: '',
+          nodes: modules,
+        );
 
   ContentTreeModel.fromResponse(GetContentTreeResponse response)
       : this(
-          sdkId: response.sdkId,
+          id: response.sdkId,
           modules: response.modules
               .map(ModuleModel.fromResponse)
               .toList(growable: false),
diff --git a/learning/tour-of-beam/frontend/lib/models/group.dart b/learning/tour-of-beam/frontend/lib/models/group.dart
index 22086e6303e..ba1d4047a57 100644
--- a/learning/tour-of-beam/frontend/lib/models/group.dart
+++ b/learning/tour-of-beam/frontend/lib/models/group.dart
@@ -23,15 +23,28 @@ import 'parent_node.dart';
 class GroupModel extends ParentNodeModel {
   const GroupModel({
     required super.id,
-    required super.title,
     required super.nodes,
+    required super.parent,
+    required super.title,
   });
 
-  GroupModel.fromResponse(GroupResponseModel group)
-      : super(
-          id: group.id,
-          title: group.title,
-          nodes:
-              group.nodes.map(NodeModel.fromResponse).toList(growable: false),
-        );
+  factory GroupModel.fromResponse(
+    GroupResponseModel groupResponse,
+    ParentNodeModel parent,
+  ) {
+    final group = GroupModel(
+      id: groupResponse.id,
+      nodes: [],
+      parent: parent,
+      title: groupResponse.title,
+    );
+
+    group.nodes.addAll(
+      groupResponse.nodes.map<NodeModel>(
+        (node) => NodeModel.fromResponse(node, group),
+      ),
+    );
+
+    return group;
+  }
 }
diff --git a/learning/tour-of-beam/frontend/lib/models/module.dart b/learning/tour-of-beam/frontend/lib/models/module.dart
index 81f8c1b6d61..eb1f7e50633 100644
--- a/learning/tour-of-beam/frontend/lib/models/module.dart
+++ b/learning/tour-of-beam/frontend/lib/models/module.dart
@@ -27,18 +27,27 @@ class ModuleModel extends ParentNodeModel {
 
   const ModuleModel({
     required super.id,
-    required super.title,
     required super.nodes,
+    required super.parent,
+    required super.title,
     required this.complexity,
   });
 
-  ModuleModel.fromResponse(ModuleResponseModel module)
-      : complexity = module.complexity,
-        super(
-          id: module.id,
-          title: module.title,
-          nodes: module.nodes
-              .map<NodeModel>(NodeModel.fromResponse)
-              .toList(growable: false),
-        );
+  factory ModuleModel.fromResponse(ModuleResponseModel moduleResponse) {
+    final module = ModuleModel(
+      complexity: moduleResponse.complexity,
+      nodes: [],
+      id: moduleResponse.id,
+      parent: null,
+      title: moduleResponse.title,
+    );
+
+    module.nodes.addAll(
+      moduleResponse.nodes.map<NodeModel>(
+        (node) => NodeModel.fromResponse(node, module),
+      ),
+    );
+
+    return module;
+  }
 }
diff --git a/learning/tour-of-beam/frontend/lib/models/node.dart b/learning/tour-of-beam/frontend/lib/models/node.dart
index d13ceea2d28..7a653de1e3a 100644
--- a/learning/tour-of-beam/frontend/lib/models/node.dart
+++ b/learning/tour-of-beam/frontend/lib/models/node.dart
@@ -19,15 +19,18 @@
 import '../repositories/models/node.dart';
 import '../repositories/models/node_type_enum.dart';
 import 'group.dart';
+import 'parent_node.dart';
 import 'unit.dart';
 
 abstract class NodeModel {
   final String id;
   final String title;
+  final NodeModel? parent;
 
   const NodeModel({
     required this.id,
     required this.title,
+    required this.parent,
   });
 
   /// Constructs nodes from the response data.
@@ -36,20 +39,27 @@ abstract class NodeModel {
   /// because they come from a golang backend which does not
   /// support inheritance, and so they use an extra layer of composition
   /// which is inconvenient in Flutter.
-  static List<NodeModel> fromMaps(List json) {
+  static List<NodeModel> fromMaps(List json, ParentNodeModel parent) {
     return json
         .cast<Map<String, dynamic>>()
         .map<NodeResponseModel>(NodeResponseModel.fromJson)
-        .map(fromResponse)
+        .map((nodeResponse) => fromResponse(nodeResponse, parent))
         .toList();
   }
 
-  static NodeModel fromResponse(NodeResponseModel node) {
+  static NodeModel fromResponse(
+    NodeResponseModel node,
+    ParentNodeModel parent,
+  ) {
     switch (node.type) {
       case NodeType.group:
-        return GroupModel.fromResponse(node.group!);
+        return GroupModel.fromResponse(node.group!, parent);
       case NodeType.unit:
-        return UnitModel.fromResponse(node.unit!);
+        return UnitModel.fromResponse(node.unit!, parent);
     }
   }
+
+  NodeModel getFirstUnit();
+
+  NodeModel? getNodeByTreeIds(List<String> treeIds);
 }
diff --git a/learning/tour-of-beam/frontend/lib/models/parent_node.dart b/learning/tour-of-beam/frontend/lib/models/parent_node.dart
index 0271cfb9508..53f3c7a1766 100644
--- a/learning/tour-of-beam/frontend/lib/models/parent_node.dart
+++ b/learning/tour-of-beam/frontend/lib/models/parent_node.dart
@@ -16,6 +16,8 @@
  * limitations under the License.
  */
 
+import 'package:collection/collection.dart';
+
 import 'node.dart';
 
 abstract class ParentNodeModel extends NodeModel {
@@ -23,7 +25,23 @@ abstract class ParentNodeModel extends NodeModel {
 
   const ParentNodeModel({
     required super.id,
+    required super.parent,
     required super.title,
     required this.nodes,
   });
+
+  @override
+  NodeModel getFirstUnit() => nodes[0].getFirstUnit();
+
+  @override
+  NodeModel? getNodeByTreeIds(List<String> treeIds) {
+    final firstId = treeIds.firstOrNull;
+    final child = nodes.firstWhereOrNull((node) => node.id == firstId);
+
+    if (child == null) {
+      return null;
+    }
+
+    return child.getNodeByTreeIds(treeIds.sublist(1));
+  }
 }
diff --git a/learning/tour-of-beam/frontend/lib/models/unit.dart b/learning/tour-of-beam/frontend/lib/models/unit.dart
index 48b55af33d1..eb2e158ddf6 100644
--- a/learning/tour-of-beam/frontend/lib/models/unit.dart
+++ b/learning/tour-of-beam/frontend/lib/models/unit.dart
@@ -18,8 +18,19 @@
 
 import '../repositories/models/unit.dart';
 import 'node.dart';
+import 'parent_node.dart';
 
 class UnitModel extends NodeModel {
-  UnitModel.fromResponse(UnitResponseModel unit)
-      : super(id: unit.id, title: unit.title);
+  UnitModel.fromResponse(UnitResponseModel unit, ParentNodeModel parent)
+      : super(
+          id: unit.id,
+          parent: parent,
+          title: unit.title,
+        );
+
+  @override
+  NodeModel getFirstUnit() => this;
+
+  @override
+  NodeModel? getNodeByTreeIds(List<String> treeIds) => this;
 }
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/controllers/content_tree.dart b/learning/tour-of-beam/frontend/lib/pages/tour/controllers/content_tree.dart
index dc5fc5a15ce..bfa63c94df4 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/controllers/content_tree.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/controllers/content_tree.dart
@@ -17,33 +17,96 @@
  */
 
 import 'package:flutter/widgets.dart';
+import 'package:get_it/get_it.dart';
 import 'package:playground_components/playground_components.dart';
 
+import '../../../cache/content_tree.dart';
+import '../../../models/group.dart';
 import '../../../models/node.dart';
+import '../../../models/unit.dart';
 
 class ContentTreeController extends ChangeNotifier {
   String _sdkId;
   List<String> _treeIds;
   NodeModel? _currentNode;
+  final _contentTreeCache = GetIt.instance.get<ContentTreeCache>();
+  final _expandedIds = <String>{};
+
+  Set<String> get expandedIds => _expandedIds;
 
   ContentTreeController({
     required String initialSdkId,
     List<String> initialTreeIds = const [],
   })  : _sdkId = initialSdkId,
-        _treeIds = initialTreeIds;
+        _treeIds = initialTreeIds {
+    _expandedIds.addAll(initialTreeIds);
+
+    _contentTreeCache.addListener(_onContentTreeCacheChange);
+    _onContentTreeCacheChange();
+  }
 
   Sdk get sdk => Sdk.parseOrCreate(_sdkId);
   String get sdkId => _sdkId;
   List<String> get treeIds => _treeIds;
   NodeModel? get currentNode => _currentNode;
 
-  void onNodeTap(NodeModel node) {
+  void openNode(NodeModel node) {
+    if (!_expandedIds.contains(node.id)) {
+      _expandedIds.add(node.id);
+    }
+
     if (node == _currentNode) {
       return;
     }
 
-    _currentNode = node;
-    // TODO(alexeyinkin): Set _treeIds from node.
+    if (node is GroupModel) {
+      openNode(node.nodes.first);
+    } else if (node is UnitModel) {
+      _currentNode = node;
+    }
+
+    if (_currentNode != null) {
+      _treeIds = _getNodeAncestors(_currentNode!, [_currentNode!.id]);
+    }
+    notifyListeners();
+  }
+
+  void expandGroup(GroupModel group) {
+    _expandedIds.add(group.id);
+    notifyListeners();
+  }
+
+  void collapseGroup(GroupModel group) {
+    _expandedIds.remove(group.id);
+    notifyListeners();
+  }
+
+  List<String> _getNodeAncestors(NodeModel node, List<String> ancestorIds) {
+    if (node.parent != null) {
+      return _getNodeAncestors(
+        node.parent!,
+        [...ancestorIds, node.parent!.id],
+      );
+    }
+    return ancestorIds.reversed.toList();
+  }
+
+  void _onContentTreeCacheChange() {
+    final contentTree = _contentTreeCache.getContentTree(_sdkId);
+    if (contentTree == null) {
+      return;
+    }
+
+    openNode(
+      contentTree.getNodeByTreeIds(_treeIds) ?? contentTree.getFirstUnit(),
+    );
+
     notifyListeners();
   }
+
+  @override
+  void dispose() {
+    _contentTreeCache.removeListener(_onContentTreeCacheChange);
+    super.dispose();
+  }
 }
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/path.dart b/learning/tour-of-beam/frontend/lib/pages/tour/path.dart
index 5f8971852f9..07dd386bdfc 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/path.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/path.dart
@@ -26,7 +26,7 @@ class TourPath extends PagePath {
   final String sdkId;
   final List<String> treeIds;
 
-  static final _regExp = RegExp(r'^/tour/([a-z]+)(/[/-a-zA-Z0-9]+)?$');
+  static final _regExp = RegExp(r'^/tour/([a-z]+)((/[-a-zA-Z0-9]+)*)$');
 
   TourPath({
     required this.sdkId,
@@ -47,7 +47,12 @@ class TourPath extends PagePath {
     if (matches == null) return null;
 
     final sdkId = matches[1] ?? (throw Error());
-    final treeIds = matches[2]?.split('/') ?? const [];
+    final treeIdsString = matches[2];
+
+    final treeIds = (treeIdsString == null)
+        ? const <String>[]
+        // TODO(nausharipov): use RegExp to remove the slash
+        : treeIdsString.substring(1).split('/');
 
     return TourPath(
       sdkId: sdkId,
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/state.dart b/learning/tour-of-beam/frontend/lib/pages/tour/state.dart
index ae8fc0e1e70..e709839e915 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/state.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/state.dart
@@ -44,6 +44,7 @@ class TourNotifier extends ChangeNotifier with PageStateMixin<void> {
         playgroundController = _createPlaygroundController(initialSdkId) {
     contentTreeController.addListener(_onChanged);
     _unitContentCache.addListener(_onChanged);
+    _onChanged();
   }
 
   @override
@@ -53,6 +54,7 @@ class TourNotifier extends ChangeNotifier with PageStateMixin<void> {
       );
 
   void _onChanged() {
+    emitPathChanged();
     final currentNode = contentTreeController.currentNode;
     if (currentNode is UnitModel) {
       final content = _unitContentCache.getUnitContent(
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group.dart
index bdebcfc507b..fad732b105b 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group.dart
@@ -17,13 +17,12 @@
  */
 
 import 'package:flutter/material.dart';
-import 'package:playground_components/playground_components.dart';
 
-import '../../../components/expansion_tile_wrapper.dart';
 import '../../../models/group.dart';
 import '../controllers/content_tree.dart';
 import 'group_nodes.dart';
 import 'group_title.dart';
+import 'stateless_expansion_tile.dart';
 
 class GroupWidget extends StatelessWidget {
   final GroupModel group;
@@ -36,23 +35,32 @@ class GroupWidget extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return ExpansionTileWrapper(
-      ExpansionTile(
-        tilePadding: EdgeInsets.zero,
-        title: GroupTitleWidget(
-          group: group,
-          onTap: () => contentTreeController.onNodeTap(group),
-        ),
-        childrenPadding: const EdgeInsets.only(
-          left: BeamSizes.size24,
-        ),
-        children: [
-          GroupNodesWidget(
+    return AnimatedBuilder(
+      animation: contentTreeController,
+      builder: (context, child) {
+        final isExpanded = contentTreeController.expandedIds.contains(group.id);
+
+        return StatelessExpansionTile(
+          isExpanded: isExpanded,
+          onExpansionChanged: (isExpanding) {
+            if (isExpanding) {
+              contentTreeController.expandGroup(group);
+            } else {
+              contentTreeController.collapseGroup(group);
+            }
+          },
+          title: GroupTitleWidget(
+            group: group,
+            onTap: () {
+              contentTreeController.openNode(group);
+            },
+          ),
+          child: GroupNodesWidget(
             nodes: group.nodes,
             contentTreeController: contentTreeController,
           ),
-        ],
-      ),
+        );
+      },
     );
   }
 }
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group_title.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group_title.dart
index a25c5498bd9..974199946cb 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group_title.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group_title.dart
@@ -38,7 +38,10 @@ class GroupTitleWidget extends StatelessWidget {
       onTap: onTap,
       child: Row(
         children: [
-          TourProgressIndicator(assetPath: Assets.svg.unitProgress0),
+          TourProgressIndicator(
+            assetPath: Assets.svg.unitProgress0,
+            isSelected: false,
+          ),
           Text(
             group.title,
             style: Theme.of(context).textTheme.headlineMedium,
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/module.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/module.dart
index 886e9f98d86..b01987bf0a7 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/module.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/module.dart
@@ -39,7 +39,7 @@ class ModuleWidget extends StatelessWidget {
       children: [
         ModuleTitleWidget(
           module: module,
-          onTap: () => contentTreeController.onNodeTap(module),
+          onTap: () => contentTreeController.openNode(module),
         ),
         ...module.nodes
             .map(
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/stateless_expansion_tile.dart
similarity index 66%
copy from learning/tour-of-beam/frontend/lib/pages/tour/widgets/group.dart
copy to learning/tour-of-beam/frontend/lib/pages/tour/widgets/stateless_expansion_tile.dart
index bdebcfc507b..149bd04a586 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/stateless_expansion_tile.dart
@@ -20,38 +20,33 @@ import 'package:flutter/material.dart';
 import 'package:playground_components/playground_components.dart';
 
 import '../../../components/expansion_tile_wrapper.dart';
-import '../../../models/group.dart';
-import '../controllers/content_tree.dart';
-import 'group_nodes.dart';
-import 'group_title.dart';
 
-class GroupWidget extends StatelessWidget {
-  final GroupModel group;
-  final ContentTreeController contentTreeController;
+class StatelessExpansionTile extends StatelessWidget {
+  final bool isExpanded;
+  final ValueChanged<bool>? onExpansionChanged;
+  final Widget title;
+  final Widget child;
 
-  const GroupWidget({
-    required this.group,
-    required this.contentTreeController,
+  const StatelessExpansionTile({
+    required this.isExpanded,
+    required this.onExpansionChanged,
+    required this.title,
+    required this.child,
   });
 
   @override
   Widget build(BuildContext context) {
     return ExpansionTileWrapper(
       ExpansionTile(
+        key: ValueKey(isExpanded),
+        initiallyExpanded: isExpanded,
         tilePadding: EdgeInsets.zero,
-        title: GroupTitleWidget(
-          group: group,
-          onTap: () => contentTreeController.onNodeTap(group),
-        ),
+        onExpansionChanged: onExpansionChanged,
+        title: title,
         childrenPadding: const EdgeInsets.only(
           left: BeamSizes.size24,
         ),
-        children: [
-          GroupNodesWidget(
-            nodes: group.nodes,
-            contentTreeController: contentTreeController,
-          ),
-        ],
+        children: [child],
       ),
     );
   }
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/tour_progress_indicator.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/tour_progress_indicator.dart
index 6184a22a9d4..6f3d6ba5608 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/tour_progress_indicator.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/tour_progress_indicator.dart
@@ -21,18 +21,30 @@ import 'package:flutter_svg/svg.dart';
 import 'package:playground_components/playground_components.dart';
 
 class TourProgressIndicator extends StatelessWidget {
+  // TODO(nausharipov): replace assetPath with progress enum
   final String assetPath;
+  final bool isSelected;
 
-  const TourProgressIndicator({required this.assetPath});
+  const TourProgressIndicator({
+    required this.assetPath,
+    required this.isSelected,
+  });
 
   @override
   Widget build(BuildContext context) {
+    final ext = Theme.of(context).extension<BeamThemeExtension>()!;
+
     return Padding(
       padding: const EdgeInsets.only(
         left: BeamSizes.size4,
         right: BeamSizes.size8,
       ),
-      child: SvgPicture.asset(assetPath),
+      child: SvgPicture.asset(
+        assetPath,
+        color: isSelected
+            ? ext.selectedProgressColor
+            : ext.unselectedProgressColor,
+      ),
     );
   }
 }
diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/unit.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/unit.dart
index 914361a347a..cfc0e32235a 100644
--- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/unit.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/unit.dart
@@ -35,17 +35,33 @@ class UnitWidget extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return ClickableWidget(
-      onTap: () => contentTreeController.onNodeTap(unit),
-      child: Padding(
-        padding: const EdgeInsets.symmetric(vertical: BeamSizes.size10),
-        child: Row(
-          children: [
-            TourProgressIndicator(assetPath: Assets.svg.unitProgress0),
-            Expanded(child: Text(unit.title)),
-          ],
-        ),
-      ),
+    return AnimatedBuilder(
+      animation: contentTreeController,
+      builder: (context, child) {
+        final isSelected = contentTreeController.currentNode?.id == unit.id;
+
+        return ClickableWidget(
+          onTap: () => contentTreeController.openNode(unit),
+          child: Container(
+            decoration: BoxDecoration(
+              color: isSelected ? Theme.of(context).selectedRowColor : null,
+              borderRadius: BorderRadius.circular(BeamSizes.size3),
+            ),
+            padding: const EdgeInsets.symmetric(vertical: BeamSizes.size10),
+            child: Row(
+              children: [
+                TourProgressIndicator(
+                  assetPath: Assets.svg.unitProgress0,
+                  isSelected: isSelected,
+                ),
+                Expanded(
+                  child: Text(unit.title),
+                ),
+              ],
+            ),
+          ),
+        );
+      },
     );
   }
 }
diff --git a/learning/tour-of-beam/frontend/lib/pages/welcome/screen.dart b/learning/tour-of-beam/frontend/lib/pages/welcome/screen.dart
index 42159356267..af6e91969bd 100644
--- a/learning/tour-of-beam/frontend/lib/pages/welcome/screen.dart
+++ b/learning/tour-of-beam/frontend/lib/pages/welcome/screen.dart
@@ -24,7 +24,6 @@ import 'package:playground_components/playground_components.dart';
 
 import '../../components/builders/content_tree.dart';
 import '../../components/builders/sdks.dart';
-import '../../components/filler_text.dart';
 import '../../components/scaffold.dart';
 import '../../constants/sizes.dart';
 import '../../generated/assets.gen.dart';
@@ -397,7 +396,6 @@ class _ModuleBody extends StatelessWidget {
       padding: _modulePadding,
       child: Column(
         children: [
-          const FillerText(width: 20),
           const SizedBox(height: BeamSizes.size16),
           Divider(
             color: themeData.dividerColor,
@@ -416,7 +414,6 @@ class _LastModuleBody extends StatelessWidget {
     return Container(
       margin: _moduleLeftMargin,
       padding: _modulePadding,
-      child: const FillerText(width: 20),
     );
   }
 }
diff --git a/learning/tour-of-beam/frontend/pubspec.lock b/learning/tour-of-beam/frontend/pubspec.lock
index e1ed198ef56..51fb2a0fd73 100644
--- a/learning/tour-of-beam/frontend/pubspec.lock
+++ b/learning/tour-of-beam/frontend/pubspec.lock
@@ -156,7 +156,7 @@ packages:
     source: hosted
     version: "4.2.0"
   collection:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: collection
       url: "https://pub.dartlang.org"
@@ -278,7 +278,7 @@ packages:
       name: flutter_code_editor
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.1.4"
+    version: "0.1.8"
   flutter_driver:
     dependency: transitive
     description: flutter
diff --git a/learning/tour-of-beam/frontend/pubspec.yaml b/learning/tour-of-beam/frontend/pubspec.yaml
index a6e829542e0..a8f4fd9a4ce 100644
--- a/learning/tour-of-beam/frontend/pubspec.yaml
+++ b/learning/tour-of-beam/frontend/pubspec.yaml
@@ -28,6 +28,7 @@ environment:
 
 dependencies:
   app_state: ^0.8.1
+  collection: ^1.16.0
   easy_localization: ^3.0.1
   easy_localization_ext: ^0.1.0
   easy_localization_loader: ^1.0.0
diff --git a/playground/frontend/playground_components/lib/src/constants/colors.dart b/playground/frontend/playground_components/lib/src/constants/colors.dart
index 447d564056e..6db92295c16 100644
--- a/playground/frontend/playground_components/lib/src/constants/colors.dart
+++ b/playground/frontend/playground_components/lib/src/constants/colors.dart
@@ -36,45 +36,51 @@ class BeamColors {
 
 class BeamGraphColors {
   static const node = BeamColors.grey3;
-  static const border = Color(0xFF45454E);
+  static const border = Color(0xff45454E);
   static const edge = BeamLightThemeColors.primary;
 }
 
 class BeamNotificationColors {
-  static const error = Color(0xFFE54545);
-  static const info = Color(0xFF3E67F6);
-  static const success = Color(0xFF37AC66);
-  static const warning = Color(0xFFEEAB00);
+  static const error = Color(0xffE54545);
+  static const info = Color(0xff3E67F6);
+  static const success = Color(0xff37AC66);
+  static const warning = Color(0xffEEAB00);
 }
 
 class BeamLightThemeColors {
-  static const border = Color(0xFFE5E5E5);
+  static const border = Color(0xffE5E5E5);
   static const primaryBackground = BeamColors.white;
   static const secondaryBackground = Color(0xffFCFCFC);
+  static const selectedUnitColor = Color(0xffE6E7E9);
+  static const selectedProgressColor = BeamColors.grey3;
+  static const unselectedProgressColor = selectedUnitColor;
   static const grey = Color(0xffE5E5E5);
-  static const listBackground = Color(0xFFA0A4AB);
+  static const listBackground = BeamColors.grey3;
   static const text = BeamColors.darkBlue;
   static const primary = Color(0xffE74D1A);
-  static const icon = Color(0xFFA0A4AB);
+  static const icon = BeamColors.grey3;
 
-  static const code1 = Color(0xFFDA2833);
-  static const code2 = Color(0xFF5929B4);
-  static const codeComment = Color(0xFF4C6B60);
-  static const codeBackground = Color(0xFFFEF6F3);
+  static const code1 = Color(0xffDA2833);
+  static const code2 = Color(0xff5929B4);
+  static const codeComment = Color(0xff4C6B60);
+  static const codeBackground = Color(0xffFEF6F3);
 }
 
 class BeamDarkThemeColors {
-  static const border = Color(0xFFA0A4AB);
+  static const border = BeamColors.grey3;
   static const primaryBackground = Color(0xff18181B);
   static const secondaryBackground = BeamColors.darkGrey;
+  static const selectedUnitColor = Color(0xff626267);
+  static const selectedProgressColor = BeamColors.grey1;
+  static const unselectedProgressColor = selectedUnitColor;
   static const grey = Color(0xff3F3F46);
-  static const listBackground = Color(0xFF606772);
-  static const text = Color(0xffFFFFFF);
+  static const listBackground = Color(0xff606772);
+  static const text = Color(0xffffffff);
   static const primary = Color(0xffF26628);
-  static const icon = Color(0xFF606772);
+  static const icon = Color(0xff606772);
 
-  static const code1 = Color(0xFFDA2833);
-  static const code2 = Color(0xFF5929B4);
-  static const codeComment = Color(0xFF4C6B60);
-  static const codeBackground = Color(0xFF231B1B);
+  static const code1 = Color(0xffDA2833);
+  static const code2 = Color(0xff5929B4);
+  static const codeComment = Color(0xff4C6B60);
+  static const codeBackground = Color(0xff231B1B);
 }
diff --git a/playground/frontend/playground_components/lib/src/theme/theme.dart b/playground/frontend/playground_components/lib/src/theme/theme.dart
index 14c811abe93..287eef0a14f 100644
--- a/playground/frontend/playground_components/lib/src/theme/theme.dart
+++ b/playground/frontend/playground_components/lib/src/theme/theme.dart
@@ -32,6 +32,9 @@ class BeamThemeExtension extends ThemeExtension<BeamThemeExtension> {
   final Color primaryBackgroundTextColor;
   final Color lightGreyBackgroundTextColor;
   final Color secondaryBackgroundColor;
+  // TODO(nausharipov): simplify new color addition
+  final Color selectedProgressColor;
+  final Color unselectedProgressColor;
 
   final Color codeBackgroundColor;
   final TextStyle codeRootStyle;
@@ -50,6 +53,8 @@ class BeamThemeExtension extends ThemeExtension<BeamThemeExtension> {
     required this.codeBackgroundColor,
     required this.codeRootStyle,
     required this.codeTheme,
+    required this.selectedProgressColor,
+    required this.unselectedProgressColor,
   });
 
   @override
@@ -64,6 +69,8 @@ class BeamThemeExtension extends ThemeExtension<BeamThemeExtension> {
     Color? codeBackgroundColor,
     TextStyle? codeRootStyle,
     CodeThemeData? codeTheme,
+    Color? selectedProgressColor,
+    Color? unselectedProgressColor,
   }) {
     return BeamThemeExtension(
       borderColor: borderColor ?? this.borderColor,
@@ -79,6 +86,10 @@ class BeamThemeExtension extends ThemeExtension<BeamThemeExtension> {
       codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor,
       codeRootStyle: codeRootStyle ?? this.codeRootStyle,
       codeTheme: codeTheme ?? this.codeTheme,
+      selectedProgressColor:
+          selectedProgressColor ?? this.selectedProgressColor,
+      unselectedProgressColor:
+          unselectedProgressColor ?? this.unselectedProgressColor,
     );
   }
 
@@ -104,6 +115,13 @@ class BeamThemeExtension extends ThemeExtension<BeamThemeExtension> {
           Color.lerp(codeBackgroundColor, other?.codeBackgroundColor, t)!,
       codeRootStyle: TextStyle.lerp(codeRootStyle, other?.codeRootStyle, t)!,
       codeTheme: t == 0.0 ? codeTheme : other?.codeTheme ?? codeTheme,
+      selectedProgressColor:
+          Color.lerp(selectedProgressColor, other?.selectedProgressColor, t)!,
+      unselectedProgressColor: Color.lerp(
+        unselectedProgressColor,
+        other?.unselectedProgressColor,
+        t,
+      )!,
     );
   }
 }
@@ -121,6 +139,7 @@ final kLightTheme = ThemeData(
   ),
   primaryColor: BeamLightThemeColors.primary,
   scaffoldBackgroundColor: BeamLightThemeColors.secondaryBackground,
+  selectedRowColor: BeamLightThemeColors.selectedUnitColor,
   tabBarTheme: _getTabBarTheme(
     textColor: BeamLightThemeColors.text,
     indicatorColor: BeamLightThemeColors.primary,
@@ -136,6 +155,8 @@ final kLightTheme = ThemeData(
       lightGreyBackgroundTextColor: BeamColors.black,
       markdownStyle: _getMarkdownStyle(Brightness.light),
       secondaryBackgroundColor: BeamLightThemeColors.secondaryBackground,
+      selectedProgressColor: BeamLightThemeColors.selectedProgressColor,
+      unselectedProgressColor: BeamLightThemeColors.unselectedProgressColor,
       codeBackgroundColor: BeamLightThemeColors.codeBackground,
       codeRootStyle: GoogleFonts.sourceCodePro(
         color: BeamLightThemeColors.text,
@@ -194,6 +215,7 @@ final kDarkTheme = ThemeData(
   ),
   primaryColor: BeamDarkThemeColors.primary,
   scaffoldBackgroundColor: BeamDarkThemeColors.secondaryBackground,
+  selectedRowColor: BeamDarkThemeColors.selectedUnitColor,
   tabBarTheme: _getTabBarTheme(
     textColor: BeamDarkThemeColors.text,
     indicatorColor: BeamDarkThemeColors.primary,
@@ -209,6 +231,8 @@ final kDarkTheme = ThemeData(
       lightGreyBackgroundTextColor: BeamColors.black,
       markdownStyle: _getMarkdownStyle(Brightness.dark),
       secondaryBackgroundColor: BeamDarkThemeColors.secondaryBackground,
+      selectedProgressColor: BeamDarkThemeColors.selectedProgressColor,
+      unselectedProgressColor: BeamDarkThemeColors.unselectedProgressColor,
       codeBackgroundColor: BeamDarkThemeColors.codeBackground,
       codeRootStyle: GoogleFonts.sourceCodePro(
         color: BeamDarkThemeColors.text,
@@ -396,8 +420,10 @@ MarkdownStyleSheet _getMarkdownStyle(Brightness brightness) {
 
   return MarkdownStyleSheet(
     p: textTheme.bodyMedium,
+    pPadding: EdgeInsets.only(top: BeamSizes.size2),
     h1: textTheme.headlineLarge,
     h3: textTheme.headlineMedium,
+    h3Padding: EdgeInsets.only(top: BeamSizes.size4),
     code: GoogleFonts.sourceCodePro(
       color: textColor,
       backgroundColor: BeamColors.transparent,