diff --git a/app/lib/main.dart b/app/lib/main.dart index b86e66850a8a..b23a3412b76e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -39,7 +39,7 @@ import 'settings/connections.dart'; import 'setup.dart' if (dart.library.js_interop) 'setup_web.dart'; import 'theme.dart'; import 'views/error.dart'; -import 'views/home.dart'; +import 'views/home/page.dart'; import 'views/main.dart'; const platform = MethodChannel('linwood.dev/butterfly'); diff --git a/app/lib/settings/experiments.dart b/app/lib/settings/experiments.dart index 9ed51373e0cc..47666ed6eabd 100644 --- a/app/lib/settings/experiments.dart +++ b/app/lib/settings/experiments.dart @@ -41,6 +41,7 @@ class ExperimentsSettingsPage extends StatelessWidget { IconButton( icon: const PhosphorIcon(PhosphorIconsLight.clockCounterClockwise), + tooltip: LeapLocalizations.of(context).reset, onPressed: () => context.read().resetFlags(), ), ], diff --git a/app/lib/settings/home.dart b/app/lib/settings/home.dart index 573ea86d1b58..0c0d5302f6a3 100644 --- a/app/lib/settings/home.dart +++ b/app/lib/settings/home.dart @@ -1,3 +1,4 @@ +import 'package:butterfly/cubits/settings.dart'; import 'package:butterfly/main.dart'; import 'package:butterfly/settings/behaviors.dart'; import 'package:butterfly/settings/inputs/home.dart'; @@ -72,78 +73,79 @@ class _SettingsPageState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: Material( - type: widget.isDialog ? MaterialType.transparency : MaterialType.canvas, - child: LayoutBuilder(builder: (context, constraints) { - final isMobile = constraints.maxWidth < 600; - - void navigateTo(SettingsView view) { - if (isMobile) { - context.push(view.path); - } else { - setState(() { - _view = view; - }); - } - } - - var navigation = Column(mainAxisSize: MainAxisSize.min, children: [ - Header( - title: Text(AppLocalizations.of(context).settings), - leading: IconButton.outlined( - icon: const PhosphorIcon(PhosphorIconsLight.x), - onPressed: () => Navigator.of(context).pop(), - ), - ), - Flexible( - child: Material( - type: widget.isDialog - ? MaterialType.transparency - : MaterialType.canvas, - child: ListView( - controller: _scrollController, - shrinkWrap: true, - children: [ - ...SettingsView.values - .where((e) => e.isEnabled) - .map((view) { - final selected = _view == view && !isMobile; - return ListTile( - leading: PhosphorIcon(view.icon(selected - ? PhosphorIconsStyle.fill - : PhosphorIconsStyle.light)), - title: Text(view.getLocalizedName(context)), - onTap: () => navigateTo(view), - selected: selected, - ); - }), - ]), - ), - ) - ]); - if (isMobile) { - return navigation; - } - final content = switch (_view) { - SettingsView.general => const GeneralSettingsPage(inView: true), - SettingsView.data => const DataSettingsPage(inView: true), - SettingsView.behaviors => const BehaviorsSettingsPage(inView: true), - SettingsView.inputs => const InputsSettingsPage(inView: true), - SettingsView.personalization => - const PersonalizationSettingsPage(inView: true), - SettingsView.view => const ViewSettingsPage(inView: true), - SettingsView.connections => - const ConnectionsSettingsPage(inView: true), - SettingsView.experiments => - const ExperimentsSettingsPage(inView: true), - }; - return Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox(width: 300, child: navigation), - Expanded(child: content), - ]); - }), + final size = MediaQuery.sizeOf(context); + final isMobile = size.width < LeapBreakpoints.compact; + final body = _buildBody(context, isMobile); + if (widget.isDialog) { + return body; + } + return Scaffold( + appBar: WindowTitleBar( + title: Text(AppLocalizations.of(context).settings), ), + body: body, ); } + + Widget _buildBody(BuildContext context, bool isMobile) { + void navigateTo(SettingsView view) { + if (isMobile) { + context.push(view.path); + } else { + setState(() { + _view = view; + }); + } + } + + var navigation = Column(mainAxisSize: MainAxisSize.min, children: [ + if (widget.isDialog) + Header( + title: Text(AppLocalizations.of(context).settings), + leading: IconButton.outlined( + icon: const PhosphorIcon(PhosphorIconsLight.x), + onPressed: () => Navigator.of(context).pop(), + ), + ), + Flexible( + child: Material( + type: MaterialType.transparency, + child: ListView( + controller: _scrollController, + shrinkWrap: true, + children: [ + ...SettingsView.values.where((e) => e.isEnabled).map((view) { + final selected = _view == view && !isMobile; + return ListTile( + leading: PhosphorIcon(view.icon(selected + ? PhosphorIconsStyle.fill + : PhosphorIconsStyle.light)), + title: Text(view.getLocalizedName(context)), + onTap: () => navigateTo(view), + selected: selected, + ); + }), + ]), + ), + ) + ]); + if (isMobile) { + return navigation; + } + final content = switch (_view) { + SettingsView.general => const GeneralSettingsPage(inView: true), + SettingsView.data => const DataSettingsPage(inView: true), + SettingsView.behaviors => const BehaviorsSettingsPage(inView: true), + SettingsView.inputs => const InputsSettingsPage(inView: true), + SettingsView.personalization => + const PersonalizationSettingsPage(inView: true), + SettingsView.view => const ViewSettingsPage(inView: true), + SettingsView.connections => const ConnectionsSettingsPage(inView: true), + SettingsView.experiments => const ExperimentsSettingsPage(inView: true), + }; + return Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + SizedBox(width: 300, child: navigation), + Expanded(child: content), + ]); + } } diff --git a/app/lib/views/files/recently.dart b/app/lib/views/files/recently.dart new file mode 100644 index 000000000000..741089baad45 --- /dev/null +++ b/app/lib/views/files/recently.dart @@ -0,0 +1,103 @@ +import 'dart:typed_data'; + +import 'package:butterfly/api/file_system.dart'; +import 'package:butterfly/api/open.dart'; +import 'package:butterfly/cubits/settings.dart'; +import 'package:butterfly/views/files/card.dart'; +import 'package:butterfly_api/butterfly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lw_file_system/lw_file_system.dart'; + +class RecentFilesView extends StatefulWidget { + final bool replace, asGrid; + const RecentFilesView({ + super.key, + required this.replace, + this.asGrid = false, + }); + + @override + State createState() => _RecentFilesViewState(); +} + +class _RecentFilesViewState extends State { + late Stream>> _stream; + late final ButterflyFileSystem _fileSystem; + final ScrollController _recentScrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _fileSystem = context.read(); + _setStream(context.read().state); + } + + @override + void dispose() { + _recentScrollController.dispose(); + super.dispose(); + } + + void _setStream(ButterflySettings settings) => + _stream = GeneralDirectoryFileSystem.fetchAssetsGlobalSync( + settings.history, _fileSystem.buildAllDocumentSystems()); + + Widget _getItem(FileSystemEntity entity) { + FileMetadata? metadata; + Uint8List? thumbnail; + if (entity is FileSystemFile) { + final data = entity.data?.load(); + metadata = data?.getMetadata(); + thumbnail = data?.getThumbnail(); + } + return AssetCard( + metadata: metadata, + thumbnail: thumbnail, + name: entity.location.identifier, + height: double.infinity, + onTap: () => openFile(context, widget.replace, entity.location), + ); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => previous.history != current.history, + listener: (_, state) => setState(() { + _setStream(state); + }), + child: StreamBuilder>>( + stream: _stream, + builder: (context, snapshot) { + final files = snapshot.data ?? []; + if (files.isEmpty) { + return Container(); + } + return widget.asGrid + ? GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + childAspectRatio: kThumbnailRatio, + crossAxisCount: 2, + ), + itemCount: files.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => _getItem(files[index]), + ) + : SizedBox( + height: 128, + child: Scrollbar( + controller: _recentScrollController, + child: ListView.builder( + controller: _recentScrollController, + scrollDirection: Axis.horizontal, + itemCount: files.length, + itemBuilder: (context, index) => _getItem(files[index]), + ), + ), + ); + }), + ); + } +} diff --git a/app/lib/views/files/view.dart b/app/lib/views/files/view.dart index 006d0e00e536..852c22211b56 100644 --- a/app/lib/views/files/view.dart +++ b/app/lib/views/files/view.dart @@ -1,10 +1,8 @@ -import 'dart:typed_data'; - import 'package:butterfly/api/file_system.dart'; import 'package:butterfly/dialogs/file_system/move.dart'; import 'package:butterfly/models/defaults.dart'; -import 'package:butterfly/views/files/card.dart'; import 'package:butterfly/views/files/entity.dart'; +import 'package:butterfly/views/files/recently.dart'; import 'package:butterfly/widgets/connection_button.dart'; import 'package:butterfly_api/butterfly_api.dart'; import 'package:flutter/material.dart'; @@ -25,7 +23,7 @@ class FilesView extends StatefulWidget { final ExternalStorage? remote; final ValueChanged? onRemoteChanged; final bool collapsed; - final bool isMobile; + final bool isMobile, isPage; const FilesView({ super.key, @@ -34,6 +32,7 @@ class FilesView extends StatefulWidget { this.onRemoteChanged, this.collapsed = false, this.isMobile = false, + this.isPage = false, }); @override @@ -95,7 +94,9 @@ class FilesViewState extends State { void _setFilesStream() { _templateSystem = _fileSystem.buildTemplateSystem(_remote); _documentSystem = _fileSystem.buildDocumentSystem(_remote); - _filesStream = _documentSystem.fetchAsset(_locationController.text); + _filesStream = _documentSystem + .fetchAsset(_locationController.text) + .asBroadcastStream(); } void reloadFileSystem() { @@ -146,553 +147,578 @@ class FilesViewState extends State { Widget build(BuildContext context) { final index = _locationController.text.lastIndexOf('/'); final parent = _locationController.text.substring(0, index < 0 ? 0 : index); + return BlocBuilder( buildWhen: (previous, current) => previous.gridView != current.gridView, - builder: (context, state) => - Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - LayoutBuilder(builder: (context, constraints) { - final isDesktop = constraints.maxWidth > 800; - final text = Text( - AppLocalizations.of(context).files, - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.start, - ); - final orderButton = IconButton( - icon: PhosphorIcon(_sortOrder == SortOrder.ascending - ? PhosphorIconsLight.sortAscending - : PhosphorIconsLight.sortDescending), - tooltip: _sortOrder == SortOrder.ascending - ? AppLocalizations.of(context).ascending - : AppLocalizations.of(context).descending, - onPressed: () => setState(() { - _sortOrder = _sortOrder == SortOrder.ascending - ? SortOrder.descending - : SortOrder.ascending; - _settingsCubit.changeSortOrder(_sortOrder); - }), - ); - final desktopActions = OverflowBar( - spacing: 8, - overflowSpacing: 8, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(AppLocalizations.of(context).switchView), - const SizedBox(width: 8), - IconButton.filledTonal( - onPressed: () => - context.read().toggleGridView(), - icon: state.gridView - ? const PhosphorIcon(PhosphorIconsLight.list) - : const PhosphorIcon(PhosphorIconsLight.gridFour), - ), - ], - ), - BlocBuilder( - buildWhen: (previous, current) => - previous.connections != current.connections, - builder: (context, state) => Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - DropdownMenu( - label: Text(AppLocalizations.of(context).source), - width: 225, - dropdownMenuEntries: [ - DropdownMenuEntry( - value: null, - label: AppLocalizations.of(context).local, - ), - ...state.connections.map((e) => DropdownMenuEntry( - value: e.identifier, - label: e.label, - )), - ], - initialSelection: _remote?.identifier, - onSelected: (value) => _setRemote( - value == null ? null : state.getRemote(value)), - ), - const SizedBox(width: 2), - state.connections.any((e) => e is RemoteStorage) - ? const SyncButton() - : const SizedBox.shrink(), - ], - ), - ), - DropdownMenu( - leadingIcon: orderButton, - label: Text(AppLocalizations.of(context).sortBy), - width: 225, - dropdownMenuEntries: SortBy.values - .map((e) => DropdownMenuEntry( - value: e, - label: getLocalizedNameOfSortBy(e), - leadingIcon: PhosphorIcon( - getIconOfSortBy(e)(PhosphorIconsStyle.light)), - )) - .toList(), - initialSelection: _sortBy, - onSelected: (value) => setState(() { - _sortBy = value ?? _sortBy; - _settingsCubit.changeSortBy(_sortBy); - }), - ), - ], - ); - final primary = Theme.of(context).colorScheme.primary; - final mobileActions = OverflowBar( - spacing: 4, - overflowSpacing: 4, - children: [ - if (!widget.collapsed) - IconButton( + builder: (context, state) { + final text = Text( + AppLocalizations.of(context).files, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.start, + ); + final orderButton = IconButton( + icon: PhosphorIcon(_sortOrder == SortOrder.ascending + ? PhosphorIconsLight.sortAscending + : PhosphorIconsLight.sortDescending), + tooltip: _sortOrder == SortOrder.ascending + ? AppLocalizations.of(context).ascending + : AppLocalizations.of(context).descending, + onPressed: () => setState(() { + _sortOrder = _sortOrder == SortOrder.ascending + ? SortOrder.descending + : SortOrder.ascending; + _settingsCubit.changeSortOrder(_sortOrder); + }), + ); + final desktopActions = OverflowBar( + spacing: 8, + overflowSpacing: 8, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(AppLocalizations.of(context).switchView), + const SizedBox(width: 8), + IconButton.filledTonal( onPressed: () => context.read().toggleGridView(), - tooltip: AppLocalizations.of(context).switchView, icon: state.gridView ? const PhosphorIcon(PhosphorIconsLight.list) : const PhosphorIcon(PhosphorIconsLight.gridFour), ), - ConnectionButton( - currentRemote: _remote?.identifier ?? '', - onChanged: _setRemote, - ), - MenuAnchor( - builder: defaultMenuButton( - tooltip: AppLocalizations.of(context).sortBy, - icon: PhosphorIcon( - getIconOfSortBy(_sortBy)(PhosphorIconsStyle.light)), - ), - menuChildren: SortBy.values - .map((e) => MenuItemButton( - leadingIcon: PhosphorIcon( - getIconOfSortBy(e)(PhosphorIconsStyle.light), - color: e == _sortBy ? primary : null), - child: Text(getLocalizedNameOfSortBy(e), - style: e == _sortBy - ? TextStyle(color: primary) - : null), - onPressed: () => setState(() { - _sortBy = e; - _settingsCubit.changeSortBy(_sortBy); - }), - )) - .toList(), + ], + ), + BlocBuilder( + buildWhen: (previous, current) => + previous.connections != current.connections, + builder: (context, state) => Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DropdownMenu( + label: Text(AppLocalizations.of(context).source), + width: 225, + dropdownMenuEntries: [ + DropdownMenuEntry( + value: null, + label: AppLocalizations.of(context).local, + ), + ...state.connections.map((e) => DropdownMenuEntry( + value: e.identifier, + label: e.label, + )), + ], + initialSelection: _remote?.identifier, + onSelected: (value) => _setRemote( + value == null ? null : state.getRemote(value)), + ), + const SizedBox(width: 2), + state.connections.any((e) => e is RemoteStorage) + ? const SyncButton() + : const SizedBox.shrink(), + ], ), - orderButton, - ], - ); - if (widget.collapsed) { - return Center(child: mobileActions); - } - return OverflowBar( - alignment: MainAxisAlignment.spaceBetween, - children: [ - text, - isDesktop ? desktopActions : mobileActions, - ], - ); - }), - const SizedBox(height: 8), - _RecentFilesView( - replace: widget.collapsed, - ), - const SizedBox(height: 16), - LayoutBuilder(builder: (context, constraints) { - final searchBar = Row(children: [ + ), + DropdownMenu( + leadingIcon: orderButton, + label: Text(AppLocalizations.of(context).sortBy), + width: 225, + dropdownMenuEntries: SortBy.values + .map((e) => DropdownMenuEntry( + value: e, + label: getLocalizedNameOfSortBy(e), + leadingIcon: PhosphorIcon( + getIconOfSortBy(e)(PhosphorIconsStyle.light)), + )) + .toList(), + initialSelection: _sortBy, + onSelected: (value) => setState(() { + _sortBy = value ?? _sortBy; + _settingsCubit.changeSortBy(_sortBy); + }), + ), + ], + ); + final primary = Theme.of(context).colorScheme.primary; + final actionsChildren = [ + if (!widget.collapsed) IconButton( - onPressed: reloadFileSystem, - tooltip: AppLocalizations.of(context).refresh, - icon: const PhosphorIcon(PhosphorIconsLight.arrowClockwise), + onPressed: () => context.read().toggleGridView(), + tooltip: AppLocalizations.of(context).switchView, + icon: state.gridView + ? const PhosphorIcon(PhosphorIconsLight.list) + : const PhosphorIcon(PhosphorIconsLight.gridFour), ), - const SizedBox(width: 8), - Expanded( - child: SearchBar( - onChanged: (value) => setState(() => _search = value), - hintText: AppLocalizations.of(context).search, - leading: const PhosphorIcon(PhosphorIconsLight.magnifyingGlass), - ), + ConnectionButton( + currentRemote: _remote?.identifier ?? '', + onChanged: _setRemote, + ), + MenuAnchor( + builder: defaultMenuButton( + tooltip: AppLocalizations.of(context).sortBy, + icon: PhosphorIcon( + getIconOfSortBy(_sortBy)(PhosphorIconsStyle.light)), ), - ]); - final locationBar = SizedBox( - height: 64, - child: _selectedFiles.isEmpty - ? BlocBuilder( - buildWhen: (previous, current) => - previous.flags != current.flags, - builder: (context, settings) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - MenuAnchor( - menuChildren: [ - MenuItemButton( - leadingIcon: - const PhosphorIcon(PhosphorIconsLight.folder), - child: - Text(AppLocalizations.of(context).newFolder), - onPressed: () async { - final name = await showDialog( - context: context, - builder: (context) => NameDialog( - validator: - defaultFileNameValidator(context), - ), - ); - if (name == null) return; - final path = _locationController.text; - final newPath = '$path/$name'; - await _documentSystem.createDirectory(newPath); - reloadFileSystem(); - }, - ), - MenuItemButton( - onPressed: () async => _createFile( - await _templateSystem.getDefaultFile( - _templateSystem - .storage?.defaults['template'] ?? - _settingsCubit.state.defaultTemplate, - ), - ), - leadingIcon: const PhosphorIcon( - PhosphorIconsLight.filePlus), - child: Text(AppLocalizations.of(context).newNote), - ), - FutureBuilder>>( - future: _templateSystem - .initialize() - .then((_) => _templateSystem.getFiles()), - builder: (context, snapshot) => SubmenuButton( - leadingIcon: - const PhosphorIcon(PhosphorIconsLight.file), - menuChildren: snapshot.data?.map((e) { - final data = e.data!; - final metadata = data.getMetadata(); - final thumbnail = data.getThumbnail(); - return MenuItemButton( - leadingIcon: thumbnail == null - ? null - : Image.memory( - thumbnail, - width: 32, - height: 18, - cacheWidth: 32, - cacheHeight: 18, - ), - child: Text(metadata?.name ?? ''), - onPressed: () => _createFile(data), - ); - }).toList() ?? - [], - child: Text( - AppLocalizations.of(context).templates), - ), - ), - MenuItemButton( - leadingIcon: const PhosphorIcon( - PhosphorIconsLight.arrowSquareIn), - onPressed: () async { - final router = GoRouter.of(context); - final importService = - context.read(); - final (result, extension) = - await importFile(context); - if (result == null) return; - final model = await importService.import( - AssetFileTypeHelper.fromFileExtension( - extension) ?? - AssetFileType.note, - result, - advanced: false, - fileSystem: _documentSystem, - templateSystem: _templateSystem, - packSystem: - _fileSystem.buildPackSystem(_remote), - ); - if (model == null) { - reloadFileSystem(); - return; - } - const route = - '/native?name=document.bfly&type=note'; - router.go(route, extra: model.exportAsBytes()); - if (!widget.collapsed) { - reloadFileSystem(); - } - }, - child: Text(AppLocalizations.of(context).import), - ), - if (settings.hasFlag('collaboration')) + menuChildren: SortBy.values + .map((e) => MenuItemButton( + leadingIcon: PhosphorIcon( + getIconOfSortBy(e)(PhosphorIconsStyle.light), + color: e == _sortBy ? primary : null), + child: Text(getLocalizedNameOfSortBy(e), + style: + e == _sortBy ? TextStyle(color: primary) : null), + onPressed: () => setState(() { + _sortBy = e; + _settingsCubit.changeSortBy(_sortBy); + }), + )) + .toList(), + ), + orderButton, + ]; + final mobileActions = OverflowBar( + spacing: 4, + overflowSpacing: 4, + children: actionsChildren, + ); + final content = + Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (!widget.isPage) ...[ + LayoutBuilder(builder: (context, constraints) { + final isDesktop = constraints.maxWidth > 800; + if (widget.collapsed) { + return Center(child: mobileActions); + } + return OverflowBar( + alignment: MainAxisAlignment.spaceBetween, + children: [ + text, + isDesktop ? desktopActions : mobileActions, + ], + ); + }), + const SizedBox(height: 8), + RecentFilesView( + replace: widget.collapsed, + ), + ], + const SizedBox(height: 16), + LayoutBuilder(builder: (context, constraints) { + final searchBar = Row(children: [ + IconButton( + onPressed: reloadFileSystem, + tooltip: AppLocalizations.of(context).refresh, + icon: const PhosphorIcon(PhosphorIconsLight.arrowClockwise), + ), + const SizedBox(width: 8), + Expanded( + child: SearchBar( + onChanged: (value) => setState(() => _search = value), + hintText: AppLocalizations.of(context).search, + leading: + const PhosphorIcon(PhosphorIconsLight.magnifyingGlass), + ), + ), + ]); + final locationBar = SizedBox( + height: 64, + child: _selectedFiles.isEmpty + ? BlocBuilder( + buildWhen: (previous, current) => + previous.flags != current.flags, + builder: (context, settings) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + MenuAnchor( + menuChildren: [ MenuItemButton( leadingIcon: const PhosphorIcon( - PhosphorIconsLight.shareNetwork), - child: - Text(AppLocalizations.of(context).connect), + PhosphorIconsLight.folder), + child: Text( + AppLocalizations.of(context).newFolder), onPressed: () async { - final url = await showDialog( + final name = await showDialog( + context: context, builder: (context) => NameDialog( - title: - AppLocalizations.of(context).enterUrl, - hint: AppLocalizations.of(context).url, - button: - AppLocalizations.of(context).connect, + validator: + defaultFileNameValidator(context), ), - context: context, ); - if (url == null) return; - GoRouter.of(context) - .pushNamed('connect', queryParameters: { - 'url': url, - }); + if (name == null) return; + final path = _locationController.text; + final newPath = '$path/$name'; + await _documentSystem + .createDirectory(newPath); + reloadFileSystem(); }, ), - ], - builder: (context, controller, child) => - FloatingActionButton.small( - heroTag: null, - onPressed: controller.toggle, - tooltip: LeapLocalizations.of(context).create, - child: const PhosphorIcon(PhosphorIconsLight.plus), - ), - ), - DragTarget( - builder: (context, candidateData, rejectedData) => - IconButton( - onPressed: _locationController.text.isEmpty - ? null - : () => setState(() { - _locationController.text = parent; - _setFilesStream(); - }), - icon: - const PhosphorIcon(PhosphorIconsLight.arrowUp), - tooltip: AppLocalizations.of(context).goUp, - ), - onWillAcceptWithDetails: (data) => true, - onAcceptWithDetails: (data) async { - await _documentSystem.moveAsset(data.data, - '$parent/${data.data.split('/').last}'); - reloadFileSystem(); - }, - ), - const SizedBox(width: 8), - Flexible( - child: TextFormField( - decoration: InputDecoration( - hintText: AppLocalizations.of(context).location, - prefixIcon: - const PhosphorIcon(PhosphorIconsLight.folder), - filled: true, - contentPadding: const EdgeInsets.only( - left: 16, - right: 16, - bottom: 6, + MenuItemButton( + onPressed: () async => _createFile( + await _templateSystem.getDefaultFile( + _templateSystem + .storage?.defaults['template'] ?? + _settingsCubit.state.defaultTemplate, + ), + ), + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.filePlus), + child: + Text(AppLocalizations.of(context).newNote), ), - ), - textAlignVertical: TextAlignVertical.center, - controller: _locationController, - onFieldSubmitted: (value) => reloadFileSystem(), - ), - ), - ], - ), - ) - : Center( - child: Card( - child: Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - IconButton( - icon: const PhosphorIcon( - PhosphorIconsLight.selectionSlash), - tooltip: - AppLocalizations.of(context).deselect, - onPressed: () => - setState(() => _selectedFiles.clear()), + FutureBuilder>>( + future: _templateSystem + .initialize() + .then((_) => _templateSystem.getFiles()), + builder: (context, snapshot) => SubmenuButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.file), + menuChildren: snapshot.data?.map((e) { + final data = e.data!; + final metadata = data.getMetadata(); + final thumbnail = data.getThumbnail(); + return MenuItemButton( + leadingIcon: thumbnail == null + ? null + : Image.memory( + thumbnail, + width: 32, + height: 18, + cacheWidth: 32, + cacheHeight: 18, + ), + child: Text(metadata?.name ?? ''), + onPressed: () => _createFile(data), + ); + }).toList() ?? + [], + child: Text( + AppLocalizations.of(context).templates), ), - IconButton( - icon: const PhosphorIcon( - PhosphorIconsLight.selectionInverse), - tooltip: AppLocalizations.of(context) - .invertSelection, + ), + MenuItemButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.arrowSquareIn), + onPressed: () async { + final router = GoRouter.of(context); + final importService = + context.read(); + final (result, extension) = + await importFile(context); + if (result == null) return; + final model = await importService.import( + AssetFileTypeHelper.fromFileExtension( + extension) ?? + AssetFileType.note, + result, + advanced: false, + fileSystem: _documentSystem, + templateSystem: _templateSystem, + packSystem: + _fileSystem.buildPackSystem(_remote), + ); + if (model == null) { + reloadFileSystem(); + return; + } + const route = + '/native?name=document.bfly&type=note'; + router.go(route, + extra: model.exportAsBytes()); + if (!widget.collapsed) { + reloadFileSystem(); + } + }, + child: + Text(AppLocalizations.of(context).import), + ), + if (settings.hasFlag('collaboration')) + MenuItemButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.shareNetwork), + child: Text( + AppLocalizations.of(context).connect), onPressed: () async { - final directory = await _documentSystem - .getAsset(_locationController.text, - readData: false); - if (directory - is! FileSystemDirectory) { - return; - } - setState(() { - final all = _selectedFiles.toSet(); - _selectedFiles.clear(); - _selectedFiles.addAll(directory.assets - .map((e) => e.path) - .toSet() - .difference(all)); + final url = await showDialog( + builder: (context) => NameDialog( + title: AppLocalizations.of(context) + .enterUrl, + hint: AppLocalizations.of(context).url, + button: AppLocalizations.of(context) + .connect, + ), + context: context, + ); + if (url == null) return; + GoRouter.of(context) + .pushNamed('connect', queryParameters: { + 'url': url, }); }, ), - ], + ], + builder: (context, controller, child) => + FloatingActionButton.small( + heroTag: null, + onPressed: controller.toggle, + tooltip: LeapLocalizations.of(context).create, + child: + const PhosphorIcon(PhosphorIconsLight.plus), ), - Row( - children: [ + ), + DragTarget( + builder: (context, candidateData, rejectedData) => IconButton( - icon: const PhosphorIcon( - PhosphorIconsLight.arrowsDownUp), - tooltip: AppLocalizations.of(context).move, - onPressed: () => showDialog( - context: context, - builder: (context) => - FileSystemAssetMoveDialog( - assets: _selectedFiles - .map((e) => AssetLocation( - path: e, - remote: - _remote?.identifier ?? '', - )) - .toList(), - fileSystem: _documentSystem, - ), - ).then((value) { - if (value != null) reloadFileSystem(); - }), + onPressed: _locationController.text.isEmpty + ? null + : () => setState(() { + _locationController.text = parent; + _setFilesStream(); + }), + icon: const PhosphorIcon( + PhosphorIconsLight.arrowUp), + tooltip: AppLocalizations.of(context).goUp, + ), + onWillAcceptWithDetails: (data) => true, + onAcceptWithDetails: (data) async { + await _documentSystem.moveAsset(data.data, + '$parent/${data.data.split('/').last}'); + reloadFileSystem(); + }, + ), + const SizedBox(width: 8), + Flexible( + child: TextFormField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context).location, + prefixIcon: const PhosphorIcon( + PhosphorIconsLight.folder), + filled: true, + contentPadding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 6, ), - Builder( - builder: (context) => IconButton( - icon: const PhosphorIcon( - PhosphorIconsLight.trash), - tooltip: AppLocalizations.of(context) - .delete, - onPressed: () async => deleteEntities( - context: context, - entities: _selectedFiles, - documentSystem: _documentSystem, - isMobile: widget.isMobile, - onDelete: reloadFileSystem, - ), - )), - ], + ), + textAlignVertical: TextAlignVertical.center, + controller: _locationController, + onFieldSubmitted: (value) => reloadFileSystem(), ), - ], + ), + ], + ), + ) + : Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + IconButton( + icon: const PhosphorIcon( + PhosphorIconsLight.selectionSlash), + tooltip: + AppLocalizations.of(context).deselect, + onPressed: () => + setState(() => _selectedFiles.clear()), + ), + IconButton( + icon: const PhosphorIcon( + PhosphorIconsLight.selectionInverse), + tooltip: AppLocalizations.of(context) + .invertSelection, + onPressed: () async { + final directory = await _documentSystem + .getAsset(_locationController.text, + readData: false); + if (directory + is! FileSystemDirectory) { + return; + } + setState(() { + final all = _selectedFiles.toSet(); + _selectedFiles.clear(); + _selectedFiles.addAll(directory.assets + .map((e) => e.path) + .toSet() + .difference(all)); + }); + }, + ), + ], + ), + Row( + children: [ + IconButton( + icon: const PhosphorIcon( + PhosphorIconsLight.arrowsDownUp), + tooltip: AppLocalizations.of(context).move, + onPressed: () => showDialog( + context: context, + builder: (context) => + FileSystemAssetMoveDialog( + assets: _selectedFiles + .map((e) => AssetLocation( + path: e, + remote: + _remote?.identifier ?? '', + )) + .toList(), + fileSystem: _documentSystem, + ), + ).then((value) { + if (value != null) reloadFileSystem(); + }), + ), + Builder( + builder: (context) => IconButton( + icon: const PhosphorIcon( + PhosphorIconsLight.trash), + tooltip: + AppLocalizations.of(context) + .delete, + onPressed: () async => + deleteEntities( + context: context, + entities: _selectedFiles, + documentSystem: _documentSystem, + isMobile: widget.isMobile, + onDelete: reloadFileSystem, + ), + )), + ], + ), + ], + ), ), ), ), - ), - ); - final isDesktop = constraints.maxWidth > 600; - if (isDesktop) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded(child: locationBar), - const SizedBox(width: 8), - SizedBox(width: 250, child: searchBar), - ], ); - } else { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchBar, - const SizedBox(height: 16), - locationBar, - ], - ); - } - }), - const SizedBox(height: 8), - BlocBuilder( - buildWhen: (previous, current) => previous.starred != current.starred, - builder: (context, settings) => - StreamBuilder?>( - stream: _filesStream, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Text(snapshot.error.toString()); - } - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (!snapshot.hasData) { - return Center( - child: Text(AppLocalizations.of(context).noElements)); - } - final entity = snapshot.data; - if (entity is! FileSystemDirectory) { - return Container(); - } - final assets = entity.assets.where((e) { - if (_search.isNotEmpty) { - return e.fileName - .toLowerCase() - .contains(_search.toLowerCase()); + final isDesktop = constraints.maxWidth > 600; + if (isDesktop) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: locationBar), + const SizedBox(width: 8), + SizedBox(width: 250, child: searchBar), + ], + ); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + searchBar, + const SizedBox(height: 16), + locationBar, + ], + ); + } + }), + const SizedBox(height: 8), + BlocBuilder( + buildWhen: (previous, current) => + previous.starred != current.starred, + builder: (context, settings) => + StreamBuilder?>( + stream: _filesStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(snapshot.error.toString()); } - return true; - }).toList() - ..sort(_sortAssets); - if (assets.isEmpty) { - return Center( - child: Text(AppLocalizations.of(context).noElements), - ); - } - if (state.gridView && !widget.collapsed) { - return Center( - child: Wrap( - spacing: 4, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.start, - children: assets.map( - (e) { - final active = widget.activeAsset == e.location; - return FileEntityItem( - entity: e, - isMobile: widget.isMobile, - active: active, - collapsed: widget.collapsed, - onTap: () => _onFileTap(e), - selected: _selectedFiles.isEmpty - ? null - : _selectedFiles.contains(e.location.path), - onSelected: _updateSelection(e.location.path), - onReload: reloadFileSystem, - gridView: true, - ); - }, - ).toList(), - ), - ); - } - return ListView.builder( - shrinkWrap: true, - itemCount: assets.length, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final e = assets[index]; - final active = widget.activeAsset == e.location; - return FileEntityItem( - entity: e, - active: active, - collapsed: widget.collapsed, - selected: _selectedFiles.isEmpty - ? null - : _selectedFiles.contains(e.location.path), - onTap: () => _onFileTap(e), - onSelected: _updateSelection(e.location.path), - onReload: reloadFileSystem, - gridView: false, - isMobile: widget.isMobile, + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (!snapshot.hasData) { + return Center( + child: + Text(AppLocalizations.of(context).noElements)); + } + final entity = snapshot.data; + if (entity is! FileSystemDirectory) { + return Container(); + } + final assets = entity.assets.where((e) { + if (_search.isNotEmpty) { + return e.fileName + .toLowerCase() + .contains(_search.toLowerCase()); + } + return true; + }).toList() + ..sort(_sortAssets); + if (assets.isEmpty) { + return Center( + child: Text(AppLocalizations.of(context).noElements), + ); + } + if (state.gridView && !widget.collapsed) { + return Center( + child: Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.start, + children: assets.map( + (e) { + final active = widget.activeAsset == e.location; + return FileEntityItem( + entity: e, + isMobile: widget.isMobile, + active: active, + collapsed: widget.collapsed, + onTap: () => _onFileTap(e), + selected: _selectedFiles.isEmpty + ? null + : _selectedFiles + .contains(e.location.path), + onSelected: _updateSelection(e.location.path), + onReload: reloadFileSystem, + gridView: true, + ); + }, + ).toList(), + ), ); - }, - ); - }), - ), - const SizedBox(height: 32), - ]), + } + return ListView.builder( + shrinkWrap: true, + itemCount: assets.length, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final e = assets[index]; + final active = widget.activeAsset == e.location; + return FileEntityItem( + entity: e, + active: active, + collapsed: widget.collapsed, + selected: _selectedFiles.isEmpty + ? null + : _selectedFiles.contains(e.location.path), + onTap: () => _onFileTap(e), + onSelected: _updateSelection(e.location.path), + onReload: reloadFileSystem, + gridView: false, + isMobile: widget.isMobile, + ); + }, + ); + }), + ), + const SizedBox(height: 32), + ]); + if (widget.isPage) { + return Scaffold( + appBar: WindowTitleBar( + title: Text(AppLocalizations.of(context).files), + actions: actionsChildren, + ), + body: SingleChildScrollView(child: content), + ); + } + return content; + }, ); } @@ -780,82 +806,3 @@ class FilesViewState extends State { } } } - -class _RecentFilesView extends StatefulWidget { - final bool replace; - const _RecentFilesView({ - required this.replace, - }); - - @override - State<_RecentFilesView> createState() => _RecentFilesViewState(); -} - -class _RecentFilesViewState extends State<_RecentFilesView> { - late Stream>> _stream; - late final ButterflyFileSystem _fileSystem; - final ScrollController _recentScrollController = ScrollController(); - - @override - void initState() { - super.initState(); - _fileSystem = context.read(); - _setStream(context.read().state); - } - - @override - void dispose() { - _recentScrollController.dispose(); - super.dispose(); - } - - void _setStream(ButterflySettings settings) => - _stream = GeneralDirectoryFileSystem.fetchAssetsGlobalSync( - settings.history, _fileSystem.buildAllDocumentSystems()); - - @override - Widget build(BuildContext context) { - return BlocListener( - listenWhen: (previous, current) => previous.history != current.history, - listener: (_, state) => setState(() { - _setStream(state); - }), - child: StreamBuilder>( - stream: _stream, - builder: (context, snapshot) { - final files = snapshot.data ?? []; - if (files.isEmpty) { - return Container(); - } - return SizedBox( - height: 128, - child: Scrollbar( - controller: _recentScrollController, - child: ListView.builder( - controller: _recentScrollController, - scrollDirection: Axis.horizontal, - itemCount: files.length, - itemBuilder: (context, index) { - final entity = files[index]; - FileMetadata? metadata; - Uint8List? thumbnail; - if (entity is FileSystemFile) { - metadata = entity.data?.getMetadata(); - thumbnail = entity.data?.getThumbnail(); - } - return AssetCard( - metadata: metadata, - thumbnail: thumbnail, - name: entity.location.identifier, - height: double.infinity, - onTap: () => - openFile(context, widget.replace, entity.location), - ); - }, - ), - ), - ); - }), - ); - } -} diff --git a/app/lib/views/home.dart b/app/lib/views/home.dart deleted file mode 100644 index c774e43aa14d..000000000000 --- a/app/lib/views/home.dart +++ /dev/null @@ -1,585 +0,0 @@ -import 'package:butterfly/actions/new.dart'; -import 'package:butterfly/actions/settings.dart'; -import 'package:butterfly/api/file_system.dart'; -import 'package:butterfly/api/open.dart'; -import 'package:butterfly/cubits/settings.dart'; -import 'package:butterfly/dialogs/template.dart'; -import 'package:butterfly/services/import.dart'; -import 'package:butterfly/views/files/card.dart'; -import 'package:butterfly_api/butterfly_api.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:lw_file_system/lw_file_system.dart'; -import 'package:material_leap/material_leap.dart'; -import 'package:phosphor_flutter/phosphor_flutter.dart'; - -import '../main.dart'; -import 'files/view.dart'; - -PhosphorIconData _getIconOfBannerVisibility(BannerVisibility visibility) => - switch (visibility) { - BannerVisibility.always => PhosphorIconsLight.caretDown, - BannerVisibility.never => PhosphorIconsLight.caretUp, - BannerVisibility.onlyOnUpdates => PhosphorIconsLight.caretRight, - }; - -String _getLocalizedNameOfBannerVisibility( - BuildContext context, BannerVisibility visibility) => - switch (visibility) { - BannerVisibility.always => AppLocalizations.of(context).always, - BannerVisibility.never => AppLocalizations.of(context).never, - BannerVisibility.onlyOnUpdates => - AppLocalizations.of(context).onlyOnUpdates, - }; - -Widget _getBannerVisibilityWidget( - BuildContext context, ButterflySettings settings) { - return MenuAnchor( - builder: defaultMenuButton( - tooltip: AppLocalizations.of(context).visibility, - icon: PhosphorIcon(_getIconOfBannerVisibility(settings.bannerVisibility)), - ), - menuChildren: BannerVisibility.values - .map( - (e) => MenuItemButton( - leadingIcon: Icon( - _getIconOfBannerVisibility(e), - ), - onPressed: () { - context.read().changeBannerVisibility(e); - }, - child: Text(_getLocalizedNameOfBannerVisibility(context, e)), - ), - ) - .toList(), - ); -} - -class HomePage extends StatefulWidget { - final AssetLocation? selectedAsset; - - const HomePage({super.key, this.selectedAsset}); - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - final GlobalKey _filesViewKey = GlobalKey(); - ExternalStorage? _remote; - late ImportService _importService; - - @override - void initState() { - super.initState(); - _remote = context.read().state.getDefaultRemote(); - _importService = ImportService(context); - } - - void updateRemote(ExternalStorage? remote) { - setState(() { - _remote = remote; - _importService = - ImportService(context, storage: _remote, useDefaultStorage: false); - }); - } - - @override - Widget build(BuildContext context) { - return MultiRepositoryProvider( - providers: [ - RepositoryProvider.value( - value: _importService, - ), - ], - child: BlocBuilder( - buildWhen: (previous, current) => - previous.bannerVisibility != current.bannerVisibility, - builder: (context, settings) { - return FutureBuilder( - future: context.read().hasNewerVersion(), - builder: (context, snapshot) { - final hasNewerVersion = snapshot.data ?? false; - final showBanner = - settings.bannerVisibility == BannerVisibility.always || - (settings.bannerVisibility == - BannerVisibility.onlyOnUpdates && - hasNewerVersion); - return Scaffold( - appBar: WindowTitleBar( - title: const Text(shortApplicationName), - onlyShowOnDesktop: showBanner, - actions: [ - if (!showBanner) ...[ - IconButton( - icon: const PhosphorIcon(PhosphorIconsLight.bookOpen), - tooltip: AppLocalizations.of(context).documentation, - onPressed: () => openHelp(['intro']), - ), - IconButton( - icon: const PhosphorIcon(PhosphorIconsLight.gear), - tooltip: AppLocalizations.of(context).settings, - onPressed: () => openSettings(context), - ), - _getBannerVisibilityWidget(context, settings), - ], - ], - ), - body: SingleChildScrollView( - child: Align( - alignment: Alignment.topCenter, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - constraints: const BoxConstraints(maxWidth: 1400), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - LayoutBuilder(builder: (context, constraints) { - final isDesktop = constraints.maxWidth > 1000; - return _HeaderHomeView( - hasNewerVersion: hasNewerVersion, - isDesktop: isDesktop, - showBanner: showBanner, - ); - }), - const SizedBox(height: 16), - LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth > 1000) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: FilesView( - activeAsset: widget.selectedAsset, - remote: _remote, - isMobile: false, - onRemoteChanged: (value) => - updateRemote(value), - )), - const SizedBox(width: 16), - SizedBox( - width: 350, - child: _QuickstartHomeView( - remote: _remote, - isMobile: false, - onReload: () => setState(() => - _filesViewKey.currentState - ?.reloadFileSystem()), - ), - ), - ], - ); - } else { - return Column( - children: [ - _QuickstartHomeView( - remote: _remote, - isMobile: true, - onReload: () => setState(() => - _filesViewKey.currentState - ?.reloadFileSystem()), - ), - const SizedBox(height: 32), - FilesView( - activeAsset: widget.selectedAsset, - remote: _remote, - isMobile: true, - key: _filesViewKey, - onRemoteChanged: (value) => - updateRemote(value), - ), - ], - ); - } - }, - ), - ], - ), - ), - ), - ), - ); - }, - ); - }, - ), - ); - } -} - -class _HeaderHomeView extends StatefulWidget { - final bool hasNewerVersion, isDesktop, showBanner; - const _HeaderHomeView({ - this.hasNewerVersion = false, - required this.isDesktop, - required this.showBanner, - }); - - @override - State<_HeaderHomeView> createState() => _HeaderHomeViewState(); -} - -class _HeaderHomeViewState extends State<_HeaderHomeView> - with TickerProviderStateMixin { - late final AnimationController _expandController; - late final Animation _animation; - late final SettingsCubit _settingsCubit; - - @override - void initState() { - super.initState(); - _settingsCubit = context.read(); - _expandController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 500), - value: widget.showBanner ? 1 : 0, - ); - _animation = CurvedAnimation( - parent: _expandController, - curve: Curves.fastOutSlowIn, - ); - } - - @override - void didUpdateWidget(_HeaderHomeView oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.showBanner) { - _expandController.forward( - from: oldWidget.isDesktop != widget.isDesktop ? 0 : null); - } else { - _expandController.reverse(); - } - } - - @override - void dispose() { - _expandController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final actions = Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton.icon( - onPressed: () => openHelp(['intro']), - icon: const PhosphorIcon(PhosphorIconsLight.bookOpen), - label: Text(AppLocalizations.of(context).documentation), - ), - IconButton( - onPressed: () => openSettings(context), - icon: const PhosphorIcon(PhosphorIconsLight.gear), - tooltip: AppLocalizations.of(context).settings, - ), - _getBannerVisibilityWidget( - context, context.read().state), - ], - ); - final style = FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 20, - ), - textStyle: const TextStyle(fontSize: 20), - ); - void openNew() { - openReleaseNotes(); - _settingsCubit.updateLastVersion(); - } - - final whatsNew = Column( - mainAxisSize: MainAxisSize.min, - children: [ - widget.hasNewerVersion - ? FilledButton( - onPressed: openNew, - style: style, - child: Text(AppLocalizations.of(context).whatsNew), - ) - : ElevatedButton( - onPressed: openNew, - style: style, - child: Text(AppLocalizations.of(context).whatsNew), - ), - if (widget.hasNewerVersion) - const SizedBox( - height: 0, - child: Stack( - children: [ - Align( - alignment: Alignment.bottomCenter, - child: PhosphorIcon(PhosphorIconsLight.caretUp), - ), - ], - ), - ), - ], - ); - final logo = Row( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - 'images/logo.png', - width: 64, - ), - const SizedBox(width: 16), - Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).welcome, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: colorScheme.onInverseSurface, - ), - overflow: TextOverflow.clip, - ), - Text( - AppLocalizations.of(context).welcomeContent, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onInverseSurface, - ), - ), - ], - ), - ), - ], - ); - final innerCard = LayoutBuilder(builder: (context, constraints) { - final isMobile = constraints.maxWidth < LeapBreakpoints.compact; - if (isMobile) { - return Column( - children: [logo, const SizedBox(height: 16), whatsNew], - ); - } - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [logo, whatsNew], - ); - }); - final card = Material( - elevation: 10, - borderRadius: BorderRadius.circular(24), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colorScheme.secondary, - colorScheme.primary, - ], - stops: const [0.2, 0.8], - ), - ), - child: innerCard, - ), - ); - final child = widget.isDesktop - ? Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded(child: card), - const SizedBox(width: 32), - actions, - ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - card, - const SizedBox(height: 32), - actions, - ], - ); - return SizeTransition( - sizeFactor: _animation, - child: Column( - children: [ - const SizedBox(height: 64), - child, - const SizedBox(height: 48), - ], - ), - ); - } -} - -class _QuickstartHomeView extends StatefulWidget { - final ExternalStorage? remote; - final bool isMobile; - final VoidCallback onReload; - - const _QuickstartHomeView({ - this.remote, - required this.onReload, - required this.isMobile, - }); - - @override - State<_QuickstartHomeView> createState() => _QuickstartHomeViewState(); -} - -class _QuickstartHomeViewState extends State<_QuickstartHomeView> { - final ScrollController _scrollController = ScrollController(); - late final TemplateFileSystem _templateSystem; - Future>? _templatesFuture; - - @override - void initState() { - _templateSystem = - context.read().buildTemplateSystem(widget.remote); - WidgetsBinding.instance.addPostFrameCallback((_) => setState(() { - _templatesFuture = _fetchTemplates(); - })); - super.initState(); - } - - @override - void didUpdateWidget(covariant _QuickstartHomeView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.remote != widget.remote) { - setState(() { - _templatesFuture = _fetchTemplates(); - }); - } - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - Future> _fetchTemplates() => - _templateSystem.initialize().then((value) => _templateSystem - .getFiles() - .then((value) => value.map((e) => e.data!).toList())); - - @override - Widget build(BuildContext context) { - return Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - child: Padding( - padding: const EdgeInsets.all(32), - child: - Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: Text( - AppLocalizations.of(context).quickstart, - style: Theme.of(context).textTheme.headlineMedium, - ), - ), - IconButton( - icon: const PhosphorIcon(PhosphorIconsLight.wrench), - tooltip: AppLocalizations.of(context).advanced, - onPressed: () => showDialog( - context: context, - builder: (ctx) => const TemplateDialog(), - ), - ), - IconButton( - icon: const PhosphorIcon(PhosphorIconsLight.arrowClockwise), - tooltip: AppLocalizations.of(context).refresh, - onPressed: () => setState(() { - _templatesFuture = _fetchTemplates(); - }), - ), - ], - ), - const SizedBox(height: 16), - FutureBuilder>( - future: _templatesFuture, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Text(snapshot.error.toString()); - } - if (snapshot.connectionState == ConnectionState.none) { - return ElevatedButton( - child: Text(AppLocalizations.of(context).view), - onPressed: () => setState(() { - _templatesFuture = _fetchTemplates(); - }), - ); - } - if (snapshot.connectionState == ConnectionState.waiting) { - return const Align( - alignment: Alignment.center, - child: CircularProgressIndicator(), - ); - } - final templates = snapshot.data ?? []; - if (templates.isEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - AppLocalizations.of(context).noTemplates, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 16), - FilledButton( - onPressed: () async { - await _templateSystem.initialize(force: true); - setState(() { - _templatesFuture = _fetchTemplates(); - }); - widget.onReload(); - }, - child: Text( - LeapLocalizations.of(context).reset, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ], - ); - } - final children = templates.map( - (e) { - final thumbnail = e.getThumbnail(); - final metadata = e.getMetadata()!; - return AssetCard( - metadata: metadata, - thumbnail: thumbnail, - onTap: () async { - await openNewDocument( - context, false, e, widget.remote?.identifier); - widget.onReload(); - }, - ); - }, - ).toList(); - if (widget.isMobile) { - return SizedBox( - height: 150, - child: Scrollbar( - controller: _scrollController, - child: ListView( - controller: _scrollController, - scrollDirection: Axis.horizontal, - children: children, - ), - ), - ); - } - return Column( - children: children, - ); - }), - ]), - ), - ); - } -} diff --git a/app/lib/views/home/header.dart b/app/lib/views/home/header.dart new file mode 100644 index 000000000000..245ee5f8bd15 --- /dev/null +++ b/app/lib/views/home/header.dart @@ -0,0 +1,244 @@ +part of 'page.dart'; + +PhosphorIconData _getIconOfBannerVisibility(BannerVisibility visibility) => + switch (visibility) { + BannerVisibility.always => PhosphorIconsLight.caretDown, + BannerVisibility.never => PhosphorIconsLight.caretUp, + BannerVisibility.onlyOnUpdates => PhosphorIconsLight.caretRight, + }; + +String _getLocalizedNameOfBannerVisibility( + BuildContext context, BannerVisibility visibility) => + switch (visibility) { + BannerVisibility.always => AppLocalizations.of(context).always, + BannerVisibility.never => AppLocalizations.of(context).never, + BannerVisibility.onlyOnUpdates => + AppLocalizations.of(context).onlyOnUpdates, + }; + +Widget _getBannerVisibilityWidget( + BuildContext context, ButterflySettings settings) { + return MenuAnchor( + builder: defaultMenuButton( + tooltip: AppLocalizations.of(context).visibility, + icon: PhosphorIcon(_getIconOfBannerVisibility(settings.bannerVisibility)), + ), + menuChildren: BannerVisibility.values + .map( + (e) => MenuItemButton( + leadingIcon: Icon( + _getIconOfBannerVisibility(e), + ), + onPressed: () { + context.read().changeBannerVisibility(e); + }, + child: Text(_getLocalizedNameOfBannerVisibility(context, e)), + ), + ) + .toList(), + ); +} + +class _HeaderHomeView extends StatefulWidget { + final bool hasNewerVersion, isDesktop, showBanner; + const _HeaderHomeView({ + this.hasNewerVersion = false, + required this.isDesktop, + required this.showBanner, + }); + + @override + State<_HeaderHomeView> createState() => _HeaderHomeViewState(); +} + +class _HeaderHomeViewState extends State<_HeaderHomeView> + with TickerProviderStateMixin { + late final AnimationController _expandController; + late final Animation _animation; + late final SettingsCubit _settingsCubit; + + @override + void initState() { + super.initState(); + _settingsCubit = context.read(); + _expandController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + value: widget.showBanner ? 1 : 0, + ); + _animation = CurvedAnimation( + parent: _expandController, + curve: Curves.fastOutSlowIn, + ); + } + + @override + void didUpdateWidget(_HeaderHomeView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.showBanner) { + _expandController.forward( + from: oldWidget.isDesktop != widget.isDesktop ? 0 : null); + } else { + _expandController.reverse(); + } + } + + @override + void dispose() { + _expandController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final actions = Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + onPressed: () => openHelp(['intro']), + icon: const PhosphorIcon(PhosphorIconsLight.bookOpen), + label: Text(AppLocalizations.of(context).documentation), + ), + IconButton( + onPressed: () => openSettings(context), + icon: const PhosphorIcon(PhosphorIconsLight.gear), + tooltip: AppLocalizations.of(context).settings, + ), + _getBannerVisibilityWidget( + context, context.read().state), + ], + ); + final style = FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 20, + ), + textStyle: const TextStyle(fontSize: 20), + ); + void openNew() { + openReleaseNotes(); + _settingsCubit.updateLastVersion(); + } + + final whatsNew = Column( + mainAxisSize: MainAxisSize.min, + children: [ + widget.hasNewerVersion + ? FilledButton( + onPressed: openNew, + style: style, + child: Text(AppLocalizations.of(context).whatsNew), + ) + : ElevatedButton( + onPressed: openNew, + style: style, + child: Text(AppLocalizations.of(context).whatsNew), + ), + if (widget.hasNewerVersion) + const SizedBox( + height: 0, + child: Stack( + children: [ + Align( + alignment: Alignment.bottomCenter, + child: PhosphorIcon(PhosphorIconsLight.caretUp), + ), + ], + ), + ), + ], + ); + final logo = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'images/logo.png', + width: 64, + ), + const SizedBox(width: 16), + Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).welcome, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: colorScheme.onInverseSurface, + ), + overflow: TextOverflow.clip, + ), + Text( + AppLocalizations.of(context).welcomeContent, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onInverseSurface, + ), + ), + ], + ), + ), + ], + ); + final innerCard = LayoutBuilder(builder: (context, constraints) { + final isMobile = constraints.maxWidth < LeapBreakpoints.compact; + if (isMobile) { + return Column( + children: [logo, const SizedBox(height: 16), whatsNew], + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [logo, whatsNew], + ); + }); + final card = Material( + elevation: 10, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.secondary, + colorScheme.primary, + ], + stops: const [0.2, 0.8], + ), + ), + child: innerCard, + ), + ); + final child = widget.isDesktop + ? Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: card), + const SizedBox(width: 32), + actions, + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + card, + const SizedBox(height: 32), + actions, + ], + ); + return SizeTransition( + sizeFactor: _animation, + child: Column( + children: [ + const SizedBox(height: 64), + child, + const SizedBox(height: 48), + ], + ), + ); + } +} diff --git a/app/lib/views/home/page.dart b/app/lib/views/home/page.dart new file mode 100644 index 000000000000..d2f1f98f6aee --- /dev/null +++ b/app/lib/views/home/page.dart @@ -0,0 +1,250 @@ +import 'package:butterfly/actions/new.dart'; +import 'package:butterfly/actions/settings.dart'; +import 'package:butterfly/api/file_system.dart'; +import 'package:butterfly/api/open.dart'; +import 'package:butterfly/cubits/settings.dart'; +import 'package:butterfly/dialogs/template.dart'; +import 'package:butterfly/services/import.dart'; +import 'package:butterfly/settings/home.dart'; +import 'package:butterfly/views/files/card.dart'; +import 'package:butterfly/views/files/recently.dart'; +import 'package:butterfly_api/butterfly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:lw_file_system/lw_file_system.dart'; +import 'package:material_leap/material_leap.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +import '../../main.dart'; +import '../files/view.dart'; + +part 'start.dart'; +part 'header.dart'; + +class HomePage extends StatefulWidget { + final AssetLocation? selectedAsset; + + const HomePage({super.key, this.selectedAsset}); + + @override + State createState() => _HomePageState(); +} + +enum _MobileTab { home, files, settings } + +class _HomePageState extends State { + final GlobalKey _filesViewKey = GlobalKey(); + ExternalStorage? _remote; + _MobileTab _tab = _MobileTab.home; + late ImportService _importService; + + @override + void initState() { + super.initState(); + _remote = context.read().state.getDefaultRemote(); + _importService = ImportService(context); + } + + void updateRemote(ExternalStorage? remote) { + setState(() { + _remote = remote; + _importService = + ImportService(context, storage: _remote, useDefaultStorage: false); + }); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final isDesktop = size.width > LeapBreakpoints.expanded; + final isMobile = size.width < LeapBreakpoints.compact; + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value( + value: _importService, + ), + ], + child: BlocBuilder( + buildWhen: (previous, current) => + previous.bannerVisibility != current.bannerVisibility, + builder: (context, settings) { + return FutureBuilder( + future: context.read().hasNewerVersion(), + builder: (context, snapshot) { + final hasNewerVersion = snapshot.data ?? false; + final showBanner = + settings.bannerVisibility == BannerVisibility.always || + (settings.bannerVisibility == + BannerVisibility.onlyOnUpdates && + hasNewerVersion); + final appBar = WindowTitleBar( + title: const Text(shortApplicationName), + onlyShowOnDesktop: showBanner && isDesktop, + actions: [ + if (isMobile || !showBanner) ...[ + if (isMobile) + IconButton( + icon: + const PhosphorIcon(PhosphorIconsLight.shootingStar), + selectedIcon: + const PhosphorIcon(PhosphorIconsFill.shootingStar), + tooltip: AppLocalizations.of(context).whatsNew, + isSelected: hasNewerVersion, + onPressed: () { + openReleaseNotes(); + context.read().updateLastVersion(); + }, + ), + IconButton( + icon: const PhosphorIcon(PhosphorIconsLight.bookOpen), + tooltip: AppLocalizations.of(context).documentation, + onPressed: () => openHelp(['intro']), + ), + if (!isMobile) + IconButton( + icon: const PhosphorIcon(PhosphorIconsLight.gear), + tooltip: AppLocalizations.of(context).settings, + onPressed: () => openSettings(context), + ), + if (!isMobile) + _getBannerVisibilityWidget(context, settings), + ], + ], + ); + return Scaffold( + appBar: isMobile ? null : appBar, + bottomNavigationBar: isMobile + ? NavigationBar( + destinations: [ + NavigationDestination( + icon: const Icon(PhosphorIconsLight.house), + selectedIcon: const Icon(PhosphorIconsFill.house), + label: AppLocalizations.of(context).home, + ), + NavigationDestination( + icon: const Icon(PhosphorIconsLight.folder), + selectedIcon: const Icon(PhosphorIconsFill.folder), + label: AppLocalizations.of(context).files, + ), + NavigationDestination( + icon: const Icon(PhosphorIconsLight.gear), + selectedIcon: const Icon(PhosphorIconsFill.gear), + label: AppLocalizations.of(context).settings, + ), + ], + onDestinationSelected: (index) { + setState(() { + _tab = _MobileTab.values[index]; + }); + }, + selectedIndex: _tab.index, + ) + : null, + body: isMobile + ? DefaultTabController( + length: 1, + child: switch (_tab) { + _MobileTab.home => Scaffold( + appBar: appBar, + body: ListView( + children: [ + _QuickstartHomeView( + remote: _remote, + isMobile: true, + onReload: () => setState(() => _filesViewKey + .currentState + ?.reloadFileSystem()), + ), + RecentFilesView( + replace: false, + asGrid: true, + ), + ], + ), + ), + _MobileTab.files => FilesView( + activeAsset: widget.selectedAsset, + remote: _remote, + isMobile: true, + isPage: true, + key: _filesViewKey, + onRemoteChanged: (value) => updateRemote(value), + ), + _MobileTab.settings => SettingsPage(), + }) + : SingleChildScrollView( + child: Align( + alignment: Alignment.topCenter, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + constraints: const BoxConstraints(maxWidth: 1400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _HeaderHomeView( + hasNewerVersion: hasNewerVersion, + isDesktop: isDesktop, + showBanner: showBanner, + ), + const SizedBox(height: 16), + if (isDesktop) + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + child: FilesView( + activeAsset: widget.selectedAsset, + remote: _remote, + isMobile: false, + onRemoteChanged: (value) => + updateRemote(value), + )), + const SizedBox(width: 16), + SizedBox( + width: 350, + child: _QuickstartHomeView( + remote: _remote, + isMobile: false, + onReload: () => setState(() => + _filesViewKey.currentState + ?.reloadFileSystem()), + ), + ), + ], + ) + else + Column( + children: [ + _QuickstartHomeView( + remote: _remote, + isMobile: true, + onReload: () => setState(() => + _filesViewKey.currentState + ?.reloadFileSystem()), + ), + const SizedBox(height: 32), + FilesView( + activeAsset: widget.selectedAsset, + remote: _remote, + isMobile: true, + key: _filesViewKey, + onRemoteChanged: (value) => + updateRemote(value), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/app/lib/views/home/start.dart b/app/lib/views/home/start.dart new file mode 100644 index 000000000000..4cdd2977f452 --- /dev/null +++ b/app/lib/views/home/start.dart @@ -0,0 +1,172 @@ +part of 'page.dart'; + +class _QuickstartHomeView extends StatefulWidget { + final ExternalStorage? remote; + final bool isMobile; + final VoidCallback onReload; + + const _QuickstartHomeView({ + this.remote, + required this.onReload, + required this.isMobile, + }); + + @override + State<_QuickstartHomeView> createState() => _QuickstartHomeViewState(); +} + +class _QuickstartHomeViewState extends State<_QuickstartHomeView> { + final ScrollController _scrollController = ScrollController(); + late final TemplateFileSystem _templateSystem; + Future>? _templatesFuture; + + @override + void initState() { + _templateSystem = + context.read().buildTemplateSystem(widget.remote); + WidgetsBinding.instance.addPostFrameCallback((_) => setState(() { + _templatesFuture = _fetchTemplates(); + })); + super.initState(); + } + + @override + void didUpdateWidget(covariant _QuickstartHomeView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.remote != widget.remote) { + setState(() { + _templatesFuture = _fetchTemplates(); + }); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future> _fetchTemplates() => + _templateSystem.initialize().then((value) => _templateSystem + .getFiles() + .then((value) => value.map((e) => e.data!).toList())); + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Padding( + padding: const EdgeInsets.all(32), + child: + Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context).quickstart, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + IconButton( + icon: const PhosphorIcon(PhosphorIconsLight.wrench), + tooltip: AppLocalizations.of(context).advanced, + onPressed: () => showDialog( + context: context, + builder: (ctx) => const TemplateDialog(), + ), + ), + IconButton( + icon: const PhosphorIcon(PhosphorIconsLight.arrowClockwise), + tooltip: AppLocalizations.of(context).refresh, + onPressed: () => setState(() { + _templatesFuture = _fetchTemplates(); + }), + ), + ], + ), + const SizedBox(height: 16), + FutureBuilder>( + future: _templatesFuture, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } + if (snapshot.connectionState == ConnectionState.none) { + return ElevatedButton( + child: Text(AppLocalizations.of(context).view), + onPressed: () => setState(() { + _templatesFuture = _fetchTemplates(); + }), + ); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Align( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ); + } + final templates = snapshot.data ?? []; + if (templates.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + AppLocalizations.of(context).noTemplates, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () async { + await _templateSystem.initialize(force: true); + setState(() { + _templatesFuture = _fetchTemplates(); + }); + widget.onReload(); + }, + child: Text( + LeapLocalizations.of(context).reset, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ); + } + final children = templates.map( + (e) { + final thumbnail = e.getThumbnail(); + final metadata = e.getMetadata()!; + return AssetCard( + metadata: metadata, + thumbnail: thumbnail, + onTap: () async { + await openNewDocument( + context, false, e, widget.remote?.identifier); + widget.onReload(); + }, + ); + }, + ).toList(); + if (widget.isMobile) { + return SizedBox( + height: 150, + child: Scrollbar( + controller: _scrollController, + child: ListView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + children: children, + ), + ), + ); + } + return Column( + children: children, + ); + }), + ]), + ), + ); + } +} diff --git a/metadata/en-US/changelogs/127.txt b/metadata/en-US/changelogs/127.txt index 9a1762cd8878..7d82b207e46b 100644 --- a/metadata/en-US/changelogs/127.txt +++ b/metadata/en-US/changelogs/127.txt @@ -1,2 +1,13 @@ +* Redesign mobile home page + * Use tabs for easier navigation + * Dedicated files view + * Move settings into a tab +* Add toggleable tools +* Move ruler into own tool + * Add color property +* Move grid into own tool * Add password protected notes ([#771](https://github.com/LinwoodDev/Butterfly/issues/771)) -* Fix undo/redo tools not showing status correctly \ No newline at end of file +* Fix undo/redo tools not showing status correctly +* Fix grid not working correctly + +Read more here: https://linwood.dev/butterfly/2.3.0-beta.0 \ No newline at end of file