Add Deep link validation tool  home page with fake data (#6147)

* 1

* http request

* 1

* 1

* 2

* Update deep_links_screen.dart

* 2

* 3

* update datatable

* Update deep_links_model.dart

2

Update deep_links_controller.dart

new mock

* Update deep_links_screen.dart

* update

* green

* Update deep_links_screen.dart

* update

* Update deep_links_screen.dart

* 1

* merge

* Update colors.dart

* Update theme.dart
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart
index 2cc26a4..961e82b 100644
--- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart
+++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart
@@ -2,4 +2,56 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-class DeepLinksController {}
+import 'package:flutter/foundation.dart';
+
+import 'deep_links_model.dart';
+import 'fake_data.dart';
+
+class DeepLinksController {
+  bool get showSpitScreen => showSpitScreenNotifier.value;
+
+  List<LinkData> get getLinkDatasByPath {
+    final linkDatasByPath = <String, LinkData>{};
+    for (var linkData in linkDatasNotifier.value) {
+      linkDatasByPath[linkData.path] = linkData;
+    }
+    return linkDatasByPath.values.toList();
+  }
+
+  List<LinkData> get getLinkDatasByDomain {
+    final linkDatasByDomain = <String, LinkData>{};
+    for (var linkData in linkDatasNotifier.value) {
+      linkDatasByDomain[linkData.domain] = linkData;
+    }
+    return linkDatasByDomain.values.toList();
+  }
+
+  final selectedLink = ValueNotifier<LinkData?>(null);
+  final linkDatasNotifier = ValueNotifier<List<LinkData>>(allLinkDatas);
+  final showSpitScreenNotifier = ValueNotifier<bool>(false);
+
+  final _searchContentNotifier = ValueNotifier<String>('');
+
+  void _updateLinks() {
+    final searchContent = _searchContentNotifier.value;
+    final List<LinkData> linkDatas = searchContent.isNotEmpty
+        ? allLinkDatas
+            .where(
+              (linkData) => linkData.matchesSearchToken(
+                RegExp(
+                  searchContent,
+                  caseSensitive: false,
+                ),
+              ),
+            )
+            .toList()
+        : allLinkDatas;
+
+    linkDatasNotifier.value = linkDatas;
+  }
+
+  set searchContent(String content) {
+    _searchContentNotifier.value = content;
+    _updateLinks();
+  }
+}
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart
new file mode 100644
index 0000000..e8c5b86
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart
@@ -0,0 +1,216 @@
+// Copyright 2023 The Chromium 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:devtools_app_shared/ui.dart';
+import 'package:flutter/material.dart';
+
+import '../../shared/primitives/utils.dart';
+import '../../shared/table/table.dart';
+import '../../shared/table/table_data.dart';
+import '../../shared/ui/colors.dart';
+import '../../shared/ui/search.dart';
+
+const kDeeplinkTableCellDefaultWidth = 200.0;
+
+/// Contains all data relevant to a deep link.
+class LinkData with SearchableDataMixin {
+  LinkData({
+    required this.domain,
+    required this.path,
+    required this.os,
+    this.scheme = const <String>['Http://', 'Https://'],
+    this.domainError = false,
+    this.pathError = false,
+  });
+
+  final String path;
+  final String domain;
+  final List<String> os;
+  final List<String> scheme;
+  final bool domainError;
+  final bool pathError;
+
+  @override
+  bool matchesSearchToken(RegExp regExpSearch) {
+    return (domain.caseInsensitiveContains(regExpSearch) == true) ||
+        (path.caseInsensitiveContains(regExpSearch) == true);
+  }
+
+  @override
+  String toString() => 'LinkData($domain $path)';
+}
+
+class _ErrorAwareText extends StatelessWidget {
+  const _ErrorAwareText({
+    required this.text,
+    required this.isError,
+  });
+  final String text;
+  final bool isError;
+
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      children: [
+        if (isError)
+          Padding(
+            padding: const EdgeInsets.only(right: denseSpacing),
+            child: Icon(
+              Icons.error,
+              color: Theme.of(context).colorScheme.error,
+              size: defaultIconSize,
+            ),
+          ),
+        const SizedBox(width: denseSpacing),
+        Text(
+          text,
+          overflow: TextOverflow.ellipsis,
+        ),
+      ],
+    );
+  }
+}
+
+class DomainColumn extends ColumnData<LinkData>
+    implements ColumnRenderer<LinkData> {
+  DomainColumn()
+      : super(
+          'Domain',
+          fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth),
+        );
+
+  @override
+  bool get supportsSorting => true;
+
+  @override
+  String getValue(LinkData dataObject) => dataObject.domain;
+
+  @override
+  Widget build(
+    BuildContext context,
+    LinkData dataObject, {
+    bool isRowSelected = false,
+    VoidCallback? onPressed,
+  }) {
+    return _ErrorAwareText(
+      isError: dataObject.domainError,
+      text: dataObject.domain,
+    );
+  }
+}
+
+class PathColumn extends ColumnData<LinkData>
+    implements ColumnRenderer<LinkData> {
+  PathColumn()
+      : super(
+          'Path',
+          fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth),
+        );
+
+  @override
+  bool get supportsSorting => true;
+
+  @override
+  String getValue(LinkData dataObject) => dataObject.path;
+
+  @override
+  Widget build(
+    BuildContext context,
+    LinkData dataObject, {
+    bool isRowSelected = false,
+    VoidCallback? onPressed,
+  }) {
+    return _ErrorAwareText(
+      isError: dataObject.pathError,
+      text: dataObject.path,
+    );
+  }
+}
+
+class SchemeColumn extends ColumnData<LinkData> {
+  SchemeColumn()
+      : super(
+          'Scheme',
+          fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth),
+        );
+
+  @override
+  String getValue(LinkData dataObject) => dataObject.scheme.join(',');
+}
+
+class OSColumn extends ColumnData<LinkData> {
+  OSColumn()
+      : super(
+          'OS',
+          fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth),
+        );
+
+  @override
+  String getValue(LinkData dataObject) => dataObject.os.join(',');
+}
+
+class StatusColumn extends ColumnData<LinkData>
+    implements ColumnRenderer<LinkData> {
+  StatusColumn()
+      : super(
+          'Status',
+          fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth),
+        );
+
+  @override
+  String getValue(LinkData dataObject) {
+    if (dataObject.domainError) {
+      return 'Failed domain checks';
+    } else if (dataObject.pathError) {
+      return 'Failed path checks';
+    } else {
+      return 'No issues found';
+    }
+  }
+
+  @override
+  Widget build(
+    BuildContext context,
+    LinkData dataObject, {
+    bool isRowSelected = false,
+    VoidCallback? onPressed,
+  }) {
+    if (dataObject.domainError || dataObject.pathError) {
+      return Text(
+        getValue(dataObject),
+        overflow: TextOverflow.ellipsis,
+        style: TextStyle(color: Theme.of(context).colorScheme.error),
+      );
+    } else {
+      return Text(
+        'No issues found',
+        style: TextStyle(color: Theme.of(context).colorScheme.green),
+        overflow: TextOverflow.ellipsis,
+      );
+    }
+  }
+}
+
+// TODO: implement this column.
+class NavigationColumn extends ColumnData<LinkData>
+    implements ColumnRenderer<LinkData> {
+  NavigationColumn()
+      : super(
+          '',
+          fixedWidthPx: scaleByFontFactor(40),
+        );
+
+  @override
+  String getValue(LinkData dataObject) => '';
+
+  @override
+  Widget build(
+    BuildContext context,
+    LinkData dataObject, {
+    bool isRowSelected = false,
+    VoidCallback? onPressed,
+  }) {
+    return const Icon(Icons.arrow_forward);
+  }
+}
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart
index c1ad7b0..0dc00c2 100644
--- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart
+++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart
@@ -2,9 +2,25 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'package:devtools_app_shared/ui.dart';
+import 'package:devtools_app_shared/utils.dart';
 import 'package:flutter/material.dart';
 
