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;