diff --git a/.widget_preview/.gitignore b/.widget_preview/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/.widget_preview/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/.widget_preview/README.md b/.widget_preview/README.md new file mode 100644 index 0000000..d09c93b --- /dev/null +++ b/.widget_preview/README.md @@ -0,0 +1,4 @@ +# Widget Preview Scaffold + +This project is generated by `flutter widget-preview` and is used to host Widgets +to be previewed in the widget previewer. diff --git a/.widget_preview/analysis_options.yaml b/.widget_preview/analysis_options.yaml new file mode 100644 index 0000000..cedcc10 --- /dev/null +++ b/.widget_preview/analysis_options.yaml @@ -0,0 +1,38 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/.widget_preview/lib/main.dart b/.widget_preview/lib/main.dart new file mode 100644 index 0000000..0dd047b --- /dev/null +++ b/.widget_preview/lib/main.dart @@ -0,0 +1,9 @@ +// Copyright 2014 The Flutter 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 'src/widget_preview_rendering.dart'; + +Future main() async { + await mainImpl(); +} diff --git a/.widget_preview/lib/src/controls.dart b/.widget_preview/lib/src/controls.dart new file mode 100644 index 0000000..ffe5f50 --- /dev/null +++ b/.widget_preview/lib/src/controls.dart @@ -0,0 +1,593 @@ +// Copyright 2014 The Flutter 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:flutter/foundation.dart'; + +import 'theme/theme.dart'; +import 'widget_preview_scaffold_controller.dart'; + +enum _PreviewSearchFilter { + groupName('Group name'), + previewName('Preview name'), + containingScript('Containing script'), + containingPackage('Containing package'); + + const _PreviewSearchFilter(this.label); + + final String label; +} + +abstract class _SearchFilterConfig { + const _SearchFilterConfig(this.filter, this._controller); + + final _PreviewSearchFilter filter; + final WidgetPreviewScaffoldController _controller; + + String get label => filter.label; + + ValueListenable listenable(); + + bool onToggle(); +} + +class _GroupSearchFilter extends _SearchFilterConfig { + const _GroupSearchFilter(WidgetPreviewScaffoldController controller) + : super(_PreviewSearchFilter.groupName, controller); + + @override + ValueListenable listenable() => _controller.searchByGroupNameListenable; + + @override + bool onToggle() => _controller.toggleSearchByGroupName(); +} + +class _PreviewNameSearchFilter extends _SearchFilterConfig { + const _PreviewNameSearchFilter(WidgetPreviewScaffoldController controller) + : super(_PreviewSearchFilter.previewName, controller); + + @override + ValueListenable listenable() => + _controller.searchByPreviewNameListenable; + + @override + bool onToggle() => _controller.toggleSearchByPreviewName(); +} + +class _ContainingScriptSearchFilter extends _SearchFilterConfig { + const _ContainingScriptSearchFilter( + WidgetPreviewScaffoldController controller, + ) : super(_PreviewSearchFilter.containingScript, controller); + + @override + ValueListenable listenable() => + _controller.searchByContainingScriptListenable; + + @override + bool onToggle() => _controller.toggleSearchByContainingScript(); +} + +class _ContainingPackageSearchFilter extends _SearchFilterConfig { + const _ContainingPackageSearchFilter( + WidgetPreviewScaffoldController controller, + ) : super(_PreviewSearchFilter.containingPackage, controller); + + @override + ValueListenable listenable() => + _controller.searchByContainingPackageListenable; + + @override + bool onToggle() => _controller.toggleSearchByContainingPackage(); +} + +/// Provides controls to change the zoom level of a [WidgetPreview]. +class ZoomControls extends StatelessWidget { + /// Provides controls to change the zoom level of a [WidgetPreview]. + const ZoomControls({super.key, required this._transformationController}); + + static const double _minScale = 1.0; + static const double _maxScale = 4.0; + + final TransformationController _transformationController; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return _ControlDecorator( + child: ValueListenableBuilder( + valueListenable: _transformationController, + builder: (context, matrix, _) { + final double scale = matrix.entry(0, 0); + final String scalePercentage = '${(scale * 100).toStringAsFixed(0)}%'; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Zoom out', + style: theme.iconButtonTheme.style, + onPressed: scale > _minScale ? _zoomOut : null, + icon: const Icon(Icons.zoom_out), + color: theme.colorScheme.onSurface, + disabledColor: theme.colorScheme.onSurface.withValues( + alpha: 0.38, + ), + ), + SizedBox( + width: 100, + height: defaultButtonHeight, + child: Slider( + min: _minScale, + max: _maxScale, + value: scale.clamp(_minScale, _maxScale), + onChanged: _setScale, + ), + ), + IconButton( + tooltip: 'Zoom in', + style: theme.iconButtonTheme.style, + onPressed: scale < _maxScale ? _zoomIn : null, + icon: const Icon(Icons.zoom_in_sharp), + color: theme.colorScheme.onSurface, + disabledColor: theme.colorScheme.onSurface.withValues( + alpha: 0.38, + ), + ), + IconButton( + tooltip: 'Reset zoom', + style: theme.iconButtonTheme.style, + onPressed: scale != _minScale ? _reset : null, + icon: const Icon(Icons.zoom_out_map), + color: theme.colorScheme.onSurface, + disabledColor: theme.colorScheme.onSurface.withValues( + alpha: 0.38, + ), + ), + SizedBox( + width: 36, + child: Text( + scalePercentage, + textAlign: TextAlign.end, + style: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + }, + ), + ); + } + + void _zoomIn() { + final double currentScale = _transformationController.value.entry(0, 0); + _setScale(currentScale + 0.25); + } + + void _zoomOut() { + final double currentScale = _transformationController.value.entry(0, 0); + _setScale(currentScale - 0.25); + } + + void _setScale(double scale) { + final double clampedScale = scale.clamp(_minScale, _maxScale); + _transformationController.value = Matrix4.diagonal3Values( + clampedScale, + clampedScale, + 1.0, + ); + } + + void _reset() { + _transformationController.value = Matrix4.identity(); + } +} + +class _ControlDecorator extends StatelessWidget { + const _ControlDecorator({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(densePadding), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: defaultBorderRadius, + ), + child: child, + ); + } +} + +/// Allows for controlling the grid vs layout view in the preview environment. +class LayoutTypeSelector extends StatelessWidget { + const LayoutTypeSelector({super.key, required this.controller}); + + final WidgetPreviewScaffoldController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return _ControlDecorator( + child: ValueListenableBuilder( + valueListenable: controller.layoutTypeListenable, + builder: (context, selectedLayout, _) { + return Row( + children: [ + IconButton( + style: theme.iconButtonTheme.style, + visualDensity: VisualDensity.compact, + onPressed: () => controller.layoutType = LayoutType.gridView, + icon: Icon(Icons.grid_on), + color: selectedLayout == LayoutType.gridView + ? Colors.blue + : Colors.black, + ), + IconButton( + onPressed: () => controller.layoutType = LayoutType.listView, + visualDensity: VisualDensity.compact, + icon: Icon(Icons.view_list), + color: selectedLayout == LayoutType.listView + ? Colors.blue + : Colors.black, + ), + ], + ); + }, + ), + ); + } +} + +class WidgetInspectorToggle extends StatelessWidget { + const WidgetInspectorToggle({super.key, required this.controller}); + + final WidgetPreviewScaffoldController controller; + + @override + Widget build(BuildContext context) { + return _ControlDecorator( + child: ValueListenableBuilder( + valueListenable: controller.widgetInspectorVisible, + builder: (context, widgetInspectorVisible, _) { + final theme = Theme.of(context); + return IconButton( + style: theme.iconButtonTheme.style, + visualDensity: VisualDensity.compact, + onPressed: controller.toggleWidgetInspectorVisible, + // TODO(bkonyi): replace with widget inspector icon. + icon: Icon(Icons.image_search), + color: widgetInspectorVisible ? Colors.blue : Colors.black, + ); + }, + ), + ); + } +} + +/// A toggle button that enables / disables filtering previews by the currently +/// selected source file. +/// +/// This control is hidden if the DTD Editor service isn't available. +class FilterBySelectedFileToggle extends StatelessWidget { + const FilterBySelectedFileToggle({super.key, required this.controller}); + + @visibleForTesting + static const kTooltip = 'Filter previews by selected file'; + + final WidgetPreviewScaffoldController controller; + + @override + Widget build(BuildContext context) { + return _ControlDecorator( + child: ValueListenableBuilder( + valueListenable: controller.filterBySelectedFileListenable, + builder: (context, value, child) { + return IconButton( + onPressed: controller.toggleFilterBySelectedFile, + icon: Icon(Icons.file_open), + color: value ? Colors.blue : Colors.black, + tooltip: kTooltip, + ); + }, + ), + ); + } +} + +/// A button that triggers a "soft" restart of a previewed widget. +/// +/// A soft restart removes the previewed widget from the widget tree for a frame before +/// re-inserting it on the next frame. This has the effect of re-running local initializers in +/// State objects, which normally requires a hot restart to accomplish in a normal application. +class SoftRestartButton extends StatelessWidget { + const SoftRestartButton({super.key, required this.softRestartListenable}); + + final ValueNotifier softRestartListenable; + + @override + Widget build(BuildContext context) { + return _ControlDecorator( + child: IconButton( + tooltip: 'Hot restart', + onPressed: _onRestart, + icon: Icon(Icons.refresh), + color: Colors.black, + ), + ); + } + + void _onRestart() { + softRestartListenable.value = true; + } +} + +/// A button that triggers a restart of the widget previewer through a hot restart request made +/// through DTD. +class WidgetPreviewerRestartButton extends StatelessWidget { + const WidgetPreviewerRestartButton({super.key, required this.controller}); + + final WidgetPreviewScaffoldController controller; + + @override + Widget build(BuildContext context) { + return _ControlDecorator( + child: IconButton( + tooltip: 'Restart the Widget Previewer', + onPressed: controller.dtdServices.hotRestartPreviewer, + icon: Icon(Icons.restart_alt), + color: Colors.black, + ), + ); + } +} + +/// Controls for searching and filtering widget previews. +/// +/// This widget combines a text query field with a popup menu for selecting +/// which preview fields are included in search. +class PreviewSearchControls extends StatefulWidget { + const PreviewSearchControls({super.key, required this.controller}); + + final WidgetPreviewScaffoldController controller; + + @override + State createState() => _PreviewSearchControlsState(); +} + +class _PreviewSearchControlsState extends State { + late final TextEditingController _searchController; + late final List<_SearchFilterConfig> _searchFilters; + + @override + void initState() { + super.initState(); + _searchFilters = <_SearchFilterConfig>[ + _GroupSearchFilter(widget.controller), + _PreviewNameSearchFilter(widget.controller), + _ContainingScriptSearchFilter(widget.controller), + _ContainingPackageSearchFilter(widget.controller), + ]; + _searchController = TextEditingController( + text: widget.controller.searchQueryListenable.value, + ); + widget.controller.searchQueryListenable.addListener( + _syncControllerQueryToTextField, + ); + } + + @override + void didUpdateWidget(covariant PreviewSearchControls oldWidget) { + super.didUpdateWidget(oldWidget); + if (identical(oldWidget.controller, widget.controller)) { + return; + } + oldWidget.controller.searchQueryListenable.removeListener( + _syncControllerQueryToTextField, + ); + widget.controller.searchQueryListenable.addListener( + _syncControllerQueryToTextField, + ); + _searchFilters + ..clear() + ..addAll(<_SearchFilterConfig>[ + _GroupSearchFilter(widget.controller), + _PreviewNameSearchFilter(widget.controller), + _ContainingScriptSearchFilter(widget.controller), + _ContainingPackageSearchFilter(widget.controller), + ]); + _syncControllerQueryToTextField(); + } + + @override + void dispose() { + widget.controller.searchQueryListenable.removeListener( + _syncControllerQueryToTextField, + ); + _searchController.dispose(); + super.dispose(); + } + + void _syncControllerQueryToTextField() { + final query = widget.controller.searchQueryListenable.value; + if (_searchController.text == query) { + return; + } + + _searchController.value = _searchController.value.copyWith( + text: query, + selection: TextSelection.collapsed(offset: query.length), + composing: TextRange.empty, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return _ControlDecorator( + child: Row( + children: [ + Expanded( + child: SizedBox( + height: defaultButtonHeight, + child: TextField( + controller: _searchController, + style: theme.regularTextStyleWithColor(Colors.black), + cursorColor: Colors.black, + textAlignVertical: TextAlignVertical.center, + onChanged: widget.controller.updateSearchQuery, + decoration: InputDecoration( + isDense: true, + hintText: 'Search previews', + hintStyle: theme.regularTextStyleWithColor(Colors.black54), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: densePadding, + horizontal: denseSpacing, + ), + prefixIcon: Icon( + Icons.search, + size: defaultIconSize, + color: Colors.black54, + ), + suffixIcon: _SearchClearButton(controller: widget.controller), + ), + ), + ), + ), + Container(height: 16, width: 1, color: Colors.black26), + _SearchFiltersMenuButton(searchFilters: _searchFilters), + ], + ), + ); + } +} + +class _SearchClearButton extends StatelessWidget { + const _SearchClearButton({required this.controller}); + + final WidgetPreviewScaffoldController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ValueListenableBuilder( + valueListenable: controller.searchQueryListenable, + builder: (context, query, _) { + if (query.isEmpty) { + return const SizedBox.shrink(); + } + return IconButton( + tooltip: 'Clear search', + style: theme.iconButtonTheme.style, + visualDensity: VisualDensity.compact, + icon: Icon(Icons.clear, size: defaultIconSize), + color: Colors.black, + onPressed: () => controller.updateSearchQuery(''), + ); + }, + ); + } +} + +class _SearchFiltersMenuButton extends StatelessWidget { + const _SearchFiltersMenuButton({required this.searchFilters}); + + final List<_SearchFilterConfig> searchFilters; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ListenableBuilder( + listenable: Listenable.merge( + searchFilters + .map((filter) => filter.listenable()) + .toList(growable: false), + ), + builder: (context, _) { + final allFiltersEnabled = searchFilters.every( + (filter) => filter.listenable().value, + ); + return PopupMenuButton<_PreviewSearchFilter>( + tooltip: 'Search fields', + style: theme.iconButtonTheme.style, + iconColor: allFiltersEnabled ? Colors.black : Colors.blue, + iconSize: defaultIconSize, + icon: const Icon(Icons.filter_list), + onSelected: (_PreviewSearchFilter selected) { + final didToggle = searchFilters + .firstWhere((filter) => filter.filter == selected) + .onToggle(); + if (!didToggle) { + _showNoRemainingSearchFilterSnackBar(context); + } + }, + itemBuilder: (context) { + return searchFilters + .map( + (filter) => CheckedPopupMenuItem<_PreviewSearchFilter>( + value: filter.filter, + checked: filter.listenable().value, + child: Text(filter.label), + ), + ) + .toList(growable: false); + }, + ); + }, + ); + } + + void _showNoRemainingSearchFilterSnackBar(BuildContext context) { + final scaffoldMessenger = ScaffoldMessenger.of(context); + scaffoldMessenger + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('At least one search field must remain enabled.'), + ), + ); + } +} + +extension on Brightness { + Brightness get invert => isLight ? Brightness.dark : Brightness.light; + bool get isLight => this == Brightness.light; +} + +/// A button that toggles the current theme brightness. +class BrightnessToggleButton extends StatelessWidget { + const BrightnessToggleButton({super.key, required this.brightnessListenable}); + + final ValueNotifier brightnessListenable; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: brightnessListenable, + builder: (context, brightness, _) { + final brightness = brightnessListenable.value; + return _ControlDecorator( + child: IconButton( + tooltip: 'Switch to ${brightness.isLight ? 'dark' : 'light'} mode', + onPressed: _toggleBrightness, + icon: Icon(brightness.isLight ? Icons.dark_mode : Icons.light_mode), + color: Colors.black, + ), + ); + }, + ); + } + + void _toggleBrightness() { + brightnessListenable.value = brightnessListenable.value.invert; + } +} diff --git a/.widget_preview/lib/src/dtd/dtd_connection_info.dart b/.widget_preview/lib/src/dtd/dtd_connection_info.dart new file mode 100644 index 0000000..4cf65d2 --- /dev/null +++ b/.widget_preview/lib/src/dtd/dtd_connection_info.dart @@ -0,0 +1,9 @@ +// ignore_for_file: implementation_imports + +const String kWidgetPreviewDtdUri = 'ws://127.0.0.1:40395/5YUcVSkoXko='; +const String kWidgetPreviewService = + 'widget-preview-293db269-cca2-49da-8059-28e5b582b22e'; +const String kWidgetPreviewScaffoldStream = + 'WidgetPreviewScaffold-293db269-cca2-49da-8059-28e5b582b22e'; +const String kProjectRootPath = + r'/home/quadradical/Documents/Code/material_emoji_picker'; diff --git a/.widget_preview/lib/src/dtd/dtd_services.dart b/.widget_preview/lib/src/dtd/dtd_services.dart new file mode 100644 index 0000000..37551f5 --- /dev/null +++ b/.widget_preview/lib/src/dtd/dtd_services.dart @@ -0,0 +1,130 @@ +// Copyright 2014 The Flutter 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 'package:dtd/dtd.dart'; +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:widget_preview_scaffold/src/dtd/dtd_connection_info.dart'; +import 'package:widget_preview_scaffold/src/dtd/editor_service.dart'; +import 'package:widget_preview_scaffold/src/dtd/utils.dart'; + +/// Provides services, streams, and RPC invocations to interact with Flutter developer tooling. +class WidgetPreviewScaffoldDtdServices with DtdEditorService { + // WARNING: Keep these constants and services in sync with those defined in the widget preview + // scaffold's dtd_services.dart. + // + // START KEEP SYNCED + + static const kIsWindows = 'isWindows'; + static const kHotRestartPreviewer = 'hotRestartPreviewer'; + static const kResolveUri = 'resolveUri'; + static const kSetPreference = 'setPreference'; + static const kGetPreference = 'getPreference'; + static const kGetDevToolsUri = 'getDevToolsUri'; + + /// Error code for RpcException thrown when attempting to load a key from + /// persistent preferences that doesn't have an entry. + static const kNoValueForKey = 200; + + // END KEEP SYNCED + + /// Connects to the Dart Tooling Daemon (DTD) specified by the Flutter tool. + /// + /// If the connection is successful, the Widget Preview Scaffold will register services and + /// subscribe to various streams to interact directly with other tooling (e.g., IDEs). + Future connect({Uri? dtdUri}) async { + final Uri dtdWsUri = dtdUri ?? Uri.parse(kWidgetPreviewDtdUri); + dtd = await DartToolingDaemon.connect(dtdWsUri); + unawaited( + dtd.postEvent( + kWidgetPreviewScaffoldStream, + 'Connected', + const {}, + ), + ); + await _determineIfWindows(); + await initializeEditorService(this); + } + + /// Disposes the DTD connection. + @override + Future dispose() async { + super.dispose(); + await dtd.close(); + } + + Future _call( + String methodName, { + Map? params, + }) => dtd.safeCall(kWidgetPreviewService, methodName, params: params); + + /// Returns `true` if the operating system is Windows. + late final bool isWindows; + + Future _determineIfWindows() async { + isWindows = (BoolResponse.fromDTDResponse( + (await _call(kIsWindows))!, + )).value!; + } + + /// Trigger a hot restart of the widget preview scaffold. + Future hotRestartPreviewer() => _call(kHotRestartPreviewer); + + /// Resolves a package:// URI to a file:// URI using the package_config. + /// + /// Returns null if [uri] can not be resolved. + Future resolveUri(Uri uri) async { + final response = await _call(kResolveUri, params: {'uri': uri.toString()}); + if (response == null) { + return null; + } + final result = StringResponse.fromDTDResponse(response).value; + return result == null ? null : Uri.parse(result); + } + + /// Retrieves an arbitrary value associated with [key] from the persistent + /// preferences map. + /// + /// Returns null if [key] is not in the map. + Future getPreference(String key) async { + try { + final response = await _call(kGetPreference, params: {'key': key}); + return switch (response?.type) { + 'StringResponse' => StringResponse.fromDTDResponse(response!).value, + 'BoolResponse' => BoolResponse.fromDTDResponse(response!).value, + _ => throw StateError('Unexpected response type: ${response?.type}'), + }; + } on RpcException catch (e) { + if (e.code == kNoValueForKey) { + return null; + } + rethrow; + } + } + + /// Retrieves the state of flag [key] from the persistent preferences map. + /// + /// If [key] is not set, [defaultValue] is returned. + Future getFlag(String key, {bool defaultValue = false}) async { + final result = await getPreference(key) as bool?; + return result ?? defaultValue; + } + + /// Sets [key] to [value] in the persistent preferences map. + Future setPreference(String key, Object? value) async { + await _call(kSetPreference, params: {'key': key, 'value': value}); + } + + /// Retrieves the DevTools URI for the previewer instance. + Future getDevToolsUri() async { + final result = StringResponse.fromDTDResponse( + (await _call(kGetDevToolsUri))!, + ); + return Uri.parse(result.value!); + } + + @override + late final DartToolingDaemon dtd; +} diff --git a/.widget_preview/lib/src/dtd/editor_service.dart b/.widget_preview/lib/src/dtd/editor_service.dart new file mode 100644 index 0000000..722c9db --- /dev/null +++ b/.widget_preview/lib/src/dtd/editor_service.dart @@ -0,0 +1,395 @@ +// Copyright 2014 The Flutter 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 'package:dtd/dtd.dart'; +import 'package:flutter/foundation.dart'; +import 'package:widget_preview_scaffold/src/dtd/dtd_services.dart'; +import 'package:widget_preview_scaffold/src/dtd/utils.dart'; + +/// Provides support for interacting with the Editor DTD service registered by IDE plugins. +mixin DtdEditorService { + DartToolingDaemon get dtd; + + /// The name of the Editor service. + static const String kEditorService = 'Editor'; + + /// The name of the Editor's getActiveLocation method. + static const String kGetActiveLocation = 'getActiveLocation'; + + /// The name of the Editor's navigateToCode method. + static const String kNavigateToCode = 'navigateToCode'; + + /// The name of the DTD Service stream. + static const String kServiceStream = 'Service'; + + /// The kind of the event sent over the [kServiceStream] stream when a new + /// service method is registered. + static const kServiceRegistered = 'ServiceRegistered'; + + /// The kind of the event sent over the [kServiceStream] stream when a + /// service method is unregistered. + static const kServiceUnregistered = 'ServiceUnregistered'; + + /// Whether or not the Editor service is available. + ValueListenable get editorServiceAvailable => _editorServiceAvailable; + static final _editorServiceAvailable = ValueNotifier(false); + + /// The currently selected source file in the IDE. + ValueListenable get selectedSourceFile => _selectedSourceFile; + static final _selectedSourceFile = ValueNotifier(null); + + /// The current theming set in the IDE. + ValueListenable get editorTheme => _editorTheme; + static final _editorTheme = ValueNotifier(null); + + /// Start listening for events on the Editor stream. + Future initializeEditorService( + WidgetPreviewScaffoldDtdServices dtdServices, + ) async { + final editorKindMap = EditorEventKind.values.asNameMap(); + dtd.onEvent(kEditorService).listen((data) { + final kind = editorKindMap[data.kind]; + switch (kind) { + // Unknown event. Use null here so we get exhaustiveness checking for + // the rest. + case null: + break; + case EditorEventKind.themeChanged: + _editorTheme.value = ThemeChangedEvent.fromJson(data.data).theme; + case EditorEventKind.activeLocationChanged: + _selectedSourceFile.value = ActiveLocationChangedEvent.fromJson( + data.data, + ).textDocument; + } + }); + await dtd.safeStreamListen(kEditorService); + + dtd.onEvent(kServiceStream).listen((data) async { + switch (data) { + case DTDEvent( + kind: kServiceRegistered, + data: { + DtdParameters.service: kEditorService, + DtdParameters.method: kGetActiveLocation, + }, + ): + // Manually retrieve the currently selected source file. + unawaited(_updateSelectedSourceFile()); + _editorServiceAvailable.value = true; + case DTDEvent( + kind: kServiceRegistered, + data: {DtdParameters.service: kEditorService}, + ): + _editorServiceAvailable.value = true; + case DTDEvent( + kind: kServiceUnregistered, + data: {DtdParameters.service: kEditorService}, + ): + _editorServiceAvailable.value = false; + } + }); + await dtd.safeStreamListen(kServiceStream); + } + + @mustCallSuper + void dispose() { + _selectedSourceFile.dispose(); + _editorServiceAvailable.dispose(); + _editorTheme.dispose(); + } + + Future _updateSelectedSourceFile() async { + final response = await dtd.safeCall(kEditorService, kGetActiveLocation); + if (response != null) { + _selectedSourceFile.value = ActiveLocation.fromJson( + response.result, + ).textDocument; + } + } + + /// Tells the editor to navigate to a given code [location]. + /// + /// Only locations with `file://` URIs are valid. + Future navigateToCode(CodeLocation location) async { + await dtd.safeCall( + kEditorService, + kNavigateToCode, + params: location.toJson(), + ); + } +} + +// TODO(bkonyi): much of the following code is copied from the DevTools codebase. We should publish +// a package containing these DTD services. See https://github.com/flutter/devtools/issues/9306. + +/// Known kinds of events that may come from the editor. +/// +/// This list is not guaranteed to match actual events from any given editor as +/// the editor might not implement all functionality or may be a future version +/// running against an older version of this code/DevTools. +enum EditorEventKind { + /// The kind for a [ThemeChangedEvent]. + themeChanged, + + /// The kind for an [ActiveLocationChangedEvent] event. + activeLocationChanged, +} + +/// A base class for all known events that an editor can produce. +/// +/// The set of subclasses is not guaranteed to match actual events from any +/// given editor as the editor might not implement all functionality or may be a +/// future version running against an older version of this code/DevTools. +sealed class EditorEvent { + EditorEventKind get kind; +} + +/// UI settings for an editor's theme. +class EditorTheme { + EditorTheme({ + required this.isDarkMode, + required this.backgroundColor, + required this.foregroundColor, + required this.fontSize, + }); + + EditorTheme.fromJson(Map map) + : this( + isDarkMode: map[Field.isDarkMode] as bool, + backgroundColor: map[Field.backgroundColor] as String?, + foregroundColor: map[Field.foregroundColor] as String?, + fontSize: map[Field.fontSize] as int?, + ); + + final bool isDarkMode; + final String? backgroundColor; + final String? foregroundColor; + final int? fontSize; + + Map toJson() => { + Field.isDarkMode: isDarkMode, + Field.backgroundColor: backgroundColor, + Field.foregroundColor: foregroundColor, + Field.fontSize: fontSize, + }; +} + +class ThemeChangedEvent extends EditorEvent { + ThemeChangedEvent({required this.theme}); + + ThemeChangedEvent.fromJson(Map map) + : this( + theme: EditorTheme.fromJson(map[Field.theme] as Map), + ); + + final EditorTheme theme; + + @override + EditorEventKind get kind => EditorEventKind.themeChanged; + + Map toJson() => {Field.theme: theme}; +} + +/// An event sent by an editor when the current cursor position/s change. +class ActiveLocationChangedEvent extends ActiveLocation implements EditorEvent { + ActiveLocationChangedEvent({required ActiveLocation activeLocation}) + : super( + selections: activeLocation.selections, + textDocument: activeLocation.textDocument, + ); + + ActiveLocationChangedEvent.fromJson(Map map) + : this(activeLocation: ActiveLocation.fromJson(map)); + + @override + EditorEventKind get kind => EditorEventKind.activeLocationChanged; +} + +class ActiveLocation { + ActiveLocation({required this.selections, required this.textDocument}); + + ActiveLocation.fromJson(Map map) + : this( + textDocument: map.containsKey(Field.textDocument) + ? TextDocument.fromJson( + map[Field.textDocument] as Map, + ) + : null, + selections: (map[Field.selections] as List) + .cast>() + .map(EditorSelection.fromJson) + .toList(), + ); + + final List selections; + final TextDocument? textDocument; + + Map toJson() => { + Field.selections: selections, + Field.textDocument: textDocument, + }; +} + +/// A reference to a text document in the editor. +/// +/// The [uriAsString] is a file URI to the text document. +/// +/// The [version] is an integer corresponding to LSP's +/// [VersionedTextDocumentIdentifier](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#versionedTextDocumentIdentifier) +class TextDocument { + TextDocument({required this.uriAsString, required this.version}); + + TextDocument.fromJson(Map map) + : this( + uriAsString: map[Field.uri] as String, + version: map[Field.version] as int?, + ); + + final String uriAsString; + final int? version; + + Map toJson() => { + Field.uri: uriAsString, + Field.version: version, + }; + + @override + bool operator ==(Object other) { + return other is TextDocument && + other.uriAsString == uriAsString && + other.version == version; + } + + @override + int get hashCode => Object.hash(uriAsString, version); +} + +/// The starting and ending cursor positions in the editor. +class EditorSelection { + EditorSelection({required this.active, required this.anchor}); + + EditorSelection.fromJson(Map map) + : this( + active: CursorPosition.fromJson( + map[Field.active] as Map, + ), + anchor: CursorPosition.fromJson( + map[Field.anchor] as Map, + ), + ); + + final CursorPosition active; + final CursorPosition anchor; + + Map toJson() => { + Field.active: active.toJson(), + Field.anchor: anchor.toJson(), + }; +} + +/// A range in the editor expressed as (zero-based) start and end positions. +class EditorRange { + EditorRange({required this.start, required this.end}); + + EditorRange.fromJson(Map map) + : this( + start: CursorPosition.fromJson( + map[Field.start] as Map, + ), + end: CursorPosition.fromJson(map[Field.end] as Map), + ); + + /// The range's start position. + final CursorPosition start; + + /// The range's end position. + final CursorPosition end; + + Map toJson() => { + Field.start: start.toJson(), + Field.end: end.toJson(), + }; +} + +/// Representation of a single cursor position in the editor. +/// +/// The cursor position is after the given [character] of the [line]. +class CursorPosition { + CursorPosition({required this.character, required this.line}); + + CursorPosition.fromJson(Map map) + : this( + character: map[Field.character] as int, + line: map[Field.line] as int, + ); + + /// The zero-based character number of this position. + final int character; + + /// The zero-based line number of this position. + final int line; + + Map toJson() => { + Field.character: character, + Field.line: line, + }; + + @override + bool operator ==(Object other) { + return other is CursorPosition && + other.character == character && + other.line == line; + } + + @override + int get hashCode => Object.hash(character, line); +} + +/// Parameters for the `navigateToCode` request. +class CodeLocation { + const CodeLocation({required this.uri, this.line, this.column}); + + /// The URI of the location to navigate to. Only `file://` URIs are supported + /// unless the service registration's `capabilities` indicate other schemes + /// are supported. + /// + /// Editors should return error code 144 if a caller passes a URI with an + /// unsupported scheme. + final String uri; + + /// Optional 1-based line number to navigate to. + final int? line; + + /// Optional 1-based column number to navigate to. + final int? column; + + Map toJson() => { + Field.uri: uri, + Field.line: ?line, + Field.column: ?column, + }; +} + +/// Constants for all fields used in JSON maps to avoid literal strings that +/// may have typos sprinkled throughout the API classes. +abstract class Field { + static const active = 'active'; + static const anchor = 'anchor'; + static const backgroundColor = 'backgroundColor'; + static const character = 'character'; + static const column = 'column'; + static const end = 'end'; + static const fontSize = 'fontSize'; + static const foregroundColor = 'foregroundColor'; + static const isDarkMode = 'isDarkMode'; + static const line = 'line'; + static const selections = 'selections'; + static const start = 'start'; + static const textDocument = 'textDocument'; + static const theme = 'theme'; + static const uri = 'uri'; + static const version = 'version'; +} diff --git a/.widget_preview/lib/src/dtd/utils.dart b/.widget_preview/lib/src/dtd/utils.dart new file mode 100644 index 0000000..292fedf --- /dev/null +++ b/.widget_preview/lib/src/dtd/utils.dart @@ -0,0 +1,39 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @docImport: package:dtd/dtd.dart +import 'package:dtd/dtd.dart'; +import 'package:json_rpc_2/json_rpc_2.dart'; + +extension WidgetPreviewScaffoldDtdUtils on DartToolingDaemon { + /// A [streamListen] implementation that ignores already subscribed exceptions. + Future safeStreamListen(String streamId) async { + try { + await streamListen(streamId); + } on RpcException catch (e) { + if (e.code != RpcErrorCodes.kStreamAlreadySubscribed) { + // TODO(bkonyi): consider logging an error. + rethrow; + } + } + } + + /// A [call] implementation that returns `null` if the service disappears or the method is not + /// found. + Future safeCall( + String? serviceName, + String methodName, { + Map? params, + }) async { + try { + return await call(serviceName, methodName, params: params); + } on RpcException catch (e) { + if (e.code != RpcErrorCodes.kMethodNotFound && + e.code != RpcErrorCodes.kServiceDisappeared) { + rethrow; + } + return null; + } + } +} diff --git a/.widget_preview/lib/src/generated_preview.dart b/.widget_preview/lib/src/generated_preview.dart new file mode 100644 index 0000000..0099e46 --- /dev/null +++ b/.widget_preview/lib/src/generated_preview.dart @@ -0,0 +1,6 @@ +// ignore_for_file: implementation_imports + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'widget_preview.dart' as _i1; + +List<_i1.WidgetPreview> previews() => []; diff --git a/.widget_preview/lib/src/split.dart b/.widget_preview/lib/src/split.dart new file mode 100644 index 0000000..0a07fff --- /dev/null +++ b/.widget_preview/lib/src/split.dart @@ -0,0 +1,365 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NOTE: most of the code in this file was pulled from the DevTools `Split` +// implementation. + +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'utils/pointer_events/pointer_events.dart'; + +// Method to convert degrees to radians +double degToRad(num deg) => deg * (math.pi / 180.0); + +/// A small double value, used to ensure that comparisons between double are +/// valid. +const defaultEpsilon = 1 / 1000; + +/// A widget that takes a list of children, lays them out along [axis], and +/// allows the user to resize them. +/// +/// The user can customize the amount of space allocated to each child by +/// dragging a divider between them. +/// +/// [initialFractions] defines how much space to give each child when building +/// this widget. +/// +/// [minSizes] defines the minimum size that each child can be set to when +/// adjusting the sizes of the children. +final class SplitPane extends StatefulWidget { + /// Builds a split oriented along [axis]. + SplitPane({ + super.key, + required this.axis, + required this.children, + required this.initialFractions, + this.minSizes, + this.splitters, + }) : assert(children.length >= 2), + assert(initialFractions.length >= 2), + assert(children.length == initialFractions.length) { + _verifyFractionsSumTo1(initialFractions); + if (minSizes != null) { + assert(minSizes!.length == children.length); + } + if (splitters != null) { + assert(splitters!.length == children.length - 1); + } + } + + /// The main axis the children will lay out on. + /// + /// If [Axis.horizontal], the children will be placed in a [Row] + /// and they will be horizontally resizable. + /// + /// If [Axis.vertical], the children will be placed in a [Column] + /// and they will be vertically resizable. + /// + /// Cannot be null. + final Axis axis; + + /// The children that will be laid out along [axis]. + final List children; + + /// The fraction of the layout to allocate to each child in [children]. + /// + /// The index of [initialFractions] corresponds to the child at index of + /// [children]. + final List initialFractions; + + /// The minimum size each child is allowed to be. + final List? minSizes; + + /// Splitter widgets to divide [children]. + /// + /// If this is null, a default splitter will be used to divide [children]. + final List? splitters; + + /// The key passed to the divider between children[index] and + /// children[index + 1]. + /// + /// Visible to grab it in tests. + @visibleForTesting + Key dividerKey(int index) => Key('$this dividerKey $index'); + + static Axis axisFor(BuildContext context, double horizontalAspectRatio) { + final screenSize = MediaQuery.of(context).size; + final aspectRatio = screenSize.width / screenSize.height; + if (aspectRatio >= horizontalAspectRatio) return Axis.horizontal; + return Axis.vertical; + } + + @override + State createState() => _SplitPaneState(); +} + +final class _SplitPaneState extends State { + late final List fractions; + bool _isDragging = false; + + bool get isHorizontal => widget.axis == Axis.horizontal; + + @override + void initState() { + super.initState(); + fractions = List.of(widget.initialFractions); + } + + @override + void dispose() { + if (_isDragging) { + toggleIframePointerEvents(false); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: _buildLayout); + } + + Widget _buildLayout(BuildContext _, BoxConstraints constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + final axisSize = isHorizontal ? width : height; + + final availableSize = axisSize - _totalSplitterSize(); + + // Size calculation helpers. + double minSizeForIndex(int index) { + if (widget.minSizes == null) return 0.0; + + double totalMinSize = 0; + for (final minSize in widget.minSizes!) { + totalMinSize += minSize; + } + + // Reduce the min sizes gracefully if the total required min size for all + // children is greater than the available size for children. + return totalMinSize > availableSize + ? widget.minSizes![index] * availableSize / totalMinSize + : widget.minSizes![index]; + } + + double minFractionForIndex(int index) => + minSizeForIndex(index) / availableSize; + + void clampFraction(int index) { + fractions[index] = fractions[index].clamp( + minFractionForIndex(index), + 1.0, + ); + } + + double sizeForIndex(int index) => availableSize * fractions[index]; + + double fractionDeltaRequired = 0.0; + double fractionDeltaAvailable = 0.0; + + double deltaFromMinimumSize(int index) => + fractions[index] - minFractionForIndex(index); + + for (int i = 0; i < fractions.length; ++i) { + final delta = deltaFromMinimumSize(i); + if (delta < 0) { + fractionDeltaRequired -= delta; + } else { + fractionDeltaAvailable += delta; + } + } + if (fractionDeltaRequired > 0) { + // Likely due to a change in the available size, the current fractions for + // the children do not obey the min size constraints. + // The min size constraints for children are scaled so it is always + // possible to meet them. A scaleFactor greater than 1 would indicate that + // it is impossible to meet the constraints. + double scaleFactor = fractionDeltaRequired / fractionDeltaAvailable; + assert(scaleFactor <= 1 + defaultEpsilon); + scaleFactor = math.min(scaleFactor, 1.0); + for (int i = 0; i < fractions.length; ++i) { + final delta = deltaFromMinimumSize(i); + if (delta < 0) { + // This is equivalent to adding delta but avoids rounding error. + fractions[i] = minFractionForIndex(i); + } else { + // Reduce all fractions that are above their minimum size by an amount + // proportional to their ability to reduce their size without + // violating their minimum size constraints. + fractions[i] -= delta * scaleFactor; + } + } + } + + // Determine what fraction to give each child, including enough space to + // display the divider. + final sizes = List.generate(fractions.length, (i) => sizeForIndex(i)); + + void updateSpacing(DragUpdateDetails dragDetails, int splitterIndex) { + final dragDelta = isHorizontal + ? dragDetails.delta.dx + : dragDetails.delta.dy; + final fractionalDelta = dragDelta / axisSize; + + // Returns the actual delta applied to elements before the splitter. + double updateSpacingBeforeSplitterIndex(double delta) { + final startingDelta = delta; + var index = splitterIndex; + while (index >= 0) { + fractions[index] += delta; + final minFraction = minFractionForIndex(index); + if (fractions[index] >= minFraction) { + clampFraction(index); + return startingDelta; + } + delta = fractions[index] - minFraction; + clampFraction(index); + index--; + } + // At this point, we know that both [startingDelta] and [delta] are + // negative, and that [delta] represents the overflow that did not get + // applied. + return startingDelta - delta; + } + + // Returns the actual delta applied to elements after the splitter. + double updateSpacingAfterSplitterIndex(double delta) { + final startingDelta = delta; + var index = splitterIndex + 1; + while (index < fractions.length) { + fractions[index] += delta; + final minFraction = minFractionForIndex(index); + if (fractions[index] >= minFraction) { + clampFraction(index); + return startingDelta; + } + delta = fractions[index] - minFraction; + clampFraction(index); + index++; + } + // At this point, we know that both [startingDelta] and [delta] are + // negative, and that [delta] represents the overflow that did not get + // applied. + return startingDelta - delta; + } + + setState(() { + // Update the fraction of space consumed by the children. Always update + // the shrinking children first so that we do not over-increase the size + // of the growing children and cause layout overflow errors. + if (fractionalDelta <= 0.0) { + final appliedDelta = updateSpacingBeforeSplitterIndex( + fractionalDelta, + ); + updateSpacingAfterSplitterIndex(-appliedDelta); + } else { + final appliedDelta = updateSpacingAfterSplitterIndex( + -fractionalDelta, + ); + updateSpacingBeforeSplitterIndex(-appliedDelta); + } + }); + _verifyFractionsSumTo1(fractions); + } + + final children = []; + for (int i = 0; i < widget.children.length; i++) { + children.addAll([ + SizedBox( + width: isHorizontal ? sizes[i] : width, + height: isHorizontal ? height : sizes[i], + child: widget.children[i], + ), + if (i < widget.children.length - 1) + MouseRegion( + cursor: isHorizontal + ? SystemMouseCursors.resizeColumn + : SystemMouseCursors.resizeRow, + child: GestureDetector( + key: widget.dividerKey(i), + behavior: HitTestBehavior.translucent, + onPanStart: (details) { + _isDragging = true; + toggleIframePointerEvents(true); + }, + onPanUpdate: (details) => updateSpacing(details, i), + onPanEnd: (details) { + _isDragging = false; + toggleIframePointerEvents(false); + }, + onPanCancel: () { + _isDragging = false; + toggleIframePointerEvents(false); + }, + // DartStartBehavior.down is needed to keep the mouse pointer stuck to + // the drag bar. There still appears to be a few frame lag before the + // drag action triggers which is't ideal but isn't a launch blocker. + dragStartBehavior: DragStartBehavior.down, + child: widget.splitters != null + ? widget.splitters![i] + : DefaultSplitter(isHorizontal: isHorizontal), + ), + ), + ]); + } + return Flex( + direction: widget.axis, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ); + } + + double _totalSplitterSize() { + final numSplitters = widget.children.length - 1; + if (widget.splitters == null) { + return numSplitters * DefaultSplitter.splitterWidth; + } else { + var totalSize = 0.0; + for (final splitter in widget.splitters!) { + totalSize += isHorizontal + ? splitter.preferredSize.width + : splitter.preferredSize.height; + } + return totalSize; + } + } +} + +final class DefaultSplitter extends StatelessWidget { + const DefaultSplitter({super.key, required this.isHorizontal}); + + static const iconSize = 24.0; + static const splitterWidth = 12.0; + + final bool isHorizontal; + + @override + Widget build(BuildContext context) { + return Transform.rotate( + angle: isHorizontal ? degToRad(90.0) : degToRad(0.0), + child: Align( + widthFactor: 0.5, + heightFactor: 0.5, + child: Icon( + Icons.drag_handle, + size: iconSize, + color: Theme.of(context).focusColor, + ), + ), + ); + } +} + +void _verifyFractionsSumTo1(List fractions) { + var sumFractions = 0.0; + for (final fraction in fractions) { + sumFractions += fraction; + } + assert( + (1.0 - sumFractions).abs() < defaultEpsilon, + 'Fractions should sum to 1.0, but instead sum to $sumFractions:\n$fractions', + ); +} diff --git a/.widget_preview/lib/src/theme/_ide_theme_desktop.dart b/.widget_preview/lib/src/theme/_ide_theme_desktop.dart new file mode 100644 index 0000000..6c8d16f --- /dev/null +++ b/.widget_preview/lib/src/theme/_ide_theme_desktop.dart @@ -0,0 +1,10 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NOTE: originally from package:devtools_app_shared + +import 'ide_theme.dart'; + +/// Load any IDE-supplied theming. +IdeTheme getIdeTheme() => IdeTheme(); diff --git a/.widget_preview/lib/src/theme/_ide_theme_web.dart b/.widget_preview/lib/src/theme/_ide_theme_web.dart new file mode 100644 index 0000000..d046142 --- /dev/null +++ b/.widget_preview/lib/src/theme/_ide_theme_web.dart @@ -0,0 +1,43 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NOTE: originally from package:devtools_app_shared + +import 'dart:ui'; + +import 'package:web/web.dart'; + +import '../utils/url/url.dart'; +import 'ide_theme.dart'; + +/// Load any IDE-supplied theming. +IdeTheme getIdeTheme() { + final queryParams = IdeThemeQueryParams(loadQueryParams()); + + final overrides = IdeTheme( + backgroundColor: queryParams.backgroundColor, + foregroundColor: queryParams.foregroundColor, + isDarkMode: queryParams.darkMode, + ); + + // If the environment has provided a background color, set it immediately + // to avoid a white page until the first Flutter frame is rendered. + if (overrides.backgroundColor != null) { + document.body!.style.backgroundColor = toCssHexColor( + overrides.backgroundColor!, + ); + } + + return overrides; +} + +/// Converts a dart:ui Color into #RRGGBBAA format for use in CSS. +String toCssHexColor(Color color) { + // In CSS Hex, Alpha comes last, but in Flutter's `value` field, alpha is + // in the high bytes, so just using `value.toRadixString(16)` will put alpha + // in the wrong position. + String hex(double channelValue) => + (channelValue * 255).round().toRadixString(16).padLeft(2, '0'); + return '#${hex(color.r)}${hex(color.g)}${hex(color.b)}${hex(color.a)}'; +} diff --git a/.widget_preview/lib/src/theme/ide_theme.dart b/.widget_preview/lib/src/theme/ide_theme.dart new file mode 100644 index 0000000..ebdd293 --- /dev/null +++ b/.widget_preview/lib/src/theme/ide_theme.dart @@ -0,0 +1,47 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NOTE: originally from package:devtools_app_shared + +import 'package:flutter/widgets.dart'; + +import '../utils/color_utils.dart'; +import 'theme.dart'; + +export '_ide_theme_desktop.dart' + if (dart.library.js_interop) '_ide_theme_web.dart'; + +/// IDE-supplied theming. +final class IdeTheme { + const IdeTheme({this.backgroundColor, this.foregroundColor, bool? isDarkMode}) + // ignore: prefer_initializing_formals + : _isDarkMode = isDarkMode; + + final Color? backgroundColor; + final Color? foregroundColor; + final bool? _isDarkMode; + + bool get isDarkMode => _isDarkMode ?? useDarkThemeAsDefault; + + /// Whether the IDE specified the DevTools color theme. + /// + /// If this returns false, that means the + /// [IdeThemeQueryParams.devToolsThemeKey] query parameter was not passed to + /// DevTools from the IDE. + bool get ideSpecifiedTheme => _isDarkMode != null; +} + +extension type IdeThemeQueryParams(Map params) { + Color? get backgroundColor => tryParseColor(params[backgroundColorKey]); + + Color? get foregroundColor => tryParseColor(params[foregroundColorKey]); + + bool get darkMode => params[devToolsThemeKey] != lightThemeValue; + + static const backgroundColorKey = 'backgroundColor'; + static const foregroundColorKey = 'foregroundColor'; + static const devToolsThemeKey = 'theme'; + static const lightThemeValue = 'light'; + static const darkThemeValue = 'dark'; +} diff --git a/.widget_preview/lib/src/theme/theme.dart b/.widget_preview/lib/src/theme/theme.dart new file mode 100644 index 0000000..332890f --- /dev/null +++ b/.widget_preview/lib/src/theme/theme.dart @@ -0,0 +1,365 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NOTE: originally from package:devtools_app_shared + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:widget_preview_scaffold/src/utils/color_utils.dart'; + +import 'ide_theme.dart'; + +// TODO(kenz): try to eliminate as many custom colors as possible, and pull +// colors only from the [lightColorScheme] and the [darkColorScheme]. + +/// Whether dark theme should be used as the default theme if none has been +/// explicitly set. +const useDarkThemeAsDefault = true; + +/// Constructs the light or dark theme for the app taking into account +/// IDE-supplied theming. +ThemeData themeFor({ + required bool isDarkTheme, + required IdeTheme ideTheme, + required ThemeData theme, +}) { + final colorTheme = isDarkTheme + ? _darkTheme(ideTheme: ideTheme, theme: theme) + : _lightTheme(ideTheme: ideTheme, theme: theme); + + return colorTheme.copyWith( + primaryTextTheme: theme.primaryTextTheme.merge(colorTheme.primaryTextTheme), + textTheme: theme.textTheme.merge(colorTheme.textTheme), + ); +} + +ThemeData _darkTheme({required IdeTheme ideTheme, required ThemeData theme}) { + final background = isValidDarkColor(ideTheme.backgroundColor) + ? ideTheme.backgroundColor! + : theme.colorScheme.surface; + return _baseTheme(theme: theme, backgroundColor: background); +} + +ThemeData _lightTheme({required IdeTheme ideTheme, required ThemeData theme}) { + final background = isValidLightColor(ideTheme.backgroundColor) + ? ideTheme.backgroundColor! + : theme.colorScheme.surface; + return _baseTheme(theme: theme, backgroundColor: background); +} + +ThemeData _baseTheme({ + required ThemeData theme, + required Color backgroundColor, +}) { + // TODO(kenz): do we need to pass in the foreground color from the [IdeTheme] + // as well as the background color? + const kCardRadius = Radius.circular(12); + return theme.copyWith( + tabBarTheme: theme.tabBarTheme.copyWith( + tabAlignment: TabAlignment.start, + labelStyle: theme.regularTextStyle, + labelPadding: const EdgeInsets.symmetric( + horizontal: defaultTabBarPadding, + ), + ), + canvasColor: backgroundColor, + scaffoldBackgroundColor: backgroundColor, + sliderTheme: theme.sliderTheme.copyWith( + trackHeight: 2.0, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5.0), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 10.0), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + padding: const EdgeInsets.all(densePadding), + minimumSize: const Size(defaultButtonHeight, defaultButtonHeight), + fixedSize: const Size(defaultButtonHeight, defaultButtonHeight), + iconSize: defaultIconSize, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(buttonMinWidth, defaultButtonHeight), + fixedSize: const Size.fromHeight(defaultButtonHeight), + foregroundColor: theme.colorScheme.onSurface, + padding: const EdgeInsets.symmetric(horizontal: denseSpacing), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + padding: const EdgeInsets.all(densePadding), + minimumSize: const Size(buttonMinWidth, defaultButtonHeight), + fixedSize: const Size.fromHeight(defaultButtonHeight), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(buttonMinWidth, defaultButtonHeight), + fixedSize: const Size.fromHeight(defaultButtonHeight), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: denseSpacing), + ), + ), + menuButtonTheme: MenuButtonThemeData( + style: ButtonStyle( + textStyle: WidgetStatePropertyAll(theme.regularTextStyle), + fixedSize: const WidgetStatePropertyAll(Size.fromHeight(24.0)), + ), + ), + expansionTileTheme: ExpansionTileThemeData( + backgroundColor: backgroundColor.brighten(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(kCardRadius), + ), + collapsedShape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(kCardRadius), + ), + ), + listTileTheme: ListTileThemeData( + dense: true, + tileColor: backgroundColor.brighten(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(kCardRadius), + ), + ), + dropdownMenuTheme: DropdownMenuThemeData(textStyle: theme.regularTextStyle), + primaryTextTheme: _devToolsTextTheme(theme, theme.primaryTextTheme), + textTheme: _devToolsTextTheme(theme, theme.textTheme), + colorScheme: theme.colorScheme.copyWith(surface: backgroundColor), + ); +} + +TextTheme _devToolsTextTheme(ThemeData theme, TextTheme textTheme) { + return textTheme.copyWith( + displayLarge: theme.boldTextStyle.copyWith(fontSize: 24), + displayMedium: theme.boldTextStyle.copyWith(fontSize: 22), + displaySmall: theme.boldTextStyle.copyWith(fontSize: 20), + headlineLarge: theme.regularTextStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + headlineMedium: theme.regularTextStyle.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + headlineSmall: theme.regularTextStyle.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + titleLarge: theme._largeText.copyWith(fontWeight: FontWeight.w500), + titleMedium: theme.regularTextStyle.copyWith(fontWeight: FontWeight.w500), + titleSmall: theme._smallText.copyWith(fontWeight: FontWeight.w500), + bodyLarge: theme._largeText, + bodyMedium: theme.regularTextStyle, + bodySmall: theme._smallText, + labelLarge: theme._largeText, + labelMedium: theme.regularTextStyle, + labelSmall: theme._smallText, + ); +} + +/// Light theme color scheme generated from DevTools Figma file. +/// +/// Do not manually change these values. +const lightColorScheme = ColorScheme( + brightness: Brightness.light, + primary: Color(0xFF195BB9), + onPrimary: Color(0xFFFFFFFF), + primaryContainer: Color(0xFFD8E2FF), + onPrimaryContainer: Color(0xFF001A41), + secondary: Color(0xFF575E71), + onSecondary: Color(0xFFFFFFFF), + secondaryContainer: Color(0xFFDBE2F9), + onSecondaryContainer: Color(0xFF141B2C), + tertiary: Color(0xFF815600), + onTertiary: Color(0xFFFFFFFF), + tertiaryContainer: Color(0xFFFFDDB1), + onTertiaryContainer: Color(0xFF291800), + error: Color(0xFFBA1A1A), + errorContainer: Color(0xFFFFDAD5), + onError: Color(0xFFFFFFFF), + onErrorContainer: Color(0xFF410002), + surface: Color(0xFFFFFFFF), + onSurface: Color(0xFF1B1B1F), + surfaceContainerHighest: Color(0xFFE1E2EC), + onSurfaceVariant: Color(0xFF44474F), + outline: Color(0xFF75777F), + onInverseSurface: Color(0xFFF2F0F4), + inverseSurface: Color(0xFF303033), + inversePrimary: Color(0xFFADC6FF), + shadow: Color(0xFF000000), + surfaceTint: Color(0xFF195BB9), + outlineVariant: Color(0xFFC4C6D0), + scrim: Color(0xFF000000), +); + +/// Dark theme color scheme generated from DevTools Figma file. +/// +/// Do not manually change these values. +const darkColorScheme = ColorScheme( + brightness: Brightness.dark, + primary: Color(0xFFADC6FF), + onPrimary: Color(0xFF002E69), + primaryContainer: Color(0xFF004494), + onPrimaryContainer: Color(0xFFD8E2FF), + secondary: Color(0xFFBFC6DC), + onSecondary: Color(0xFF293041), + secondaryContainer: Color(0xFF3F4759), + onSecondaryContainer: Color(0xFFDBE2F9), + tertiary: Color(0xFFFEBA4B), + onTertiary: Color(0xFF442B00), + tertiaryContainer: Color(0xFF624000), + onTertiaryContainer: Color(0xFFFFDDB1), + error: Color(0xFFFFB4AB), + errorContainer: Color(0xFF930009), + onError: Color(0xFF690004), + onErrorContainer: Color(0xFFFFDAD5), + surface: Color(0xFF1B1B1F), + onSurface: Color(0xFFC7C6CA), + surfaceContainerHighest: Color(0xFF44474F), + onSurfaceVariant: Color(0xFFC4C6D0), + outline: Color(0xFF8E9099), + onInverseSurface: Color(0xFF1B1B1F), + inverseSurface: Color(0xFFE3E2E6), + inversePrimary: Color(0xFF195BB9), + shadow: Color(0xFF000000), + surfaceTint: Color(0xFFADC6FF), + outlineVariant: Color(0xFF44474F), + scrim: Color(0xFF000000), +); + +/// Threshold used to determine whether a colour is light/dark enough for us to +/// override the default DevTools themes with. +/// +/// A value of 0.5 would result in all colours being considered light/dark, and +/// a value of 0.12 allowing around only the 12% darkest/lightest colours by +/// Flutter's luminance calculation. +/// 12% was chosen because VS Code's default light background color is #f3f3f3 +/// which is a little under 11%. +const _lightDarkLuminanceThreshold = 0.12; + +bool isValidDarkColor(Color? color) { + if (color == null) { + return false; + } + return color.computeLuminance() <= _lightDarkLuminanceThreshold; +} + +bool isValidLightColor(Color? color) { + if (color == null) { + return false; + } + return color.computeLuminance() >= 1 - _lightDarkLuminanceThreshold; +} + +// Size constants: +const defaultButtonHeight = 26.0; +const buttonMinWidth = 26.0; + +const defaultIconSize = 14.0; + +// Padding / spacing constants: +const extraLargeSpacing = 32.0; +const largeSpacing = 16.0; +const defaultSpacing = 12.0; +const intermediateSpacing = 10.0; +const denseSpacing = 8.0; + +const defaultTabBarPadding = 14.0; +const tabBarSpacing = 8.0; +const denseRowSpacing = 6.0; + +const densePadding = 4.0; + +// Other UI related constants: +final defaultBorderRadius = BorderRadius.circular(_defaultBorderRadiusValue); +const defaultRadius = Radius.circular(_defaultBorderRadiusValue); +const _defaultBorderRadiusValue = 16.0; + +const defaultElevation = 4.0; + +// Font size constants: +const largeFontSize = 14.0; +const defaultFontSize = 12.0; +const smallFontSize = 10.0; + +extension DevToolsSharedColorScheme on ColorScheme { + bool get isLight => brightness == Brightness.light; + + bool get isDark => brightness == Brightness.dark; + + Color get subtleTextColor => const Color(0xFF919094); + + Color get _devtoolsLink => + isLight ? const Color(0xFF1976D2) : Colors.lightBlueAccent; + + Color get tooltipTextColor => isLight ? Colors.white : Colors.black; +} + +/// Utility extension methods to the [ThemeData] class. +extension ThemeDataExtension on ThemeData { + /// Returns whether we are currently using a dark theme. + bool get isDarkTheme => brightness == Brightness.dark; + + TextStyle get regularTextStyle => fixBlurryText( + TextStyle(color: colorScheme.onSurface, fontSize: defaultFontSize), + ); + + TextStyle regularTextStyleWithColor(Color? color, {Color? backgroundColor}) => + regularTextStyle.copyWith(color: color, backgroundColor: backgroundColor); + + TextStyle get _smallText => + regularTextStyle.copyWith(fontSize: smallFontSize); + + TextStyle get _largeText => + regularTextStyle.copyWith(fontSize: largeFontSize); + + TextStyle get errorTextStyle => regularTextStyleWithColor(colorScheme.error); + + TextStyle get boldTextStyle => + regularTextStyle.copyWith(fontWeight: FontWeight.bold); + + TextStyle get subtleTextStyle => + regularTextStyle.copyWith(color: colorScheme.subtleTextColor); + + TextStyle get fixedFontStyle => fixBlurryText( + regularTextStyle.copyWith( + fontFamily: GoogleFonts.robotoMono().fontFamily, + // Slightly smaller for fixes font text since it will appear larger + // to begin with. + fontSize: defaultFontSize - 1, + ), + ); + + TextStyle get subtleFixedFontStyle => + fixedFontStyle.copyWith(color: colorScheme.subtleTextColor); + + TextStyle get selectedSubtleTextStyle => + subtleTextStyle.copyWith(color: colorScheme.onSurface); + + TextStyle get tooltipFixedFontStyle => + fixedFontStyle.copyWith(color: colorScheme.tooltipTextColor); + + TextStyle get fixedFontLinkStyle => fixedFontStyle.copyWith( + color: colorScheme._devtoolsLink, + decoration: TextDecoration.underline, + ); + + TextStyle get linkTextStyle => fixBlurryText( + TextStyle( + color: colorScheme._devtoolsLink, + decoration: TextDecoration.underline, + fontSize: defaultFontSize, + ), + ); +} + +/// Returns a [TextStyle] with [FontFeature.proportionalFigures] applied to +/// fix blurry text. +TextStyle fixBlurryText(TextStyle style) { + return style.copyWith( + fontFeatures: [const FontFeature.proportionalFigures()], + ); +} diff --git a/.widget_preview/lib/src/utils.dart b/.widget_preview/lib/src/utils.dart new file mode 100644 index 0000000..32949af --- /dev/null +++ b/.widget_preview/lib/src/utils.dart @@ -0,0 +1,202 @@ +// Copyright 2014 The Flutter 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:flutter/widget_previews.dart'; + +import 'widget_preview.dart'; + +Iterable buildMultiWidgetPreview({ + required String packageName, + required String scriptUri, + required int line, + required int column, + required MultiPreview preview, + required Object? Function() previewFunction, +}) { + return preview.transform().map( + (p) => buildWidgetPreview( + packageName: packageName, + scriptUri: scriptUri, + line: line, + column: column, + transformedPreview: p, + previewFunction: previewFunction, + ), + ); +} + +WidgetPreview buildWidgetPreview({ + required String packageName, + required String scriptUri, + required int line, + required int column, + required Preview transformedPreview, + required Object? Function() previewFunction, +}) { + Widget Function() previewBuilder; + if (previewFunction is WidgetBuilder Function()) { + previewBuilder = () { + return Builder(builder: previewFunction()); + }; + } else { + previewBuilder = previewFunction as Widget Function(); + } + return WidgetPreview( + builder: previewBuilder, + scriptUri: scriptUri, + line: line, + column: column, + previewData: transformedPreview, + packageName: packageName, + ); +} + +WidgetPreview buildWidgetPreviewError({ + required String packageName, + required String scriptUri, + required int line, + required int column, + required String packageUri, + required String functionName, + required bool dependencyHasErrors, +}) { + var errorMessage = '$packageUri has errors!'; + if (dependencyHasErrors) { + errorMessage = 'Dependency of $errorMessage'; + } + return WidgetPreview( + builder: () => Text('$functionName: $errorMessage'), + scriptUri: scriptUri, + line: line, + column: column, + previewData: const Preview(group: 'Invalid Previews'), + packageName: packageName, + ); +} + +/// A basic vertical spacer. +class VerticalSpacer extends StatelessWidget { + /// Creates a basic vertical spacer. + const VerticalSpacer({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox(height: 10); + } +} + +/// A basic horizontal spacer. +class HorizontalSpacer extends StatelessWidget { + /// Creates a basic vertical spacer. + const HorizontalSpacer({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox(width: 10); + } +} + +/// A widget that explicitly responds to hot reload events. +/// +/// Hot reload will always result in [reassemble] being called. +class HotReloadListener extends StatefulWidget { + const HotReloadListener({ + super.key, + required this.onHotReload, + required this.child, + }); + + final VoidCallback onHotReload; + final Widget child; + + @override + HotReloadListenerState createState() => HotReloadListenerState(); +} + +class HotReloadListenerState extends State { + @override + void reassemble() { + super.reassemble(); + widget.onHotReload(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// Wraps [child] in a border with default styling. +/// +/// This border can optionally be made non-uniform by setting any of +/// [showTop], [showBottom], [showLeft] or [showRight] to false. +/// +/// Originally from DevTools. +final class OutlineDecoration extends StatelessWidget { + const OutlineDecoration({ + super.key, + this.child, + this.showTop = true, + this.showBottom = true, + this.showLeft = true, + this.showRight = true, + }); + + factory OutlineDecoration.onlyBottom({required Widget? child}) => + OutlineDecoration( + showTop: false, + showLeft: false, + showRight: false, + child: child, + ); + + factory OutlineDecoration.onlyTop({required Widget? child}) => + OutlineDecoration( + showBottom: false, + showLeft: false, + showRight: false, + child: child, + ); + + factory OutlineDecoration.onlyLeft({required Widget? child}) => + OutlineDecoration( + showBottom: false, + showTop: false, + showRight: false, + child: child, + ); + + factory OutlineDecoration.onlyRight({required Widget? child}) => + OutlineDecoration( + showBottom: false, + showTop: false, + showLeft: false, + child: child, + ); + + final bool showTop; + final bool showBottom; + final bool showLeft; + final bool showRight; + + final Widget? child; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).focusColor; + final border = BorderSide(color: color); + return Container( + decoration: BoxDecoration( + border: Border( + left: showLeft ? border : BorderSide.none, + right: showRight ? border : BorderSide.none, + top: showTop ? border : BorderSide.none, + bottom: showBottom ? border : BorderSide.none, + ), + ), + child: child, + ); + } +} diff --git a/.widget_preview/lib/src/utils/color_utils.dart b/.widget_preview/lib/src/utils/color_utils.dart new file mode 100644 index 0000000..03df6d7 --- /dev/null +++ b/.widget_preview/lib/src/utils/color_utils.dart @@ -0,0 +1,70 @@ +// Copyright 2014 The Flutter 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:ui'; + +Color? tryParseColor(String? input) { + if (input == null) return null; + + try { + return parseCssHexColor(input); + } catch (e) { + return null; + } +} + +/// Parses a 3 or 6 digit CSS Hex Color into a dart:ui Color. +Color parseCssHexColor(String input) { + // Remove any leading # (and the escaped version to be lenient) + input = input.replaceAll('#', '').replaceAll('%23', ''); + + // Handle 3/4-digit hex codes (eg. #123 == #112233) + if (input.length == 3 || input.length == 4) { + input = input.split('').map((c) => '$c$c').join(); + } + + // Pad alpha with FF. + if (input.length == 6) { + input = '${input}ff'; + } + + // In CSS, alpha is in the lowest bits, but for Flutter's value, it's in the + // highest bits, so move the alpha from the end to the start before parsing. + if (input.length == 8) { + input = '${input.substring(6)}${input.substring(0, 6)}'; + } + final value = int.parse(input, radix: 16); + + return Color(value); +} + +/// Utility extension methods to the [Color] class. +extension ColorExtension on Color { + /// Return a slightly darker color than the current color. + Color darken([double percent = 0.05]) { + assert(0.0 <= percent && percent <= 1.0); + percent = 1.0 - percent; + + final c = this; + return Color.from( + alpha: c.a, + red: c.r * percent, + green: c.g * percent, + blue: c.b * percent, + ); + } + + /// Return a slightly brighter color than the current color. + Color brighten([double percent = 0.05]) { + assert(0.0 <= percent && percent <= 1.0); + + final c = this; + return Color.from( + alpha: c.a, + red: c.r + ((1.0 - c.r) * percent), + green: c.g + ((1.0 - c.g) * percent), + blue: c.b + ((1.0 - c.b) * percent), + ); + } +} diff --git a/.widget_preview/lib/src/utils/pointer_events/_pointer_events_stub.dart b/.widget_preview/lib/src/utils/pointer_events/_pointer_events_stub.dart new file mode 100644 index 0000000..8572078 --- /dev/null +++ b/.widget_preview/lib/src/utils/pointer_events/_pointer_events_stub.dart @@ -0,0 +1,38 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +bool _hasTestIframe = false; +String _pointerEventsStyle = ''; + +/// Toggles the `pointer-events` CSS property on all `iframe` elements in the document. +/// +/// Fakes the behavior on non-web platforms for testing. +void toggleIframePointerEvents(bool disable) { + if (_hasTestIframe) { + _pointerEventsStyle = disable ? 'none' : ''; + } +} + +/// Appends a test iframe to the document. +/// +/// Fakes the behavior on non-web platforms for testing. +void debugAppendTestIframe() { + _hasTestIframe = true; + _pointerEventsStyle = ''; +} + +/// Gets the pointer-events style of the test iframe. +/// +/// Fakes the behavior on non-web platforms for testing. +String? debugGetIframePointerEvents() { + return _hasTestIframe ? _pointerEventsStyle : null; +} + +/// Removes the test iframe. +/// +/// Fakes the behavior on non-web platforms for testing. +void debugRemoveTestIframe() { + _hasTestIframe = false; + _pointerEventsStyle = ''; +} diff --git a/.widget_preview/lib/src/utils/pointer_events/_pointer_events_web.dart b/.widget_preview/lib/src/utils/pointer_events/_pointer_events_web.dart new file mode 100644 index 0000000..bc137a0 --- /dev/null +++ b/.widget_preview/lib/src/utils/pointer_events/_pointer_events_web.dart @@ -0,0 +1,43 @@ +// Copyright 2014 The Flutter 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:web/web.dart' as web; + +/// Toggles the `pointer-events` CSS property on all `iframe` elements in the document. +/// +/// On Flutter Web, platform views (like WebViews/iframes) reside in their own HTML +/// DOM trees above the WebGL canvas. This means they capture native mouse events +/// and prevent them from reaching the parent window, causing the resize drag +/// interaction to lose focus if the cursor passes over the iframe. +/// +/// Setting `pointer-events: none` on the iframe DOM elements during a drag +/// operation bypasses this issue, causing the browser to ignore the iframe and +/// deliver all mouse movements to the parent window where the splitter's drag +/// listener can continue smoothly. +void toggleIframePointerEvents(bool disable) { + final iframes = web.document.querySelectorAll('iframe'); + for (int i = 0; i < iframes.length; i++) { + final iframe = iframes.item(i) as web.HTMLElement; + iframe.style.pointerEvents = disable ? 'none' : ''; + } +} + +web.HTMLIFrameElement? _testIframe; + +/// Appends a test iframe to the document. +void debugAppendTestIframe() { + _testIframe = web.HTMLIFrameElement(); + web.document.body!.appendChild(_testIframe!); +} + +/// Gets the pointer-events style of the test iframe. +String? debugGetIframePointerEvents() { + return _testIframe?.style.pointerEvents; +} + +/// Removes the test iframe. +void debugRemoveTestIframe() { + _testIframe?.remove(); + _testIframe = null; +} diff --git a/.widget_preview/lib/src/utils/pointer_events/pointer_events.dart b/.widget_preview/lib/src/utils/pointer_events/pointer_events.dart new file mode 100644 index 0000000..657343b --- /dev/null +++ b/.widget_preview/lib/src/utils/pointer_events/pointer_events.dart @@ -0,0 +1,6 @@ +// Copyright 2014 The Flutter 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 '_pointer_events_stub.dart' + if (dart.library.js_interop) '_pointer_events_web.dart'; diff --git a/.widget_preview/lib/src/utils/url/_url_stub.dart b/.widget_preview/lib/src/utils/url/_url_stub.dart new file mode 100644 index 0000000..51ebed0 --- /dev/null +++ b/.widget_preview/lib/src/utils/url/_url_stub.dart @@ -0,0 +1,24 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NOTE: originally from package:devtools_app_shared + +Map loadQueryParams() => {}; + +/// Gets the URL from the browser. +/// +/// Returns null for non-web platforms. +String? getWebUrl() => null; + +/// Performs a web redirect using window.location.replace(). +/// +/// No-op for non-web platforms. +// Unused parameter lint doesn't make sense for stub files. +void webRedirect(String url) {} + +/// Updates the query parameter with [key] to the new [value], and optionally +/// reloads the page when [reload] is true. +/// +/// No-op for non-web platforms. +void updateQueryParameter(String key, String? value, {bool reload = false}) {} diff --git a/.widget_preview/lib/src/utils/url/_url_web.dart b/.widget_preview/lib/src/utils/url/_url_web.dart new file mode 100644 index 0000000..875a52b --- /dev/null +++ b/.widget_preview/lib/src/utils/url/_url_web.dart @@ -0,0 +1,36 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NOTE: originally from package:devtools_app_shared + +import 'package:web/web.dart'; + +Map loadQueryParams({String Function(String)? urlModifier}) { + var url = getWebUrl()!; + url = urlModifier?.call(url) ?? url; + return Uri.parse(url).queryParameters; +} + +String? getWebUrl() => window.location.toString(); + +void webRedirect(String url) { + window.location.replace(url); +} + +void updateQueryParameter(String key, String? value, {bool reload = false}) { + final newQueryParams = Map.of(loadQueryParams()); + if (value == null) { + newQueryParams.remove(key); + } else { + newQueryParams[key] = value; + } + final newUri = Uri.parse( + window.location.toString(), + ).replace(queryParameters: newQueryParams); + window.history.replaceState(window.history.state, '', newUri.toString()); + + if (reload) { + window.location.reload(); + } +} diff --git a/.widget_preview/lib/src/utils/url/url.dart b/.widget_preview/lib/src/utils/url/url.dart new file mode 100644 index 0000000..7f15f82 --- /dev/null +++ b/.widget_preview/lib/src/utils/url/url.dart @@ -0,0 +1,7 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NOTE: originally from package:devtools_app_shared + +export '_url_stub.dart' if (dart.library.js_interop) '_url_web.dart'; diff --git a/.widget_preview/lib/src/widget_preview.dart b/.widget_preview/lib/src/widget_preview.dart new file mode 100644 index 0000000..9f1e65d --- /dev/null +++ b/.widget_preview/lib/src/widget_preview.dart @@ -0,0 +1,137 @@ +// Copyright 2014 The Flutter 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/foundation.dart'; +import 'package:flutter/widget_previews.dart'; +import 'package:flutter/widgets.dart'; + +/// A group of [WidgetPreview] instances sharing the same group name. +class WidgetPreviewGroup { + const WidgetPreviewGroup({required this.name, required this.previews}); + + /// Returns `false` if the group has no previews. + /// + /// This can happen if a filter is applied that results in no previews matching + /// the filter being part of the group. + bool get hasPreviews => previews.isNotEmpty; + + /// The name of the group, as specified by the 'group' parameter in [Preview]. + final String name; + + /// The set of preview instances which are part of a group with a given [name]. + final List previews; +} + +/// Wraps a [Widget], initializing various state and properties to allow for +/// previewing of the [Widget] in the widget previewer. +class WidgetPreview { + /// Wraps [builder] in a [WidgetPreview] instance that applies some set of + /// properties. + const WidgetPreview({ + required this.builder, + required this.scriptUri, + required this.line, + required this.column, + required this.previewData, + required this.packageName, + }); + + @visibleForTesting + const WidgetPreview.test({ + required this.builder, + required this.previewData, + this.scriptUri = '', + this.line = -1, + this.column = -1, + this.packageName = '', + }); + + /// The absolute file:// URI pointing to the script containing this preview. + /// + /// This matches the URI format sent by IDEs for active location change events. + final String scriptUri; + + /// The line at which the Preview annotation was applied. + final int line; + + /// The column at which the Preview annotation was applied. + final int column; + + /// The name of the package in which a preview was defined. + /// + /// For example, if a preview is defined in 'package:foo/src/bar.dart', this + /// will have the value 'foo'. + final String packageName; + + /// A description to be displayed alongside the preview. + /// + /// If not provided, no name will be associated with the preview. + String? get name => previewData.name; + + /// A callback to build the [Widget] to be rendered in the preview. + final Widget Function() builder; + + Widget Function() get previewBuilder { + if (previewData.wrapper == null) { + return builder; + } + return switch (previewData) { + Preview(:final Widget Function(Widget) wrapper) => () => wrapper( + builder(), + ), + _ => builder, + }; + } + + /// Artificial constraints to be applied to the previewed widget. + /// + /// If not provided, the previewed widget will attempt to set its own + /// constraints. + /// + /// If a dimension has a value of `double.infinity`, the previewed widget + /// will attempt to set its own constraints in the relevant dimension. + Size? get size => previewData.size; + + /// Applies font scaling to text within the [Widget] returned by [builder]. + /// + /// If not provided, the default text scaling factor provided by [MediaQuery] + /// will be used. + double? get textScaleFactor => previewData.textScaleFactor; + + /// Material and Cupertino theming data to be applied to the previewed [Widget]. + /// + /// If not provided, the default theme will be used. + PreviewThemeData? get theme => previewData.theme?.call(); + + /// Sets the initial theme brightness. + /// + /// If not provided, the current system default brightness will be used. + Brightness? get brightness => previewData.brightness; + + /// A callback to return a localization configuration to be applied to the + /// previewed [Widget]. + /// + /// Note: this must be a reference to a static, public function defined as + /// either a top-level function or static member in a class. + PreviewLocalizationsData? get localizations => + previewData.localizations?.call(); + + final Preview previewData; + + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('name', name, ifNull: 'not set')) + ..add(DiagnosticsProperty('group', previewData.group)) + ..add(DiagnosticsProperty('size', size)) + ..add(DiagnosticsProperty('textScaleFactor', textScaleFactor)) + ..add(DiagnosticsProperty('theme', theme)) + ..add(DiagnosticsProperty('brightness', brightness)) + ..add( + DiagnosticsProperty( + 'localizations', + localizations, + ), + ); + } +} diff --git a/.widget_preview/lib/src/widget_preview_inspector_service.dart b/.widget_preview/lib/src/widget_preview_inspector_service.dart new file mode 100644 index 0000000..224fb37 --- /dev/null +++ b/.widget_preview/lib/src/widget_preview_inspector_service.dart @@ -0,0 +1,89 @@ +// Copyright 2014 The Flutter 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/widgets.dart'; +import 'package:widget_preview_scaffold/src/dtd/dtd_connection_info.dart'; +import 'package:widget_preview_scaffold/src/dtd/dtd_services.dart'; +import 'package:widget_preview_scaffold/src/dtd/editor_service.dart'; +import 'package:widget_preview_scaffold/src/widget_preview_rendering.dart'; + +/// A custom [WidgetInspectorService] responsible for routing navigation events +/// to the IDE. +/// +/// IMPORTANT NOTE: this **must** be called before WidgetsFlutterBinding.ensureInitialized() +/// is called, otherwise the inspector service extensions will be registered against +/// the default WidgetInspectorService, causing overrides to not be invoked. +class WidgetPreviewScaffoldInspectorService with WidgetInspectorService { + WidgetPreviewScaffoldInspectorService({required this.dtdServices}) { + WidgetInspectorService.instance = this; + addPubRootDirectories([kProjectRootPath]); + } + + /// The DTD services instance used to communicate with the tool. + final WidgetPreviewScaffoldDtdServices dtdServices; + + // Keys used to specify the creation location of a widget when serializing a + // DiagnosticsNode to JSON. This location is used by the widget inspector + // to jump to the creation location of a selected widget. + static const kFile = 'fileUri'; + static const kLine = 'line'; + static const kColumn = 'column'; + + CodeLocation? _nextNavigationLocation; + + @protected + @override + bool setSelection(Object? object, [String? groupName]) { + // The next navigation event sent to `postEvent` will be for this selection. + // Save the location of preview annotation applications so we can override + // the navigation target in `postEvent`. + if (object is PreviewWidgetElement) { + final previewData = (object.widget as PreviewWidget).preview; + _nextNavigationLocation = CodeLocation( + uri: previewData.scriptUri, + line: previewData.line, + column: previewData.column, + ); + } + final result = super.setSelection(object, groupName); + _nextNavigationLocation = null; + return result; + } + + @override + void postEvent( + String eventKind, + Map eventData, { + String stream = 'Extension', + }) { + // It's unlikely that the widget previewer will be connected to directly by + // an IDE via the VM service, so we forward navigation events via the + // Editor DTD service. + if (eventKind == 'navigate') { + CodeLocation? location = _nextNavigationLocation; + if (eventData case { + kFile: final String file, + kLine: final int line, + kColumn: final int column, + } when location == null) { + location = CodeLocation(uri: file, line: line, column: column); + } else if (location != null) { + // If a [PreviewWidgetElement] was selected, we're not navigating to the + // creation location of the widget. Override the location details in the + // event data, just in case an IDE is attached and listening for + // navigation events through the VM service. + // TODO(bkonyi): determine if this is necessary + eventData.addAll({ + kFile: location.uri, + kLine: location.line!, + kColumn: location.column!, + }); + } + if (location != null) { + dtdServices.navigateToCode(location); + } + } + super.postEvent(eventKind, eventData, stream: stream); + } +} diff --git a/.widget_preview/lib/src/widget_preview_rendering.dart b/.widget_preview/lib/src/widget_preview_rendering.dart new file mode 100644 index 0000000..b731021 --- /dev/null +++ b/.widget_preview/lib/src/widget_preview_rendering.dart @@ -0,0 +1,1251 @@ +// Copyright 2014 The Flutter 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:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widget_previews.dart'; + +import 'package:stack_trace/stack_trace.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import 'package:widget_preview_scaffold/src/dtd/editor_service.dart'; +import 'package:widget_preview_scaffold/src/split.dart'; +import 'package:widget_preview_scaffold/src/theme/ide_theme.dart'; +import 'package:widget_preview_scaffold/src/theme/theme.dart'; + +import 'package:widget_preview_scaffold/src/controls.dart'; +import 'package:widget_preview_scaffold/src/generated_preview.dart'; +import 'package:widget_preview_scaffold/src/utils.dart'; +import 'package:widget_preview_scaffold/src/widget_preview.dart'; +import 'package:widget_preview_scaffold/src/widget_preview_inspector_service.dart'; +import 'package:widget_preview_scaffold/src/widget_preview_scaffold_controller.dart'; + +/// Displayed when an unhandled exception is thrown when initializing the widget +/// tree for a preview (i.e., before the build phase). +/// +/// Provides users with details about the thrown exception, including the exception +/// contents and a scrollable stack trace. +class WidgetPreviewErrorWidget extends StatelessWidget { + WidgetPreviewErrorWidget({ + super.key, + required this.controller, + required this.error, + required StackTrace stackTrace, + required this.size, + }) : trace = Trace.from(stackTrace).terse; + + final WidgetPreviewScaffoldController controller; + + /// The [Object] that was thrown, resulting in an unhandled exception. + final Object error; + + /// The stack trace identifying where [error] was thrown from. + final Trace trace; + + /// The size of the error widget. + final Size size; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + height: size.height, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Failed to initialize widget tree: ', + style: theme.boldTextStyle, + ), + TextSpan(text: error.toString(), style: theme.fixedFontStyle), + ], + ), + ), + Text('Stacktrace:', style: theme.boldTextStyle), + ValueListenableBuilder( + valueListenable: controller.editorServiceAvailable, + builder: (context, editorServiceAvailable, child) { + return SelectableText.rich( + TextSpan( + children: _formatFrames( + theme, + trace.frames, + editorServiceAvailable, + ), + style: theme.fixedFontStyle, + ), + ); + }, + ), + ], + ), + ), + ); + } + + List _formatFrames( + ThemeData theme, + List frames, + bool editorServiceAvailable, + ) { + // Figure out the longest path so we know how much to pad. + final int longest = frames + .map((frame) => frame.location.length) + .fold(0, math.max); + + // Print out the stack trace nicely formatted. + return frames.map((frame) { + if (frame is UnparsedFrame) return TextSpan(text: '$frame\n'); + // The Editor.navigateToCode service can't handle Dart core library paths, + // so don't allow for navigation to them. Also disable navigation if the + // Editor service isn't available. + final isLinkable = + (frame.uri.isScheme('file') || frame.uri.isScheme('package')) && + editorServiceAvailable; + final style = isLinkable + ? theme.fixedFontLinkStyle + : theme.fixedFontStyle; + return TextSpan( + children: [ + TextSpan( + text: frame.location, + style: style, + recognizer: isLinkable + ? (TapGestureRecognizer() + ..onTap = () async { + final resolvedUri = await controller.dtdServices + .resolveUri(frame.uri); + controller.dtdServices.navigateToCode( + CodeLocation( + uri: resolvedUri.toString(), + line: frame.line, + column: frame.column, + ), + ); + }) + : null, + ), + TextSpan(text: ' ' * (longest - frame.location.length)), + const TextSpan(text: ' '), + TextSpan(text: '${frame.member}\n', style: style), + ], + ); + }).toList(); + } +} + +/// Displayed when no @Preview() annotations are detected in the project. +/// +/// Links to documentation. +class NoPreviewsDetectedWidget extends StatelessWidget { + const NoPreviewsDetectedWidget({super.key}); + + static Uri documentationUrl = Uri.https('flutter.dev', 'to/widget-previews'); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Column( + children: [ + Text('No previews detected', style: theme.boldTextStyle), + const VerticalSpacer(), + Text('Read more about getting started with widget previews at:'), + Text.rich( + TextSpan( + text: documentationUrl.toString(), + style: theme.linkTextStyle, + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrl(documentationUrl); + }, + ), + ), + ], + ), + ); + } +} + +/// A wrapper that serves as the root entry for a single preview in the widget inspector. +class PreviewWidget extends StatelessWidget { + const PreviewWidget({super.key, required this.preview, required this.child}); + + final WidgetPreview preview; + final Widget child; + + @override + StatelessElement createElement() => PreviewWidgetElement(this); + + @override + Widget build(BuildContext context) { + return child; + } + + @override + String toStringShort() { + final StringBuffer buffer = StringBuffer( + '@${preview.previewData.runtimeType}', + ); + if (preview.name != null) { + buffer.write('(name: "${preview.name}")'); + } + return buffer.toString(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + preview.debugFillProperties(properties); + } +} + +/// A custom [StatelessElement] with the sole purpose of simplifying identifying +/// selections of @Preview annotations in the widget inspector. +class PreviewWidgetElement extends StatelessElement { + PreviewWidgetElement(super.widget); +} + +class WidgetPreviewGroupWidget extends StatelessWidget { + const WidgetPreviewGroupWidget({ + super.key, + required this.controller, + required this.group, + }); + + final WidgetPreviewScaffoldController controller; + final WidgetPreviewGroup group; + + // Spacing values for the grid layout + static const _gridSpacing = 8.0; + static const _gridRunSpacing = 8.0; + + /// The default radius of a Material 3 `Card`, as per documentation for `Card.shape`. + // TODO(bkonyi): inherit this from the theme. + static const _kCardRadius = Radius.circular(12); + + Widget _buildGridViewFlex(List previews) { + return Wrap( + spacing: WidgetPreviewGroupWidget._gridSpacing, + runSpacing: WidgetPreviewGroupWidget._gridRunSpacing, + alignment: WrapAlignment.start, + children: [ + for (final WidgetPreview preview in previews) + WidgetPreviewWidget(controller: controller, preview: preview), + ], + ); + } + + Widget _buildVerticalListView(List previews) { + return Column( + children: [ + for (final preview in previews) + Center( + child: WidgetPreviewWidget( + controller: controller, + preview: preview, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: ListTileTheme( + data: ListTileTheme.of(context).copyWith( + dense: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(_kCardRadius), + ), + ), + child: Theme( + // Prevents divider lines appearing at the top and bottom of the + // expanded ExpansionTile. + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + key: PageStorageKey(group.name), + title: Text(group.name), + initiallyExpanded: true, + children: [ + ValueListenableBuilder( + valueListenable: controller.layoutTypeListenable, + builder: (context, selectedLayout, _) { + return switch (selectedLayout) { + LayoutType.gridView => _buildGridViewFlex(group.previews), + LayoutType.listView => _buildVerticalListView( + group.previews, + ), + }; + }, + ), + ], + ), + ), + ), + ); + } +} + +class WidgetPreviewWidget extends StatefulWidget { + const WidgetPreviewWidget({ + super.key, + required this.preview, + required this.controller, + }); + + final WidgetPreview preview; + + final WidgetPreviewScaffoldController controller; + + @override + State createState() => WidgetPreviewWidgetState(); +} + +class WidgetPreviewWidgetState extends State { + final transformationController = TransformationController(); + + // Set the initial preview brightness based on the platform default or the + // value explicitly specified for the preview. + late final brightnessListenable = ValueNotifier( + widget.preview.brightness ?? MediaQuery.platformBrightnessOf(context), + ); + + final softRestartListenable = ValueNotifier(false); + final key = GlobalKey(); + + /// Returns the last size of the previewed widget. + Size get lastChildSize => + (key.currentContext!.findRenderObject() as RenderBox).size; + + @override + void didUpdateWidget(WidgetPreviewWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + final previousBrightness = oldWidget.preview.brightness; + final newBrightness = widget.preview.brightness; + final currentBrightness = brightnessListenable.value; + final systemBrightness = MediaQuery.platformBrightnessOf(context); + + // No initial brightness was previously defined. + if (previousBrightness == null && newBrightness != null) { + if (currentBrightness == systemBrightness) { + // If the current brightness is different than the system brightness, the user has manually + // changed the brightness through the UI, so don't change it automatically. + brightnessListenable.value = newBrightness; + } + } + // Changing the initial brightness to either a new initial brightness or system brightness. + else if (previousBrightness != null) { + // If the current brightness is different than the initial brightness, the user has manually + // changed the brightness through the UI, so don't change it automatically. + if (currentBrightness == previousBrightness) { + brightnessListenable.value = newBrightness ?? systemBrightness; + } + } + } + + @override + Widget build(BuildContext context) { + final previewerConstraints = + WidgetPreviewerWindowConstraints.getRootConstraints(context); + + final maxSizeConstraints = previewerConstraints.copyWith( + minHeight: previewerConstraints.maxHeight / 2.0, + maxHeight: previewerConstraints.maxHeight / 2.0, + ); + + bool errorThrownDuringTreeConstruction = false; + + // Wrap the previewed widget with a ValueListenableBuilder responsible for performing a "soft" + // restart. + // + // A soft restart simply removes the previewed widget from the widget tree for a frame before + // re-inserting it on the next frame. This has the effect of re-running local initializers in + // State objects, which normally requires a hot restart to accomplish in a normal application. + Widget preview = ValueListenableBuilder( + valueListenable: softRestartListenable, + builder: (context, performRestart, _) { + try { + final previewWidget = Container( + key: key, + child: WidgetPreviewTheming( + theme: widget.preview.theme, + child: EnableWidgetInspectorScope( + child: PreviewWidget( + preview: widget.preview, + child: widget.preview.previewBuilder(), + ), + ), + ), + ); + if (performRestart) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Trigger a rebuild on the next frame to re-insert previewWidget. + softRestartListenable.value = false; + }, debugLabel: 'Soft Restart'); + return SizedBox.fromSize(size: lastChildSize); + } + return previewWidget; + } on Object catch (error, stackTrace) { + // Catch any unhandled exceptions and display an error widget instead of taking + // down the entire preview environment. + errorThrownDuringTreeConstruction = true; + return WidgetPreviewErrorWidget( + controller: widget.controller, + error: error, + stackTrace: stackTrace, + size: maxSizeConstraints.biggest, + ); + } + }, + ); + + final Size? size = widget.preview.size; + + // Add support for selecting only previewed widgets via the widget + // inspector. + preview = ValueListenableBuilder( + valueListenable: + WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier, + builder: (context, enableWidgetInspector, child) { + // Don't allow inspecting the error widget. + if (child is WidgetPreviewErrorWidget) { + return child; + } + if (enableWidgetInspector) { + return WidgetInspector( + // TODO(bkonyi): wire up inspector controls for individual previews or + // the entire preview environment. This currently requires users to + // to enable widget selection via the Widget Inspector tool in DevTools. + + // These buttons would be rendered on top of the previewed widget, so + // don't display them. + exitWidgetSelectionButtonBuilder: null, + moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, + child: child!, + ); + } + return child!; + }, + child: _WidgetPreviewWrapper( + previewerConstraints: maxSizeConstraints, + child: SizedBox( + width: size?.width == double.infinity ? null : size?.width, + height: size?.height == double.infinity ? null : size?.height, + child: preview, + ), + ), + ); + + preview = WidgetPreviewMediaQueryOverride( + preview: widget.preview, + brightnessListenable: brightnessListenable, + child: preview, + ); + + preview = WidgetPreviewLocalizations( + localizationsData: widget.preview.localizations, + child: preview, + ); + + // Override the asset resolution behavior to automatically insert + // 'packages/$packageName/` in front of non-package paths as some previews + // may reference assets that are within the current project and wouldn't + // normally require a package specifier. + // TODO(bkonyi): this doesn't modify the behavior of asset loading logic in + // the engine implementation. This means that any asset loading done by + // APIs provided in dart:ui won't work correctly for non-package asset + // paths (e.g., shaders loaded by `FragmentProgram.fromAsset()`). + // + // See https://github.com/flutter/flutter/issues/171284 + preview = DefaultAssetBundle( + bundle: PreviewAssetBundle(packageName: widget.preview.packageName), + child: preview, + ); + + final hasName = widget.preview.name != null; + preview = Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (hasName) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + widget.preview.name!, + style: fixBlurryText( + TextStyle(fontSize: 16, fontWeight: FontWeight.w300), + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + // TODO(bkonyi): use theming or define global constants. + horizontal: 16.0, + ).add(hasName ? const EdgeInsets.only(top: 8.0) : EdgeInsets.zero), + decoration: hasName + ? BoxDecoration( + border: Border(top: Divider.createBorderSide(context)), + ) + : null, + child: Column( + children: [ + ZoomablePreviewArea( + transformationController: transformationController, + errorThrownDuringTreeConstruction: + errorThrownDuringTreeConstruction, + child: preview, + ), + const VerticalSpacer(), + Builder( + builder: (context) { + return _WidgetPreviewControlRow( + transformationController: transformationController, + errorThrownDuringTreeConstruction: + errorThrownDuringTreeConstruction, + brightnessListenable: brightnessListenable, + softRestartListenable: softRestartListenable, + ); + }, + ), + ], + ), + ), + ], + ); + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Card.outlined( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: preview, + ), + ), + ); + } +} + +class _WidgetPreviewControlRow extends StatelessWidget { + const _WidgetPreviewControlRow({ + required this.transformationController, + required this.errorThrownDuringTreeConstruction, + required this.brightnessListenable, + required this.softRestartListenable, + }); + + final TransformationController transformationController; + final bool errorThrownDuringTreeConstruction; + final ValueNotifier brightnessListenable; + final ValueNotifier softRestartListenable; + + @override + Widget build(BuildContext context) { + // Don't show controls if an error occurred. + if (errorThrownDuringTreeConstruction) { + return Container(); + } + return Row( + mainAxisSize: MainAxisSize.min, + // If an unhandled exception was caught and we're displaying an error + // widget, these controls should be disabled. + // TODO(bkonyi): improve layout of controls. + children: [ + ZoomControls(transformationController: transformationController), + const SizedBox(width: 30), + BrightnessToggleButton(brightnessListenable: brightnessListenable), + const SizedBox(width: 10), + SoftRestartButton(softRestartListenable: softRestartListenable), + ], + ); + } +} + +/// Applies theming defined in [theme] to [child]. +class WidgetPreviewTheming extends StatelessWidget { + const WidgetPreviewTheming({ + super.key, + required this.theme, + required this.child, + }); + + final Widget child; + + /// The set of themes to be applied to [child]. + final PreviewThemeData? theme; + + @override + Widget build(BuildContext context) { + final themeData = theme; + if (themeData == null) { + return child; + } + return themeData.apply(context, child); + } +} + +/// Wraps the previewed [child] with the correct [MediaQueryData] overrides +/// based on [preview] and the current device [Brightness]. +class WidgetPreviewMediaQueryOverride extends StatelessWidget { + const WidgetPreviewMediaQueryOverride({ + super.key, + required this.preview, + required this.brightnessListenable, + required this.child, + }); + + /// The preview specification used to render the preview. + final WidgetPreview preview; + + /// The currently set brightness for this preview instance. + final ValueListenable brightnessListenable; + + final Widget child; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: brightnessListenable, + builder: (context, brightness, _) { + return MediaQuery( + data: _buildMediaQueryOverride( + context: context, + brightness: brightness, + ), + // Use mediaQueryPreview instead of preview to avoid capturing preview + // and creating an infinite loop. + child: child, + ); + }, + ); + } + + MediaQueryData _buildMediaQueryOverride({ + required BuildContext context, + required Brightness brightness, + }) { + var mediaQueryData = MediaQuery.of( + context, + ).copyWith(platformBrightness: brightness); + + if (preview.textScaleFactor != null) { + mediaQueryData = mediaQueryData.copyWith( + textScaler: TextScaler.linear(preview.textScaleFactor!), + ); + } + + var size = Size( + preview.size?.width ?? mediaQueryData.size.width, + preview.size?.height ?? mediaQueryData.size.height, + ); + + if (preview.size != null) { + mediaQueryData = mediaQueryData.copyWith(size: size); + } + + return mediaQueryData; + } +} + +/// Wraps [child] with a [Localizations] with localization data from +/// [localizationsData]. +class WidgetPreviewLocalizations extends StatefulWidget { + const WidgetPreviewLocalizations({ + super.key, + required this.localizationsData, + required this.child, + }); + + final PreviewLocalizationsData? localizationsData; + final Widget child; + + @override + State createState() => + _WidgetPreviewLocalizationsState(); +} + +class _WidgetPreviewLocalizationsState + extends State { + PreviewLocalizationsData get _localizationsData => widget.localizationsData!; + late final LocalizationsResolver _localizationsResolver = + LocalizationsResolver( + supportedLocales: _localizationsData.supportedLocales, + locale: _localizationsData.locale, + localeListResolutionCallback: + _localizationsData.localeListResolutionCallback, + localeResolutionCallback: _localizationsData.localeResolutionCallback, + localizationsDelegates: _localizationsData.localizationsDelegates, + ); + + @override + void didUpdateWidget(WidgetPreviewLocalizations oldWidget) { + super.didUpdateWidget(oldWidget); + final PreviewLocalizationsData? localizationsData = + widget.localizationsData; + if (localizationsData == null) { + return; + } + _localizationsResolver.update( + supportedLocales: localizationsData.supportedLocales, + locale: localizationsData.locale, + localeListResolutionCallback: + localizationsData.localeListResolutionCallback, + localeResolutionCallback: localizationsData.localeResolutionCallback, + localizationsDelegates: localizationsData.localizationsDelegates, + ); + } + + @override + Widget build(BuildContext context) { + if (widget.localizationsData == null) { + return widget.child; + } + return ListenableBuilder( + listenable: _localizationsResolver, + builder: (context, _) { + return Localizations( + locale: _localizationsResolver.locale, + delegates: _localizationsResolver.localizationsDelegates.toList(), + child: widget.child, + ); + }, + ); + } +} + +/// An [InheritedWidget] that propagates the current size of the +/// WidgetPreviewScaffold. +/// +/// This is needed when determining how to put constraints on previewed widgets +/// that would otherwise have infinite constraints. +class WidgetPreviewerWindowConstraints extends InheritedWidget { + const WidgetPreviewerWindowConstraints({ + super.key, + required super.child, + required this.constraints, + }); + + final BoxConstraints constraints; + + static BoxConstraints getRootConstraints(BuildContext context) { + final result = context + .dependOnInheritedWidgetOfExactType(); + assert( + result != null, + 'No WidgetPreviewerWindowConstraints founds in context', + ); + return result!.constraints; + } + + @override + bool updateShouldNotify(WidgetPreviewerWindowConstraints oldWidget) { + return oldWidget.constraints != constraints; + } +} + +class ZoomablePreviewArea extends StatelessWidget { + const ZoomablePreviewArea({ + super.key, + required this.child, + required this.transformationController, + required this.errorThrownDuringTreeConstruction, + }); + + final Widget child; + final TransformationController transformationController; + final bool errorThrownDuringTreeConstruction; + + @override + Widget build(BuildContext context) { + if (errorThrownDuringTreeConstruction) { + return child; + } + return ListenableBuilder( + listenable: transformationController, + builder: (context, _) { + final double scale = transformationController.value.entry(0, 0); + return SingleChildScrollView( + scrollDirection: Axis.vertical, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _ScaledLayoutWrapper(scale: scale, child: child), + ), + ); + }, + ); + } +} + +class _ScaledLayoutWrapper extends SingleChildRenderObjectWidget { + const _ScaledLayoutWrapper({super.child, required this.scale}); + + final double scale; + + @override + RenderObject createRenderObject(BuildContext context) { + return _ScaledLayoutRenderObject(scale: scale); + } + + @override + void updateRenderObject( + BuildContext context, + _ScaledLayoutRenderObject renderObject, + ) { + renderObject.scale = scale; + } +} + +class _ScaledLayoutRenderObject extends RenderShiftedBox { + _ScaledLayoutRenderObject({required this._scale, RenderBox? child}) + : super(child); + + double _scale; + double get scale => _scale; + set scale(double value) { + if (_scale == value) { + return; + } + _scale = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child == null) { + return 0.0; + } + return child!.getMinIntrinsicWidth(height / scale) * scale; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child == null) { + return 0.0; + } + return child!.getMaxIntrinsicWidth(height / scale) * scale; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child == null) { + return 0.0; + } + return child!.getMinIntrinsicHeight(width / scale) * scale; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child == null) { + return 0.0; + } + return child!.getMaxIntrinsicHeight(width / scale) * scale; + } + + @override + void performLayout() { + final child = this.child; + if (child == null) { + size = Size.zero; + return; + } + child.layout(constraints, parentUsesSize: true); + size = constraints.constrain(child.size * scale); + + final BoxParentData childParentData = child.parentData! as BoxParentData; + childParentData.offset = Offset.zero; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) { + layer = null; + return; + } + if (scale == 1.0) { + super.paint(context, offset); + layer = null; + return; + } + final Matrix4 transform = Matrix4.diagonal3Values(scale, scale, 1.0); + layer = context.pushTransform( + needsCompositing, + offset, + transform, + super.paint, + oldLayer: layer is TransformLayer ? layer as TransformLayer? : null, + ); + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + if (scale != 1.0) { + transform.scaleByDouble(scale, scale, 1.0, 1.0); + } + super.applyPaintTransform(child, transform); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (child == null) { + return false; + } + final Matrix4 transform = Matrix4.diagonal3Values(scale, scale, 1.0); + return result.addWithPaintTransform( + transform: transform, + position: position, + hitTest: (BoxHitTestResult result, Offset position) { + return super.hitTestChildren(result, position: position); + }, + ); + } +} + +// TODO(bkonyi): according to goderbauer@, this probably isn't the best approach to ensure we +// handle unconstrained widgets. This should be reworked. +/// Wrapper applying a custom render object to force constraints on +/// unconstrained widgets. +class _WidgetPreviewWrapper extends SingleChildRenderObjectWidget { + const _WidgetPreviewWrapper({ + super.child, + required this.previewerConstraints, + }); + + /// The size of the previewer render surface. + final BoxConstraints previewerConstraints; + + @override + RenderObject createRenderObject(BuildContext context) { + return _WidgetPreviewWrapperBox( + previewerConstraints: previewerConstraints, + child: null, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _WidgetPreviewWrapperBox renderObject, + ) { + renderObject.setPreviewerConstraints(previewerConstraints); + } +} + +/// Custom render box that forces constraints onto unconstrained widgets. +class _WidgetPreviewWrapperBox extends RenderShiftedBox { + _WidgetPreviewWrapperBox({ + required RenderBox? child, + required this._previewerConstraints, + }) : super(child); + + BoxConstraints _constraintOverride = const BoxConstraints(); + BoxConstraints _previewerConstraints; + + void setPreviewerConstraints(BoxConstraints previewerConstraints) { + if (_previewerConstraints == previewerConstraints) { + return; + } + _previewerConstraints = previewerConstraints; + markNeedsLayout(); + } + + @override + void layout(Constraints constraints, {bool parentUsesSize = false}) { + if (child != null && constraints is BoxConstraints) { + double minInstrinsicHeight; + try { + minInstrinsicHeight = child!.getMinIntrinsicHeight( + constraints.maxWidth, + ); + } on Object { + minInstrinsicHeight = 0.0; + } + // Determine if the previewed widget is vertically constrained. If the + // widget has a minimum intrinsic height of zero given the widget's max + // width, it has an unconstrained height and will cause an overflow in + // the previewer. In this case, apply finite constraints (e.g., the + // constraints for the root of the previewer). Otherwise, use the + // widget's actual constraints. + _constraintOverride = minInstrinsicHeight == 0 + ? _previewerConstraints + : const BoxConstraints(); + } + super.layout(constraints, parentUsesSize: parentUsesSize); + } + + @override + void performLayout() { + final child = this.child; + if (child == null) { + size = Size.zero; + return; + } + final updatedConstraints = _constraintOverride.enforce(constraints); + child.layout(updatedConstraints, parentUsesSize: true); + size = constraints.constrain(child.size); + } +} + +/// Custom [AssetBundle] used to map original asset paths from the parent +/// projects to those in the preview project. +class PreviewAssetBundle extends PlatformAssetBundle { + PreviewAssetBundle({required this.packageName}); + + /// The name of the package in which a preview was defined. + /// + /// For example, if a preview is defined in 'package:foo/src/bar.dart', this + /// will have the value 'foo'. + final String packageName; + + // Assets shipped via package dependencies have paths that start with + // 'packages'. + static const String _kPackagesPrefix = 'packages'; + + // TODO(bkonyi): when loading an invalid asset path that doesn't start with + // 'packages', this throws a FlutterError referencing the modified key + // instead of the original. We should catch the error and rethrow one with + // the original key in the error message. + @override + Future load(String key) { + // These assets are always present or are shipped via a package and aren't + // actually located in the parent project, meaning their paths did not need + // to be modified. + if (key == 'AssetManifest.bin' || + key == 'AssetManifest.bin.json' || + key == 'FontManifest.json' || + key.startsWith(_kPackagesPrefix)) { + return super.load(key); + } + // Other assets are from the parent project. Map their keys to package + // paths corresponding to the package containing the preview. + return super.load(_toPackagePath(key)); + } + + @override + Future loadBuffer(String key) async { + if (kIsWeb) { + final ByteData bytes = await load(key); + return ImmutableBuffer.fromUint8List(Uint8List.sublistView(bytes)); + } + return await ImmutableBuffer.fromAsset( + key.startsWith(_kPackagesPrefix) ? key : _toPackagePath(key), + ); + } + + String _toPackagePath(String key) => '$_kPackagesPrefix/$packageName/$key'; +} + +/// Main entrypoint for the widget previewer. +/// +/// We don't actually define this as `main` to avoid copying this file into +/// the preview scaffold project which prevents us from being able to use hot +/// restart to iterate on this file. +Future mainImpl() async { + final controller = WidgetPreviewScaffoldController(previews: previews); + await controller.initialize(); + // WARNING: do not move this line. This constructor sets + // [WidgetInspectorService.instance] to the custom service for the widget + // previewer. If [WidgetsFlutterBinding.ensureInitialized()] is invoked before + // the custom service is set, inspector service extensions will be registered + // against the wrong service. + WidgetPreviewScaffoldInspectorService(dtdServices: controller.dtdServices); + final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized(); + // Disable the injection of [WidgetInspector] into the widget tree built by + // [WidgetsApp]. [WidgetInspector] instances will be created for each + // individual preview so the widget inspector won't allow for users to select + // widgets that make up the widget preview scaffolding. + binding.debugExcludeRootWidgetInspector = true; + runWidget( + DisableWidgetInspectorScope( + child: binding.wrapWithDefaultView( + // Forces the set of previews to be recalculated after a hot reload. + HotReloadListener( + onHotReload: controller.onHotReload, + child: WidgetPreviewScaffold( + controller: controller, + ideTheme: getIdeTheme(), + ), + ), + ), + ), + ); +} + +class WidgetPreviewScaffold extends StatefulWidget { + const WidgetPreviewScaffold({ + super.key, + required this.controller, + this.ideTheme = const IdeTheme(), + this.enableWebView = true, + }); + + final WidgetPreviewScaffoldController controller; + final IdeTheme ideTheme; + final bool enableWebView; + + @override + State createState() => _WidgetPreviewScaffoldState(); +} + +class _WidgetPreviewScaffoldState extends State { + WebViewController? _webViewController; + + @override + void initState() { + super.initState(); + if (widget.enableWebView) { + _webViewController = WebViewController() + ..loadRequest(widget.controller.devToolsUri); + } + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: themeFor( + isDarkTheme: false, + ideTheme: widget.ideTheme, + theme: ThemeData(), + ), + darkTheme: themeFor( + isDarkTheme: true, + ideTheme: widget.ideTheme, + theme: ThemeData.dark(), + ), + themeMode: widget.ideTheme.isDarkMode ? ThemeMode.dark : ThemeMode.light, + home: Material( + child: OutlineDecoration.onlyTop( + child: ValueListenableBuilder( + valueListenable: widget.controller.widgetInspectorVisible, + builder: (context, widgetInspectorVisible, previewView) { + if (!widgetInspectorVisible) { + return previewView!; + } + return SplitPane( + axis: Axis.horizontal, + initialFractions: const [0.7, 0.3], + children: [ + OutlineDecoration.onlyRight(child: previewView!), + OutlineDecoration.onlyLeft( + child: widget.enableWebView + ? WebViewWidget(controller: _webViewController!) + : Container(), + ), + ], + ); + }, + // Display the previewer + child: Column( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.all(8.0), + child: WidgetPreviews(controller: widget.controller), + ), + ), + WidgetPreviewControls(controller: widget.controller), + ], + ), + ), + ), + ), + ); + } +} + +/// The set of controls used to control the preview environment. +class WidgetPreviewControls extends StatelessWidget { + const WidgetPreviewControls({super.key, required this.controller}); + + static const _controlsPadding = 20.0; + final WidgetPreviewScaffoldController controller; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + bottom: _controlsPadding, + left: _controlsPadding, + right: _controlsPadding, + ), + child: Row( + children: [ + LayoutTypeSelector(controller: controller), + ValueListenableBuilder( + valueListenable: controller.editorServiceAvailable, + builder: (context, editorServiceAvailable, _) { + if (!editorServiceAvailable) { + return Container(); + } + return Row( + children: [ + HorizontalSpacer(), + FilterBySelectedFileToggle(controller: controller), + ], + ); + }, + ), + HorizontalSpacer(), + Expanded(child: PreviewSearchControls(controller: controller)), + HorizontalSpacer(), + WidgetInspectorToggle(controller: controller), + Spacer(), + WidgetPreviewerRestartButton(controller: controller), + ], + ), + ); + } +} + +/// Renders the set of currently selected widget previews. +class WidgetPreviews extends StatelessWidget { + const WidgetPreviews({super.key, required this.controller}); + + final WidgetPreviewScaffoldController controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.filteredPreviewSetListenable, + builder: (context, previewGroups, _) { + if (previewGroups.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [NoPreviewsDetectedWidget()], + ); + } + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final previewGroupsList = previewGroups.toList(); + return WidgetPreviewerWindowConstraints( + constraints: constraints, + child: ListView.builder( + itemCount: previewGroups.length, + itemBuilder: (context, index) { + return WidgetPreviewGroupWidget( + controller: controller, + group: previewGroupsList[index], + ); + }, + ), + ); + }, + ); + }, + ); + } +} diff --git a/.widget_preview/lib/src/widget_preview_scaffold_controller.dart b/.widget_preview/lib/src/widget_preview_scaffold_controller.dart new file mode 100644 index 0000000..8b2cb60 --- /dev/null +++ b/.widget_preview/lib/src/widget_preview_scaffold_controller.dart @@ -0,0 +1,287 @@ +// Copyright 2014 The Flutter 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 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as path; +import 'package:widget_preview_scaffold/src/widget_preview_rendering.dart'; +import 'dtd/dtd_services.dart'; +import 'widget_preview.dart'; + +/// Define the Enum for Layout Types +enum LayoutType { gridView, listView } + +typedef WidgetPreviews = Iterable; +typedef WidgetPreviewGroups = Iterable; +typedef PreviewsCallback = WidgetPreviews Function(); + +/// Controller used to process events and determine which previews should be +/// displayed and how they should be displayed in the [WidgetPreviewScaffold]. +class WidgetPreviewScaffoldController { + WidgetPreviewScaffoldController({ + required PreviewsCallback previews, + @visibleForTesting WidgetPreviewScaffoldDtdServices? dtdServicesOverride, + // ignore: prefer_initializing_formals + }) : _previews = previews, + dtdServices = dtdServicesOverride ?? WidgetPreviewScaffoldDtdServices(); + + @visibleForTesting + static const kFilterBySelectedFilePreference = 'filterBySelectedFile'; + + /// Initializes the controller by establishing a connection to DTD and + /// listening for events. + Future initialize() async { + await dtdServices.connect(); + context = path.Context( + style: dtdServices.isWindows ? path.Style.windows : path.Style.posix, + ); + _registerListeners(); + await Future.wait([ + dtdServices + .getFlag(kFilterBySelectedFilePreference, defaultValue: true) + .then((value) => _filterBySelectedFile.value = value), + dtdServices.getDevToolsUri().then((uri) { + devToolsUri = uri; + }), + ]); + } + + /// Cleanup internal controller state. + Future dispose() async { + await dtdServices.dispose(); + + _layoutType.dispose(); + _filterBySelectedFile.dispose(); + _searchQuery.dispose(); + for (final searchField in _searchFields) { + searchField.dispose(); + } + } + + /// Update state after the project has been reassembled due to a hot reload. + void onHotReload() => _updateFilteredPreviewSet(); + + /// The active DTD connection used to communicate with other developer tooling. + final WidgetPreviewScaffoldDtdServices dtdServices; + + final PreviewsCallback _previews; + + late final path.Context context; + + /// Specifies how the previews should be laid out. + ValueListenable get layoutTypeListenable => _layoutType; + final _layoutType = ValueNotifier(LayoutType.gridView); + + LayoutType get layoutType => _layoutType.value; + set layoutType(LayoutType type) => _layoutType.value = type; + + /// Set to true when the Editor service is available over DTD. + ValueListenable get editorServiceAvailable => + dtdServices.editorServiceAvailable; + + /// The DevTools instance that's used to display the widget inspector within the previewer. + late final Uri devToolsUri; + + /// Specifies if only previews from the currently selected source file should be rendered. + ValueListenable get filterBySelectedFileListenable => + _filterBySelectedFile; + final _filterBySelectedFile = ValueNotifier(true); + + /// Enable or disable filtering by selected source file. + Future toggleFilterBySelectedFile() async { + final updated = !_filterBySelectedFile.value; + await dtdServices.setPreference(kFilterBySelectedFilePreference, updated); + _filterBySelectedFile.value = updated; + } + + /// The current case-insensitive query used to search previews. + ValueListenable get searchQueryListenable => _searchQuery; + final _searchQuery = ValueNotifier(''); + + /// Update the search query used to filter previews. + void updateSearchQuery(String query) => _searchQuery.value = query; + + /// Whether to include group names when applying search filters. + ValueListenable get searchByGroupNameListenable => _searchByGroupName; + final _searchByGroupName = ValueNotifier(true); + + /// Whether to include preview names when applying search filters. + ValueListenable get searchByPreviewNameListenable => + _searchByPreviewName; + final _searchByPreviewName = ValueNotifier(true); + + /// Whether to include script URIs when applying search filters. + ValueListenable get searchByContainingScriptListenable => + _searchByContainingScript; + final _searchByContainingScript = ValueNotifier(true); + + /// Whether to include package names when applying search filters. + ValueListenable get searchByContainingPackageListenable => + _searchByContainingPackage; + final _searchByContainingPackage = ValueNotifier(true); + + /// Toggle inclusion of group names in search filters. + /// + /// Returns true if the filter state was changed. + bool toggleSearchByGroupName() => _toggleSearchField(_searchByGroupName); + + /// Toggle inclusion of preview names in search filters. + /// + /// Returns true if the filter state was changed. + bool toggleSearchByPreviewName() => _toggleSearchField(_searchByPreviewName); + + /// Toggle inclusion of script URIs in search filters. + /// + /// Returns true if the filter state was changed. + bool toggleSearchByContainingScript() => + _toggleSearchField(_searchByContainingScript); + + /// Toggle inclusion of package names in search filters. + /// + /// Returns true if the filter state was changed. + bool toggleSearchByContainingPackage() => + _toggleSearchField(_searchByContainingPackage); + + /// Specifies if the DevTools Widget Inspector should be visible. + ValueListenable get widgetInspectorVisible => _widgetInspectorVisible; + final _widgetInspectorVisible = ValueNotifier(false); + + /// Enable or disable the DevTools Widget Inspector. + void toggleWidgetInspectorVisible() => + _widgetInspectorVisible.value = !_widgetInspectorVisible.value; + + /// The current set of previews to be displayed. + ValueListenable get filteredPreviewSetListenable => + _filteredPreviewSet; + final _filteredPreviewSet = ValueNotifier([]); + + void _registerListeners() { + dtdServices.selectedSourceFile.addListener(_updateFilteredPreviewSet); + editorServiceAvailable.addListener( + () => _updateFilteredPreviewSet(editorServiceAvailabilityUpdated: true), + ); + filterBySelectedFileListenable.addListener(_updateFilteredPreviewSet); + searchQueryListenable.addListener(_updateFilteredPreviewSet); + for (final searchField in _searchFields) { + searchField.addListener(_updateFilteredPreviewSet); + } + // Set the initial state. + _updateFilteredPreviewSet(); + } + + late final _searchFields = >[ + _searchByGroupName, + _searchByPreviewName, + _searchByContainingScript, + _searchByContainingPackage, + ]; + + String _getSearchableValue( + WidgetPreview preview, + ValueNotifier searchField, + ) { + if (identical(searchField, _searchByGroupName)) { + return preview.previewData.group.toLowerCase(); + } + if (identical(searchField, _searchByPreviewName)) { + return (preview.name ?? '').toLowerCase(); + } + if (identical(searchField, _searchByContainingScript)) { + return preview.scriptUri.toLowerCase(); + } + if (identical(searchField, _searchByContainingPackage)) { + return preview.packageName.toLowerCase(); + } + + throw StateError('Unknown search field'); + } + + bool _toggleSearchField(ValueNotifier searchField) { + if (searchField.value && !_hasAnotherActiveSearchField(searchField)) { + return false; + } + searchField.value = !searchField.value; + return true; + } + + bool _hasAnotherActiveSearchField(ValueNotifier activeSearchField) => + _searchFields.any( + (field) => !identical(field, activeSearchField) && field.value, + ); + + bool _matchesSearchFilter(WidgetPreview preview, String searchQuery) { + if (searchQuery.isEmpty) { + return true; + } + + for (final searchField in _searchFields) { + if (!searchField.value) { + continue; + } + if (_getSearchableValue(preview, searchField).contains(searchQuery)) { + return true; + } + } + + return false; + } + + void _updateFilteredPreviewSet({ + bool editorServiceAvailabilityUpdated = false, + }) { + final previews = _previews(); + + final normalizedSearchQuery = _searchQuery.value.trim().toLowerCase(); + String? selectedSourcePath; + + if (editorServiceAvailable.value && _filterBySelectedFile.value) { + final selectedSourceFile = dtdServices.selectedSourceFile.value; + // If the Editor service has only just become available and we're filtering + // by selected file, we need to explicitly set the filtered preview set as + // empty, otherwise `selectedSourceFile` will interpreted as a non-source + // file being selected in the editor. + if (editorServiceAvailabilityUpdated && selectedSourceFile == null) { + _filteredPreviewSet.value = []; + return; + } + // If filtering by selected file, we don't update the filtered preview set + // if the currently selected file is null. This can happen when a non-source + // window is selected (e.g., the widget previewer itself in VSCode), so we + // ignore these updates. + if (selectedSourceFile == null) { + return; + } + // Convert to a file path for comparing to avoid issues with optional encoding in URIs. + // See https://github.com/flutter/flutter/issues/175524. + selectedSourcePath = context.fromUri(selectedSourceFile.uriAsString); + } + + final previewGroups = {}; + for (final preview in previews) { + if (selectedSourcePath != null && + !context.equals( + // TODO(bkonyi): we can probably save some cycles by caching the file path + // rather than computing it on each filter. + context.fromUri(preview.scriptUri), + selectedSourcePath, + )) { + continue; + } + if (!_matchesSearchFilter(preview, normalizedSearchQuery)) { + continue; + } + + final group = preview.previewData.group; + previewGroups + .putIfAbsent( + group, + () => WidgetPreviewGroup(name: group, previews: []), + ) + .previews + .add(preview); + } + _filteredPreviewSet.value = previewGroups.values.toList(); + } +} diff --git a/.widget_preview/preview_manifest.json b/.widget_preview/preview_manifest.json new file mode 100644 index 0000000..76b52c4 --- /dev/null +++ b/.widget_preview/preview_manifest.json @@ -0,0 +1 @@ +{"version":"0.0.2","sdk-version":"3.13.0","pubspec-hashes":{"/home/quadradical/Documents/Code/material_emoji_picker/pubspec.yaml":"240ad6569f7ed673949d65842675b8da"}} \ No newline at end of file diff --git a/.widget_preview/pubspec.lock b/.widget_preview/pubspec.lock new file mode 100644 index 0000000..a345f97 --- /dev/null +++ b/.widget_preview/pubspec.lock @@ -0,0 +1,734 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: c6747018d37d32feeeb858a1d7043831cb541460ebe1a890a995cdb8df8ea5a5 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + dart_service_protocol_shared: + dependency: transitive + description: + name: dart_service_protocol_shared + sha256: "1737875c176d7e3d87bb3a359182828b542fe20a0b34198b8d31a81af5c7a76d" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + dtd: + dependency: "direct main" + description: + name: dtd + sha256: "09ddb228b3d1478a093556357692a4c203ff4f9d5f8cda05dfdca0ff3fb7c5d3" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + fast_immutable_collections: + dependency: transitive + description: + name: fast_immutable_collections + sha256: "58cec99fc068427c71901e82d4b31b232240ebe6e61200993c2cb91bcada0ff6" + url: "https://pub.dev" + source: hosted + version: "11.2.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_hooks: + dependency: transitive + description: + name: flutter_hooks + sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42" + url: "https://pub.dev" + source: hosted + version: "0.21.3+1" + flutter_lints: + dependency: "direct main" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: transitive + description: + name: flutter_riverpod + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" + url: "https://pub.dev" + source: hosted + version: "3.4.3" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: e3cb3ee6b47fd2472c23de6da5744796a4da195137759ddb3fbcc9467b7b3c7d + url: "https://pub.dev" + source: hosted + version: "8.2.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks_riverpod: + dependency: transitive + description: + name: hooks_riverpod + sha256: d58e1e14aa112ca9c56d54effb7d0d717b2d3f96dad3cdd1fce16f8740450700 + url: "https://pub.dev" + source: hosted + version: "3.4.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + json_rpc_2: + dependency: "direct main" + description: + name: json_rpc_2 + sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + material_emoji_picker: + dependency: "direct main" + description: + path: "/home/quadradical/Documents/Code/material_emoji_picker" + relative: false + source: path + version: "1.0.2" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: "6d056d60ce745335fcf1c726a4fc46a481a62229f202b65dbc2db5edbbabc7e2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + url: "https://pub.dev" + source: hosted + version: "9.6.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" + url: "https://pub.dev" + source: hosted + version: "3.4.3" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: "direct main" + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_sliver_list: + dependency: transitive + description: + name: super_sliver_list + sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unified_analytics: + dependency: transitive + description: + name: unified_analytics + sha256: "28bb11ef24567e720dc1397cba60df03cc5aded5bf6bef4d17b49934402c719b" + url: "https://pub.dev" + source: hosted + version: "8.0.18" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: "direct main" + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_web: + dependency: "direct main" + description: + name: webview_flutter_web + sha256: "18a7ccc1c31dd9a5c759a1b7217a2a1e04bd8f65712714a4070bfac19a23ca9e" + url: "https://pub.dev" + source: hosted + version: "0.2.3+4" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730 + url: "https://pub.dev" + source: hosted + version: "3.26.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/.widget_preview/pubspec.yaml b/.widget_preview/pubspec.yaml new file mode 100644 index 0000000..3a3131c --- /dev/null +++ b/.widget_preview/pubspec.yaml @@ -0,0 +1,26 @@ +name: widget_preview_scaffold +description: Scaffolding for Flutter Widget Previews +publish_to: none +version: 0.0.1 +environment: + sdk: ^3.13.0 +dependencies: + dtd: ^4.0.0 + flutter: + sdk: flutter + flutter_lints: ^6.0.0 + google_fonts: ^8.2.1 + json_rpc_2: ^4.1.0 + material_emoji_picker: + path: /home/quadradical/Documents/Code/material_emoji_picker + path: ^1.9.1 + stack_trace: ^1.12.2 + url_launcher: ^6.3.2 + web: ^1.1.1 + webview_flutter: ^4.14.1 + webview_flutter_web: ^0.2.3+4 +flutter: + uses-material-design: true +dependency_overrides: + material_emoji_picker: + path: /home/quadradical/Documents/Code/material_emoji_picker diff --git a/.widget_preview/web/favicon.png b/.widget_preview/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/.widget_preview/web/favicon.png differ diff --git a/.widget_preview/web/icons/Icon-192.png b/.widget_preview/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/.widget_preview/web/icons/Icon-192.png differ diff --git a/.widget_preview/web/icons/Icon-512.png b/.widget_preview/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/.widget_preview/web/icons/Icon-512.png differ diff --git a/.widget_preview/web/icons/Icon-maskable-192.png b/.widget_preview/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/.widget_preview/web/icons/Icon-maskable-192.png differ diff --git a/.widget_preview/web/icons/Icon-maskable-512.png b/.widget_preview/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/.widget_preview/web/icons/Icon-maskable-512.png differ diff --git a/.widget_preview/web/index.html b/.widget_preview/web/index.html new file mode 100644 index 0000000..df92389 --- /dev/null +++ b/.widget_preview/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + widget_preview_scaffold + + + + + + + diff --git a/.widget_preview/web/manifest.json b/.widget_preview/web/manifest.json new file mode 100644 index 0000000..3f44304 --- /dev/null +++ b/.widget_preview/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "widget_preview_scaffold", + "short_name": "widget_preview_scaffold", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/lib/src/widgets/emoji_picker.dart b/lib/src/widgets/emoji_picker.dart index 430533a..8dc9df6 100644 --- a/lib/src/widgets/emoji_picker.dart +++ b/lib/src/widgets/emoji_picker.dart @@ -65,7 +65,6 @@ final class const EmojiPicker({ ); return Column( - spacing: 4, children: [ SearchBar( hintText: "Search emoji", @@ -81,6 +80,7 @@ final class const EmojiPicker({ ), ], ), + SizedBox(height: 8), SizedBox( height: 48, child: Scrollbar(