+import '../../shared/common_widgets.dart';
+import '../../shared/primitives/utils.dart';
 import '../../shared/screen.dart';
+import '../../shared/table/table.dart';
+import '../../shared/table/table_data.dart';
+import '../../shared/ui/colors.dart';
+import '../../shared/utils.dart';
+import 'deep_links_controller.dart';
+import 'deep_links_model.dart';
+
+enum TableViewType {
+  domainView,
+  pathView,
+  singleUrlView,
+}
 
 class DeepLinksScreen extends Screen {
   DeepLinksScreen() : super.fromMetaData(ScreenMetaData.deepLinks);
@@ -17,8 +33,273 @@
 
   @override
   Widget build(BuildContext context) {
-    return const Center(
-      child: Text('TODO: build deep link validation tool'),
+    return const DeepLinkPage();
+  }
+}
+
+class DeepLinkPage extends StatefulWidget {
+  const DeepLinkPage({super.key});
+
+  @override
+  State<DeepLinkPage> createState() => _DeepLinkPageState();
+}
+
+class _DeepLinkPageState extends State<DeepLinkPage>
+    with
+        AutoDisposeMixin,
+        SingleTickerProviderStateMixin,
+        ProvidedControllerMixin<DeepLinksController, DeepLinkPage> {
+  @override
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+    if (!initController()) return;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return DefaultTabController(
+      length: TableViewType.values.length,
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          AreaPaneHeader(
+            title: Text(
+              'All deep links',
+              style: Theme.of(context).textTheme.bodyLarge,
+            ),
+            actions: [
+              SizedBox(
+                width: wideSearchFieldWidth,
+                child: DevToolsClearableTextField(
+                  labelText: '',
+                  hintText: 'Search a URL, domain or path',
+                  prefixIcon: const Icon(Icons.search),
+                  onChanged: (value) {
+                    controller.searchContent = value;
+                  },
+                ),
+              ),
+            ],
+          ),
+          const SizedBox(height: denseSpacing),
+          const TabBar(
+            tabs: [
+              Text('Domain view'),
+              Text('Path view'),
+              Text('Single URL view'),
+            ],
+            tabAlignment: TabAlignment.start,
+            isScrollable: true,
+          ),
+          Expanded(
+            child: ValueListenableBuilder<List<LinkData>>(
+              valueListenable: controller.linkDatasNotifier,
+              builder: (context, linkDatas, _) => ValueListenableBuilder<bool>(
+                valueListenable: controller.showSpitScreenNotifier,
+                builder: (context, showSpitScreen, _) => TabBarView(
+                  children: [
+                    _DataTableWithValidationDetails(
+                      tableView: TableViewType.domainView,
+                      linkDatas: controller.getLinkDatasByDomain,
+                      controller: controller,
+                      showSpitScreen: showSpitScreen,
+                    ),
+                    _DataTableWithValidationDetails(
+                      tableView: TableViewType.pathView,
+                      linkDatas: controller.getLinkDatasByPath,
+                      controller: controller,
+                      showSpitScreen: showSpitScreen,
+                    ),
+                    _DataTableWithValidationDetails(
+                      tableView: TableViewType.singleUrlView,
+                      linkDatas: linkDatas,
+                      controller: controller,
+                      showSpitScreen: showSpitScreen,
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _DataTableWithValidationDetails extends StatelessWidget {
+  const _DataTableWithValidationDetails({
+    required this.linkDatas,
+    required this.tableView,
+    required this.controller,
+    required this.showSpitScreen,
+  });
+  final List<LinkData> linkDatas;
+  final TableViewType tableView;
+  final DeepLinksController controller;
+  final bool showSpitScreen;
+
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      children: [
+        Expanded(
+          child: _DataTable(
+            tableView: tableView,
+            linkDatas: linkDatas,
+            controller: controller,
+          ),
+        ),
+        if (showSpitScreen)
+          Expanded(
+            child: ValueListenableBuilder<LinkData?>(
+              valueListenable: controller.selectedLink,
+              builder: (context, selectedLink, _) => _ValidationDetailScreen(
+                tableView: tableView,
+                linkData: selectedLink!,
+                controller: controller,
+              ),
+            ),
+          ),
+      ],
+    );
+  }
+}
+
+class _DataTable extends StatelessWidget {
+  const _DataTable({
+    required this.linkDatas,
+    required this.tableView,
+    required this.controller,
+  });
+  final List<LinkData> linkDatas;
+  final TableViewType tableView;
+  final DeepLinksController controller;
+
+  @override
+  Widget build(BuildContext context) {
+    final ColumnData<LinkData> domain = DomainColumn();
+    final ColumnData<LinkData> path = PathColumn();
+
+    return FlatTable(
+      keyFactory: (node) => ValueKey(node.toString),
+      data: linkDatas,
+      dataKey: 'deep-links',
+      autoScrollContent: true,
+      columns: <ColumnData>[
+        if (tableView != TableViewType.pathView) domain,
+        if (tableView != TableViewType.domainView) path,
+        SchemeColumn(),
+        OSColumn(),
+        if (!controller.showSpitScreen) ...[
+          StatusColumn(),
+          NavigationColumn(),
+        ],
+      ],
+      selectionNotifier: controller.selectedLink,
+      defaultSortColumn: tableView == TableViewType.pathView ? path : domain,
+      defaultSortDirection: SortDirection.ascending,
+      onItemSelected: (item) => controller.showSpitScreenNotifier.value = true,
+    );
+  }
+}
+
+class _ValidationDetailScreen extends StatelessWidget {
+  const _ValidationDetailScreen({
+    required this.linkData,
+    required this.tableView,
+    required this.controller,
+  });
+
+  final LinkData linkData;
+  final TableViewType tableView;
+  final DeepLinksController controller;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.symmetric(horizontal: largeSpacing),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              const Text('Selected Deep link validation details'),
+              IconButton(
+                onPressed: () =>
+                    controller.showSpitScreenNotifier.value = false,
+                icon: const Icon(Icons.close),
+              ),
+            ],
+          ),
+          Text(
+            'This tool assistants helps you diagnose Universal Links, App Links,'
+            ' and Custom Schemes in your app. Web check are done for the web association'
+            ' file on your website. App checks are done for the intent filters in'
+            ' the manifest and info.plist file, routing issues, URL format, etc.',
+            style: Theme.of(context).textTheme.bodySmall,
+          ),
+          const Text('Domain check'),
+          Expanded(child: _DomainCheckTable(linkData: linkData)),
+        ],
+      ),
+    );
+  }
+}
+
+class _DomainCheckTable extends StatelessWidget {
+  const _DomainCheckTable({
+    required this.linkData,
+  });
+
+  final LinkData linkData;
+
+  @override
+  Widget build(BuildContext context) {
+    return DataTable(
+      columns: const [
+        DataColumn(label: Text('OS')),
+        DataColumn(label: Text('Issue type')),
+        DataColumn(label: Text('Status')),
+      ],
+      rows: [
+        if (linkData.os.contains('Android'))
+          DataRow(
+            cells: [
+              const DataCell(Text('Android')),
+              const DataCell(Text('Digital assets link file')),
+              DataCell(
+                linkData.domainError
+                    ? Text(
+                        'Check failed',
+                        style: TextStyle(
+                          color: Theme.of(context).colorScheme.error,
+                        ),
+                      )
+                    : Text(
+                        'No issues found',
+                        style: TextStyle(
+                          color: Theme.of(context).colorScheme.green,
+                        ),
+                      ),
+              ),
+            ],
+          ),
+        if (linkData.os.contains('iOS'))
+          DataRow(
+            cells: [
+              const DataCell(Text('iOS')),
+              const DataCell(Text('Apple-App-Site-Association file')),
+              DataCell(
+                Text(
+                  'No issues found',
+                  style: TextStyle(color: Theme.of(context).colorScheme.green),
+                ),
+              ),
+            ],
+          ),
+      ],
     );
   }
 }
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/fake_data.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/fake_data.dart
new file mode 100644
index 0000000..2e16a18
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/deep_link_validation/fake_data.dart
@@ -0,0 +1,40 @@
+// Copyright 2023 The Chromium 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 'deep_links_model.dart';
+
+/// Fake data for demo usage. Will replece this file with real deep link data.
+const paths = <String>[
+  '/shoes/..*',
+  '/Clothes/..*',
+  '/Toys/..*',
+  '/Jewelry/..*',
+  '/Watches/..* ',
+  '/Glasses/..*',
+];
+
+final allLinkDatas = <LinkData>[
+  for (var path in paths)
+    LinkData(
+      os: ['Android', 'iOS'],
+      domain: 'm.shopping.com',
+      path: path,
+      domainError: true,
+      pathError: path.contains('shoe'),
+    ),
+  for (var path in paths)
+    LinkData(
+      os: ['iOS'],
+      domain: 'm.french.shopping.com',
+      path: path,
+      pathError: path.contains('shoe'),
+    ),
+  for (var path in paths)
+    LinkData(
+      os: ['Android'],
+      domain: 'm.chinese.shopping.com',
+      path: path,
+      pathError: path.contains('shoe'),
+    ),
+];
diff --git a/packages/devtools_app/lib/src/shared/common_widgets.dart b/packages/devtools_app/lib/src/shared/common_widgets.dart
index bf4f20e..b2fa723 100644
--- a/packages/devtools_app/lib/src/shared/common_widgets.dart
+++ b/packages/devtools_app/lib/src/shared/common_widgets.dart
@@ -847,6 +847,7 @@
     required this.labelText,
     TextEditingController? controller,
     this.hintText,
+    this.prefixIcon,
     this.onChanged,
     this.autofocus = false,
   })  : controller = controller ?? TextEditingController(),
