Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -49,33 +49,43 @@ class SmallWebHistoryHeader extends ConsumerWidget {
children: [
Text('Recent Discoveries', style: theme.textTheme.titleSmall),
const Spacer(),
PopupMenuButton<_ClearAction>(
icon: Icon(
Icons.more_vert,
size: 20,
color: colorScheme.onSurfaceVariant,
MenuAnchor(
builder: (context, controller, _) => IconButton(
icon: Icon(
Icons.more_vert,
size: 20,
color: colorScheme.onSurfaceVariant,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
controller.isOpen ? controller.close() : controller.open(),
),
iconSize: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
offset: const Offset(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (action) async {
switch (action) {
case _ClearAction.clearMode:
await ref
.read(smallWebDatabaseProvider)
.smallWebVisitDao
.deleteVisitsBySourceAndMode(
sourceKind: sourceKind,
mode: mode,
);
case _ClearAction.clearAll:
menuChildren: [
if (modeLabel != null)
MenuItemButton(
leadingIcon: const Icon(Icons.delete_sweep, size: 20),
onPressed: () async {
await ref
.read(smallWebDatabaseProvider)
.smallWebVisitDao
.deleteVisitsBySourceAndMode(
sourceKind: sourceKind,
mode: mode,
);
},
child: Text('Clear $modeLabel'),
),
MenuItemButton(
leadingIcon: Icon(
Icons.delete_forever,
size: 20,
color: colorScheme.error,
),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -101,33 +111,10 @@ class SmallWebHistoryHeader extends ConsumerWidget {
.smallWebVisitDao
.deleteAllVisits();
}
}
},
itemBuilder: (context) => [
if (modeLabel != null)
PopupMenuItem(
value: _ClearAction.clearMode,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.delete_sweep, size: 20),
title: Text('Clear $modeLabel'),
),
),
PopupMenuItem(
value: _ClearAction.clearAll,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.delete_forever,
size: 20,
color: colorScheme.error,
),
title: Text(
'Clear all discoveries',
style: TextStyle(color: colorScheme.error),
),
},
child: Text(
'Clear all discoveries',
style: TextStyle(color: colorScheme.error),
),
),
],
@@ -137,8 +124,6 @@ class SmallWebHistoryHeader extends ConsumerWidget {
}
}
enum _ClearAction { clearMode, clearAll }
class SmallWebHistoryList extends HookConsumerWidget {
static const _initialCount = 10;
@@ -17,6 +17,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -140,46 +141,45 @@ class _SourceKindChip extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuButton<SmallWebSourceKind>(
initialValue: sourceKind,
onSelected: (kind) {
if (kind != sourceKind) onChanged(kind);
},
offset: const Offset(0, 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
itemBuilder: (context) => [
return MenuAnchor(
builder: (context, controller, _) => InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => controller.isOpen ? controller.close() : controller.open(),
child: Chip(
avatar: Icon(sourceKind.icon, size: 18),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(sourceKind.label),
const SizedBox(width: 2),
const Icon(Icons.arrow_drop_down, size: 18),
],
),
visualDensity: VisualDensity.compact,
),
),
menuChildren: [
for (final kind in SmallWebSourceKind.values)
PopupMenuItem(
value: kind,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(kind.icon, size: 20),
title: Text(kind.label),
subtitle: Text(
kind.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
MenuItemButton(
onPressed: () => onChanged(kind),
leadingIcon: Icon(kind.icon, size: 20),
trailingIcon: kind == sourceKind
? Icon(Icons.check, size: 20, color: colorScheme.primary)
: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(kind.label),
Text(
kind.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
trailing: kind == sourceKind
? Icon(Icons.check, size: 20, color: colorScheme.primary)
: null,
],
),
),
],
child: Chip(
avatar: Icon(sourceKind.icon, size: 18),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(sourceKind.label),
const SizedBox(width: 2),
const Icon(Icons.arrow_drop_down, size: 18),
],
),
visualDensity: VisualDensity.compact,
),
);
}
}
@@ -282,74 +282,80 @@ class _WebCategoriesPanel extends ConsumerWidget {
final categoriesAsync = ref.watch(kagiCategoriesProvider);
return categoriesAsync.when(
data: (kagiCategories) => ListView(
data: (kagiCategories) => FadingScroll(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
Text('Refine Category', style: theme.textTheme.titleSmall),
FilterChip(
label: const Text('All'),
selected: session.currentCategory == null,
showCheckmark: false,
onSelected: (_) async {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.setCategory(null);
await notifier.discover();
},
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Refine Category', style: theme.textTheme.titleSmall),
FilterChip(
label: const Text('All'),
selected: session.currentCategory == null,
showCheckmark: false,
onSelected: (_) async {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.setCategory(null);
await notifier.discover();
},
),
],
),
const SizedBox(height: 8),
for (final MapEntry(key: groupName, value: slugs)
in kagiCategories.groups.entries) ...[
_SectionHeader(title: groupName),
const SizedBox(height: 6),
_CategoryGrid(
categories: kagiCategories.categories,
slugs: slugs,
currentCategory: session.currentCategory,
onCategorySelected: (slug) async {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.setCategory(
session.currentCategory == slug ? null : slug,
);
await notifier.discover();
},
),
const SizedBox(height: 10),
],
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
compact: true,
),
const SizedBox(height: 8),
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(
sourceKind: session.sourceKind,
mode: session.mode,
),
],
),
const SizedBox(height: 8),
for (final MapEntry(key: groupName, value: slugs)
in kagiCategories.groups.entries) ...[
_SectionHeader(title: groupName),
const SizedBox(height: 6),
_CategoryGrid(
categories: kagiCategories.categories,
slugs: slugs,
currentCategory: session.currentCategory,
onCategorySelected: (slug) async {
final notifier = ref.read(
smallWebSessionControllerProvider.notifier,
);
notifier.setCategory(
session.currentCategory == slug ? null : slug,
);
await notifier.discover();
},
),
const SizedBox(height: 10),
],
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
compact: true,
),
const SizedBox(height: 8),
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(
sourceKind: session.sourceKind,
mode: session.mode,
),
],
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const SizedBox.shrink(),
@@ -394,58 +400,71 @@ class _ModeContextPanel extends ConsumerWidget {
KagiSmallWebMode.web => (Icons.language, ''),
};
return ListView(
return FadingScroll(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
const SizedBox(height: 16),
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
const SizedBox(height: 16),
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 36,
color: colorScheme.onPrimaryContainer,
),
),
),
child: Icon(icon, size: 36, color: colorScheme.onPrimaryContainer),
),
),
const SizedBox(height: 12),
Text(
'Searching ${mode.label}',
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
description,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
compact: true,
),
const SizedBox(height: 8),
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(sourceKind: session.sourceKind, mode: session.mode),
],
const SizedBox(height: 12),
Text(
'Searching ${mode.label}',
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
description,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
compact: true,
),
const SizedBox(height: 8),
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(
sourceKind: session.sourceKind,
mode: session.mode,
),
],
);
},
);
}
}
@@ -462,44 +481,53 @@ class _DefaultContentPanel extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
return FadingScroll(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
),
const SizedBox(height: 12),
if (session.sourceKind == SmallWebSourceKind.wander) ...[
_WanderConsoleCard(currentConsoleUrl: session.currentConsoleUrl),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () async {
Navigator.of(context).pop();
await showWanderConsoleSheet(context);
},
icon: const Icon(Icons.dns, size: 18),
label: const Text('Browse Consoles'),
),
const SizedBox(height: 12),
],
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(sourceKind: session.sourceKind, mode: session.mode),
],
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
if (session.infoMessage != null) ...[
_InfoMessageCard(message: session.infoMessage!),
const SizedBox(height: 8),
],
SmallWebAttributionCard(
data: SmallWebAttributionData.forSelection(
sourceKind: session.sourceKind,
mode: session.mode,
),
onOpenUri: (uri) => openSmallWebAttributionUri(context, uri),
),
const SizedBox(height: 12),
if (session.sourceKind == SmallWebSourceKind.wander) ...[
_WanderConsoleCard(currentConsoleUrl: session.currentConsoleUrl),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () async {
Navigator.of(context).pop();
await showWanderConsoleSheet(context);
},
icon: const Icon(Icons.dns, size: 18),
label: const Text('Browse Consoles'),
),
const SizedBox(height: 12),
],
const _DiscoverButton(),
const SizedBox(height: 12),
SmallWebHistoryHeader(
sourceKind: session.sourceKind,
mode: session.mode,
),
const SizedBox(height: 4),
SmallWebHistoryList(
sourceKind: session.sourceKind,
mode: session.mode,
),
],
);
},
);
}
}
@@ -736,16 +764,25 @@ class _SmallWebMenuLoading extends StatelessWidget {
),
const Divider(height: 1),
Expanded(
child: ListView(
child: FadingScroll(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: const [
Bone.text(words: 2),
SizedBox(height: 8),
_SkeletonHistoryItem(),
_SkeletonHistoryItem(),
_SkeletonHistoryItem(),
],
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
children: const [
Bone.text(words: 2),
SizedBox(height: 8),
_SkeletonHistoryItem(),
_SkeletonHistoryItem(),
_SkeletonHistoryItem(),
],
);
},
),
),
],
@@ -798,19 +835,25 @@ class _SmallWebMenuError extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
return FadingScroll(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
SizedBox(
height: 240,
child: FailureWidget(
title: 'Small Web unavailable',
exception: error,
onRetry: onRetry,
),
),
],
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
SizedBox(
height: 240,
child: FailureWidget(
title: 'Small Web unavailable',
exception: error,
onRetry: onRetry,
),
),
],
);
},
);
}
}
@@ -17,10 +17,12 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/domain/providers.dart';
import 'package:weblibre/presentation/widgets/inline_count_badge.dart';
class SmallWebModeChips extends ConsumerWidget {
final KagiSmallWebMode? currentMode;
@@ -34,16 +36,6 @@ class SmallWebModeChips extends ConsumerWidget {
required this.onModeSelected,
});
static String _formatCount(int count) {
if (count >= 1000) {
final k = count / 1000;
return k == k.roundToDouble()
? '${k.round()}k'
: '${k.toStringAsFixed(1)}k';
}
return count.toString();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final countsAsync = ref.watch(smallWebAllModeItemCountsProvider);
@@ -52,53 +44,46 @@ class SmallWebModeChips extends ConsumerWidget {
return SizedBox(
height: 48,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: KagiSmallWebMode.values.length,
itemBuilder: (context, index) {
final mode = KagiSmallWebMode.values[index];
final isSelected = currentMode == mode;
final count = counts[mode];
return Padding(
padding: const EdgeInsets.only(right: 8, top: 4),
child: ChoiceChip(
avatar: Icon(mode.icon, size: 18),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(mode.label),
if (count != null && count > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.15)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Text(
_formatCount(count),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isSelected
child: FadingScroll(
fadingSize: 15,
builder: (context, controller) {
return ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: KagiSmallWebMode.values.length,
itemBuilder: (context, index) {
final mode = KagiSmallWebMode.values[index];
final isSelected = currentMode == mode;
final count = counts[mode];
return Padding(
padding: const EdgeInsets.only(right: 8, top: 4),
child: ChoiceChip(
avatar: Icon(mode.icon, size: 18),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(mode.label),
if (count != null && count > 0) ...[
const SizedBox(width: 6),
InlineCountBadge(
count: count,
backgroundColor: isSelected
? colorScheme.primary.withValues(alpha: 0.15)
: colorScheme.surfaceContainerHighest,
foregroundColor: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
),
],
],
),
selected: isSelected,
showCheckmark: false,
onSelected: isLoading ? null : (_) => onModeSelected(mode),
),
],
],
),
selected: isSelected,
showCheckmark: false,
onSelected: isLoading ? null : (_) => onModeSelected(mode),
),
);
},
);
},
),
@@ -17,6 +17,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -263,18 +264,24 @@ class _WanderConsoleSheetLoading extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Skeletonizer(
child: ListView(
child: FadingScroll(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: const [
TextField(
decoration: InputDecoration(hintText: 'Filter consoles...'),
),
SizedBox(height: 12),
_SkeletonConsoleTile(),
_SkeletonConsoleTile(),
_SkeletonConsoleTile(),
],
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: const [
TextField(
decoration: InputDecoration(hintText: 'Filter consoles...'),
),
SizedBox(height: 12),
_SkeletonConsoleTile(),
_SkeletonConsoleTile(),
_SkeletonConsoleTile(),
],
);
},
),
);
}
@@ -323,19 +330,25 @@ class _WanderConsoleSheetError extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
return FadingScroll(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
SizedBox(
height: 240,
child: FailureWidget(
title: 'Could not load Small Web session',
exception: error,
onRetry: onRetry,
),
),
],
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
SizedBox(
height: 240,
child: FailureWidget(
title: 'Could not load Small Web session',
exception: error,
onRetry: onRetry,
),
),
],
);
},
);
}
}
@@ -381,17 +394,23 @@ class _LinkedConsoleList extends ConsumerWidget {
),
);
}
return ListView.builder(
return FadingScroll(
controller: scrollController,
padding: const EdgeInsets.only(top: 4, bottom: 56),
itemCount: filtered.length,
itemBuilder: (context, index) {
final console = filtered[index];
return _ConsoleListTile(
url: console.url,
pageCount: console.pageCount,
selectedConsoleUrl: selectedConsoleUrl,
isLoading: isLoading,
fadingSize: 25,
builder: (context, controller) {
return ListView.builder(
controller: controller,
padding: const EdgeInsets.only(top: 4, bottom: 56),
itemCount: filtered.length,
itemBuilder: (context, index) {
final console = filtered[index];
return _ConsoleListTile(
url: console.url,
pageCount: console.pageCount,
selectedConsoleUrl: selectedConsoleUrl,
isLoading: isLoading,
);
},
);
},
);
@@ -430,17 +449,23 @@ class _AllConsoleList extends ConsumerWidget {
),
);
}
return ListView.builder(
return FadingScroll(
controller: scrollController,
padding: const EdgeInsets.only(top: 4, bottom: 56),
itemCount: consoles.length,
itemBuilder: (context, index) {
final console = consoles[index];
return _ConsoleListTile(
url: console.url,
pageCount: console.pageCount,
selectedConsoleUrl: selectedConsoleUrl,
isLoading: isLoading,
fadingSize: 25,
builder: (context, controller) {
return ListView.builder(
controller: controller,
padding: const EdgeInsets.only(top: 4, bottom: 56),
itemCount: consoles.length,
itemBuilder: (context, index) {
final console = consoles[index];
return _ConsoleListTile(
url: console.url,
pageCount: console.pageCount,
selectedConsoleUrl: selectedConsoleUrl,
isLoading: isLoading,
);
},
);
},
);