diff --git a/client/lib/ui/explorer/explorer_view.dart b/client/lib/ui/explorer/explorer_view.dart index 72939f44..a4a8ef93 100644 --- a/client/lib/ui/explorer/explorer_view.dart +++ b/client/lib/ui/explorer/explorer_view.dart @@ -22,6 +22,7 @@ import 'package:super_clipboard/super_clipboard.dart'; import 'explorer_controller.dart'; import 'folder_empty_view.dart'; +import 'folder_grid_view.dart'; import 'folder_list_view.dart'; class ExplorerView extends StatefulWidget { @@ -154,7 +155,7 @@ class _ExplorerViewState extends State { return RefreshIndicator( key: _refreshKey, onRefresh: context.read().refresh, - child: const FolderListView(), + child: const FolderGridView(), ); } } diff --git a/client/lib/ui/explorer/folder_grid_item.dart b/client/lib/ui/explorer/folder_grid_item.dart new file mode 100644 index 00000000..ecbf113a --- /dev/null +++ b/client/lib/ui/explorer/folder_grid_item.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:phylum/libphylum/db/db.dart'; +import 'package:phylum/libphylum/phylum_account.dart'; +import 'package:phylum/ui/explorer/resource_icon_extension.dart'; +import 'package:phylum/util/file_size.dart'; +import 'package:phylum/util/time.dart'; +import 'package:provider/provider.dart'; + +import 'explorer_controller.dart'; + +class FolderGridItem extends StatelessWidget { + final Resource resource; + final bool dropTargetActive; + + FolderGridItem({required this.resource, required this.dropTargetActive}) : super(key: ValueKey(resource.id)); + + @override + Widget build(BuildContext context) { + final account = context.read(); + final showBorder = context.select((state) => state.focusId == resource.id && state.showFocus); + final highlight = context.select((state) => state.isSelected(resource.id)); + final dim = context.select((state) => state.isSelected(resource.id) && state.dragging); + + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final border = dropTargetActive + ? BorderSide(color: colorScheme.secondary, width: 2.0) + : showBorder + ? BorderSide(color: colorScheme.primary, width: 2.0) + : const BorderSide(color: Colors.transparent, width: 2.0); + + final color = highlight + ? dim + ? colorScheme.primaryContainer.withAlpha(192) + : colorScheme.primaryContainer + : dropTargetActive + ? colorScheme.secondaryContainer + : null; + + return IconTheme( + data: IconThemeData( + color: colorScheme.onSurfaceVariant, + ), + child: Card( + color: color, + borderOnForeground: true, + clipBehavior: Clip.antiAlias, + shape: dropTargetActive || showBorder + ? RoundedRectangleBorder(side: border, borderRadius: BorderRadius.circular(12.0)) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: resource.getIcon(account)), + Container( + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerLow), + padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + resource.name, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: theme.textTheme.bodyLarge!.copyWith( + color: highlight + ? dim + ? colorScheme.primary.withAlpha(192) + : colorScheme.primary + : colorScheme.onSurface, + ), + ), + StreamBuilder( + stream: account.db.latestVersion(resource.id).watchSingleOrNull(), + builder: (context, snapshot) { + String subtitle = ""; + if (resource.modified != null) { + subtitle = resource.modified!.formatShort(); + } + final data = snapshot.data; + if (data != null) { + if (subtitle.isNotEmpty) { + subtitle += " \u2022 "; + } + subtitle += data.size.formatForDisplay(); + } + return Text( + subtitle, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: theme.textTheme.bodyMedium!.copyWith( + color: highlight + ? dim + ? colorScheme.primary.withAlpha(192) + : colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ); + }), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/ui/explorer/folder_grid_view.dart b/client/lib/ui/explorer/folder_grid_view.dart new file mode 100644 index 00000000..069db66a --- /dev/null +++ b/client/lib/ui/explorer/folder_grid_view.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:phylum/ui/app/shortcuts.dart'; +import 'package:phylum/libphylum/db/db.dart'; +import 'package:phylum/ui/explorer/folder_grid_item.dart'; +import 'package:phylum/ui/explorer/resource_drop_and_drop.dart'; +import 'package:phylum/ui/explorer/resource_item_gesture_handler.dart'; +import 'package:provider/provider.dart'; + +import 'explorer_controller.dart'; + +const _rowSpacing = 12.0; + +class FolderGridView extends StatefulWidget { + const FolderGridView({super.key}); + + @override + State createState() => _FolderGridViewState(); +} + +class _FolderGridViewState extends State { + double _rowHeight = 0.0; + double _viewportHeight = 0.0; + int _crossAxisCount = 1; + final _scrollController = ScrollController(debugLabel: "ResourceItemGrid"); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void ensureVisible(int index) { + final currentOffset = _scrollController.offset; + final itemStart = (index ~/ _crossAxisCount) * (_rowHeight + _rowSpacing); + if (itemStart < currentOffset) { + _scrollController.jumpTo(itemStart); + } else if (currentOffset <= itemStart + _rowHeight + -_viewportHeight) { + _scrollController.jumpTo(itemStart - _viewportHeight + _rowHeight); + } + } + + @override + Widget build(BuildContext context) { + final resources = context.select>((state) => state.resources); + return LayoutBuilder(builder: (context, constraints) { + _viewportHeight = constraints.maxHeight; + _crossAxisCount = constraints.maxWidth ~/ 240; + _rowHeight = constraints.maxWidth / _crossAxisCount; + return Actions( + actions: { + FocusUpIntent: CallbackAction(onInvoke: (i) { + final index = context + .read() + .updateSelection((i) => i < _crossAxisCount ? i : i - _crossAxisCount, i.mode, true); + ensureVisible(index); + return null; + }), + FocusDownIntent: CallbackAction(onInvoke: (i) { + final index = context.read().updateSelection( + (i) => i < 0 + ? 0 + : i + _crossAxisCount >= resources.length + ? i + : i + _crossAxisCount, + i.mode, + true); + ensureVisible(index); + return null; + }), + FocusLeftIntent: CallbackAction(onInvoke: (i) { + final index = context.read().updateSelection((i) => i - 1, i.mode, true); + ensureVisible(index); + return null; + }), + FocusRightIntent: CallbackAction(onInvoke: (i) { + final index = context.read().updateSelection((i) => i + 1, i.mode, true); + ensureVisible(index); + return null; + }), + FocusFirstIntent: CallbackAction(onInvoke: (i) { + final index = context.read().updateSelection((i) => 0, i.mode, true); + ensureVisible(index); + return null; + }), + FocusLastIntent: CallbackAction(onInvoke: (i) { + final index = context.read().updateSelection((i) => 1 << 31, i.mode, true); + ensureVisible(index); + return null; + }), + }, + child: Focus( + autofocus: true, + descendantsAreFocusable: false, + child: GridView.builder( + itemCount: resources.length, + controller: _scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _crossAxisCount, + crossAxisSpacing: 12, + mainAxisSpacing: _rowSpacing, + mainAxisExtent: _rowHeight, + ), + itemBuilder: (context, index) { + final resource = resources[index]; + return (resource.dir) + ? ResourceDragTarget( + resourceId: resource.id, + buildItem: (context, dropTargetActive) => ResourceItemGestureHandler( + index: index, + resource: resource, + buildItem: () => FolderGridItem( + resource: resource, + dropTargetActive: dropTargetActive, + ), + ), + ) + : ResourceItemGestureHandler( + index: index, + resource: resource, + buildItem: () => FolderGridItem( + resource: resource, + dropTargetActive: false, + ), + ); + }), + ), + ); + }); + } +} diff --git a/client/lib/ui/explorer/folder_list_view.dart b/client/lib/ui/explorer/folder_list_view.dart index 1c3318fe..c9f4c96a 100644 --- a/client/lib/ui/explorer/folder_list_view.dart +++ b/client/lib/ui/explorer/folder_list_view.dart @@ -62,7 +62,7 @@ class _FolderListViewState extends State { return null; }), FocusLastIntent: CallbackAction(onInvoke: (i) { - final index = context.read().updateSelection((i) => 4294967296, i.mode, true); + final index = context.read().updateSelection((i) => 1 << 31, i.mode, true); ensureVisible(index); return null; }), @@ -71,7 +71,7 @@ class _FolderListViewState extends State { autofocus: true, descendantsAreFocusable: false, child: LayoutBuilder(builder: (context, constraints) { - _rowHeight ??= calculateRowHeight(context); + _rowHeight ??= ResourceDetailsRow.calculateHeight(context); _viewportHeight = constraints.maxHeight; return ListView.builder( itemCount: resources.length, diff --git a/client/lib/ui/explorer/resource_details_row.dart b/client/lib/ui/explorer/resource_details_row.dart index a391cff5..200b2aae 100644 --- a/client/lib/ui/explorer/resource_details_row.dart +++ b/client/lib/ui/explorer/resource_details_row.dart @@ -19,34 +19,6 @@ const _iconPaddingH = 16.0; const _iconSize = 24.0; const _subtitleIconSize = 14.0; -double calculateRowHeight(BuildContext context) { - final theme = Theme.of(context); - final scaler = MediaQuery.textScalerOf(context); - final titleSize = (TextPainter( - text: TextSpan(text: 'A', style: theme.textTheme.bodyLarge!), - maxLines: 1, - textScaler: scaler, - textDirection: TextDirection.ltr) - ..layout()) - .size - .height; - final subtitleTextSize = (TextPainter( - text: TextSpan(text: 'A', style: theme.textTheme.bodyMedium!), - maxLines: 1, - textScaler: scaler, - textDirection: TextDirection.ltr) - ..layout()) - .size - .height; - // final subtitleTextSize = scaler.scale(theme.textTheme.bodyMedium!.fontSize!); - final subtitleSize = subtitleTextSize > _subtitleIconSize ? subtitleTextSize : _subtitleIconSize; - final contentSize = titleSize + subtitleSize; - final iconSize = _iconSize + (_iconPaddingV * 2); - final totalSize = (2 * _rowPaddingV) + (iconSize > contentSize ? iconSize : contentSize); - // print('$totalSize $titleSize $subtitleSize ($subtitleTextSize $_subtitleIconSize)'); - return totalSize; -} - class ResourceDetailsRow extends StatelessWidget { final Resource resource; final bool dropTargetActive; @@ -205,4 +177,32 @@ class ResourceDetailsRow extends StatelessWidget { )), ); } + + static double calculateHeight(BuildContext context) { + final theme = Theme.of(context); + final scaler = MediaQuery.textScalerOf(context); + final titleSize = (TextPainter( + text: TextSpan(text: 'A', style: theme.textTheme.bodyLarge!), + maxLines: 1, + textScaler: scaler, + textDirection: TextDirection.ltr) + ..layout()) + .size + .height; + final subtitleTextSize = (TextPainter( + text: TextSpan(text: 'A', style: theme.textTheme.bodyMedium!), + maxLines: 1, + textScaler: scaler, + textDirection: TextDirection.ltr) + ..layout()) + .size + .height; + // final subtitleTextSize = scaler.scale(theme.textTheme.bodyMedium!.fontSize!); + final subtitleSize = subtitleTextSize > _subtitleIconSize ? subtitleTextSize : _subtitleIconSize; + final contentSize = titleSize + subtitleSize; + final iconSize = _iconSize + (_iconPaddingV * 2); + final totalSize = (2 * _rowPaddingV) + (iconSize > contentSize ? iconSize : contentSize); + // print('$totalSize $titleSize $subtitleSize ($subtitleTextSize $_subtitleIconSize)'); + return totalSize; + } }