@@ -854,6 +855,7 @@
 
   final TextEditingController controller;
   final String? hintText;
+  final Widget? prefixIcon;
   final String labelText;
   final Function(String)? onChanged;
   final bool autofocus;
@@ -873,6 +875,7 @@
         border: const OutlineInputBorder(),
         labelText: labelText,
         hintText: hintText,
+        prefixIcon: prefixIcon,
         suffixIcon: IconButton(
           tooltip: 'Clear',
           icon: const Icon(Icons.clear),
diff --git a/packages/devtools_app/lib/src/shared/ui/colors.dart b/packages/devtools_app/lib/src/shared/ui/colors.dart
index 7faef30..39f3b7a 100644
--- a/packages/devtools_app/lib/src/shared/ui/colors.dart
+++ b/packages/devtools_app/lib/src/shared/ui/colors.dart
@@ -145,6 +145,7 @@
       isLight ? Colors.grey[50]! : const Color(0xFF1B1B1F);
 
   Color get grey => const Color.fromARGB(255, 128, 128, 128);
+  Color get green => const Color.fromARGB(255, 156, 233, 195);
 
   Color get overlayShadowColor => const Color.fromRGBO(0, 0, 0, 0.5);
 }
diff --git a/packages/devtools_app_shared/lib/src/ui/theme/theme.dart b/packages/devtools_app_shared/lib/src/ui/theme/theme.dart
index 8c9caf4..e01723a 100644
--- a/packages/devtools_app_shared/lib/src/ui/theme/theme.dart
+++ b/packages/devtools_app_shared/lib/src/ui/theme/theme.dart
@@ -259,6 +259,7 @@
 double get inputDecorationElementHeight => scaleByFontFactor(20.0);
 
 // Padding / spacing constants:
+const largeSpacing = 32.0;
 const defaultSpacing = 16.0;
 const intermediateSpacing = 12.0;
 const denseSpacing = 8.0;