From 225ce4631ce7048e165af487fe8c45538921d979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Wed, 4 Mar 2026 10:23:57 +0100 Subject: [PATCH] feat(garage): add TPB pagination and multi-select move workflow - Add lazy loading/pagination for collection garage grid (40 per page) - Load additional pages on scroll near bottom and show load-more indicator - Add selection mode with long-press card selection - Add bulk move action to transfer selected cars to another collection - Add selection state visuals on car cards - Keep existing detail/edit/delete behavior compatible with new paging model --- lib/screens/garage_screen.dart | 234 +++++++++++++++++++++++++++++---- lib/widgets/car_card.dart | 27 ++++ 2 files changed, 237 insertions(+), 24 deletions(-) diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index f2a94c6..f6b860a 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../main.dart'; +import '../services/collection_service.dart'; import '../services/storage_service.dart'; import '../theme/app_colors.dart'; import '../widgets/car_card.dart'; @@ -27,38 +28,74 @@ class GarageScreen extends StatefulWidget { class GarageScreenState extends State { List> _cars = []; bool _isLoading = true; + bool _isLoadingMore = false; + bool _hasMore = true; + int _page = 0; + static const int _pageSize = 40; + String? _error; String _searchQuery = ''; final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + + bool _selectionMode = false; + final Set _selectedIds = {}; @override void initState() { super.initState(); - _loadCars(); + _scrollController.addListener(_onScroll); + _loadCars(reset: true); } @override void dispose() { + _scrollController.dispose(); _searchController.dispose(); super.dispose(); } /// Public method so other screens can trigger a refresh. - void refresh() => _loadCars(); + void refresh() => _loadCars(reset: true); + + void _onScroll() { + if (!_scrollController.hasClients || _isLoadingMore || !_hasMore) return; + final pos = _scrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 320) { + _loadCars(); + } + } + + Future _loadCars({bool reset = false}) async { + if (reset) { + _page = 0; + _hasMore = true; + _selectedIds.clear(); + _selectionMode = false; + } + + if (!_hasMore && !reset) return; - Future _loadCars() async { setState(() { - _isLoading = true; - _error = null; + if (reset || _cars.isEmpty) { + _isLoading = true; + } else { + _isLoadingMore = true; + } + if (reset) _error = null; }); try { + final from = _page * _pageSize; + final to = from + _pageSize - 1; + final data = await supabase .from('hotwheels') .select( 'id, created_at, hw_id, notes, user_image_url, global_cars(name, series, year, color)') .eq('collection_id', widget.collectionId) - .order('created_at', ascending: false); + .order('created_at', ascending: false) + .range(from, to); final rows = List>.from(data); final withSignedUrls = await Future.wait( @@ -74,14 +111,18 @@ class GarageScreenState extends State { if (!mounted) return; setState(() { - _cars = withSignedUrls; + _cars = reset ? withSignedUrls : [..._cars, ...withSignedUrls]; + _hasMore = withSignedUrls.length == _pageSize; + if (_hasMore) _page += 1; _isLoading = false; + _isLoadingMore = false; }); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _isLoading = false; + _isLoadingMore = false; }); } } @@ -102,14 +143,27 @@ class GarageScreenState extends State { Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ - // ── Header ── SliverAppBar( expandedHeight: 140, pinned: true, + actions: [ + if (_selectionMode) + IconButton( + tooltip: 'Cancel Selection', + onPressed: () => _toggleSelectionMode(false), + icon: const Icon(Icons.close), + ) + else + IconButton( + tooltip: 'Select Cars', + onPressed: () => _toggleSelectionMode(true), + icon: const Icon(Icons.select_all), + ), + ], flexibleSpace: FlexibleSpaceBar( - titlePadding: - const EdgeInsets.only(left: 20, bottom: 16), + titlePadding: const EdgeInsets.only(left: 20, bottom: 16), title: Text( widget.collectionName, style: TextStyle( @@ -126,9 +180,7 @@ class GarageScreenState extends State { ), ), background: Container( - decoration: const BoxDecoration( - gradient: AppColors.brandGradient, - ), + decoration: const BoxDecoration(gradient: AppColors.brandGradient), child: Align( alignment: Alignment.centerRight, child: Padding( @@ -143,8 +195,6 @@ class GarageScreenState extends State { ), ), ), - - // ── Stats bar ── SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), @@ -165,8 +215,6 @@ class GarageScreenState extends State { ), ), ), - - // ── Search bar ── SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), @@ -189,8 +237,6 @@ class GarageScreenState extends State { ), ), ), - - // ── Content ── if (_isLoading) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), @@ -199,7 +245,7 @@ class GarageScreenState extends State { SliverFillRemaining( child: _ErrorView( message: _error!, - onRetry: _loadCars, + onRetry: () => _loadCars(reset: true), ), ) else if (_filteredCars.isEmpty) @@ -227,18 +273,60 @@ class GarageScreenState extends State { year: global?['year'] as int?, color: global?['color'] as String?, imageUrl: car['signed_image_url'] as String?, + isSelected: _selectedIds.contains(car['id'] as int), addedAt: car['created_at'] != null ? DateTime.tryParse(car['created_at']) : null, - onTap: () => _showCarDetails(car), + onTap: () => _selectionMode + ? _toggleCarSelection(car) + : _showCarDetails(car), + onLongPress: () => _toggleCarSelection(car), ); }, childCount: _filteredCars.length, ), ), ), + if (_isLoadingMore) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 18), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + ), ], ), + bottomNavigationBar: _selectionMode + ? SafeArea( + top: false, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).cardTheme.color, + border: Border( + top: BorderSide( + color: AppColors.textHint.withValues(alpha: 0.25), + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + '${_selectedIds.length} selected', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + OutlinedButton( + onPressed: _selectedIds.isEmpty ? null : _moveSelectedCars, + child: const Text('Move to...'), + ), + ], + ), + ), + ) + : null, ); } @@ -253,7 +341,105 @@ class GarageScreenState extends State { return '$count'; } + void _toggleSelectionMode(bool enabled) { + setState(() { + _selectionMode = enabled; + if (!enabled) { + _selectedIds.clear(); + } + }); + } + + void _toggleCarSelection(Map car) { + final id = car['id'] as int; + setState(() { + _selectionMode = true; + if (_selectedIds.contains(id)) { + _selectedIds.remove(id); + } else { + _selectedIds.add(id); + } + if (_selectedIds.isEmpty) { + _selectionMode = false; + } + }); + } + + Future _moveSelectedCars() async { + if (_selectedIds.isEmpty) return; + + try { + final collections = await CollectionService.getMyCollections(); + if (!mounted) return; + + final candidates = collections + .where((c) => c.id != widget.collectionId) + .toList(growable: false); + + if (candidates.isEmpty) { + showGlobalSnackBar('No other collection available.', isError: true); + return; + } + + String? targetId; + final confirmed = await showDialog( + context: context, + builder: (_) => StatefulBuilder( + builder: (context, setSheetState) => AlertDialog( + title: const Text('Move Selected Cars'), + content: DropdownButtonFormField( + initialValue: targetId, + decoration: const InputDecoration( + labelText: 'Target collection', + ), + items: candidates + .map( + (c) => DropdownMenuItem( + value: c.id, + child: Text(c.name), + ), + ) + .toList(), + onChanged: (value) => setSheetState(() => targetId = value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: targetId == null + ? null + : () => Navigator.pop(context, true), + child: const Text('Move'), + ), + ], + ), + ), + ); + + if (confirmed != true || targetId == null) return; + + await supabase + .from('hotwheels') + .update({'collection_id': targetId}) + .inFilter('id', _selectedIds.toList()); + + if (!mounted) return; + showGlobalSnackBar('${_selectedIds.length} car(s) moved.'); + _toggleSelectionMode(false); + await _loadCars(reset: true); + } catch (e) { + showGlobalSnackBar('Failed to move cars: $e', isError: true); + } + } + void _showCarDetails(Map car) { + if (_selectionMode) { + _toggleCarSelection(car); + return; + } + final global = car['global_cars'] as Map?; final hwId = car['hw_id'] as String? ?? '???'; final name = global?['name'] as String?; @@ -459,7 +645,7 @@ class GarageScreenState extends State { showGlobalSnackBar('Photo updated!'); if (sheetContext.mounted) Navigator.pop(sheetContext); - _loadCars(); // refresh grid + _loadCars(reset: true); // refresh grid } catch (e) { showGlobalSnackBar('Failed to save: $e', isError: true); } @@ -482,7 +668,7 @@ class GarageScreenState extends State { showGlobalSnackBar('Car updated!'); if (sheetContext.mounted) Navigator.pop(sheetContext); - _loadCars(); + _loadCars(reset: true); } catch (e) { showGlobalSnackBar('Failed to update: $e', isError: true); } @@ -527,7 +713,7 @@ class GarageScreenState extends State { Navigator.pop(sheetContext); // close bottom sheet } showGlobalSnackBar('${car['hw_id']} removed from your garage.'); - _loadCars(); + _loadCars(reset: true); } catch (e) { showGlobalSnackBar('Failed to remove: $e', isError: true); } diff --git a/lib/widgets/car_card.dart b/lib/widgets/car_card.dart index 82979fc..7375e64 100644 --- a/lib/widgets/car_card.dart +++ b/lib/widgets/car_card.dart @@ -11,6 +11,8 @@ class CarCard extends StatelessWidget { final String? imageUrl; final DateTime? addedAt; final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool isSelected; const CarCard({ super.key, @@ -22,6 +24,8 @@ class CarCard extends StatelessWidget { this.imageUrl, this.addedAt, this.onTap, + this.onLongPress, + this.isSelected = false, }); @override @@ -31,8 +35,12 @@ class CarCard extends StatelessWidget { return Card( clipBehavior: Clip.antiAlias, + color: isSelected + ? AppColors.orange.withValues(alpha: 0.18) + : Theme.of(context).cardTheme.color, child: InkWell( onTap: onTap, + onLongPress: onLongPress, borderRadius: BorderRadius.circular(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -70,6 +78,25 @@ class CarCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (isSelected) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: const [ + Icon(Icons.check_circle, + size: 16, color: AppColors.orange), + SizedBox(width: 6), + Text( + 'Selected', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.orange, + ), + ), + ], + ), + ), // HW ID badge Container( padding: