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
This commit is contained in:
Lukas Müllner 2026-03-04 10:23:57 +01:00
parent 1240b22f29
commit 225ce4631c
2 changed files with 237 additions and 24 deletions

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../main.dart'; import '../main.dart';
import '../services/collection_service.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../widgets/car_card.dart'; import '../widgets/car_card.dart';
@ -27,38 +28,74 @@ class GarageScreen extends StatefulWidget {
class GarageScreenState extends State<GarageScreen> { class GarageScreenState extends State<GarageScreen> {
List<Map<String, dynamic>> _cars = []; List<Map<String, dynamic>> _cars = [];
bool _isLoading = true; bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMore = true;
int _page = 0;
static const int _pageSize = 40;
String? _error; String? _error;
String _searchQuery = ''; String _searchQuery = '';
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final _scrollController = ScrollController();
bool _selectionMode = false;
final Set<int> _selectedIds = <int>{};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadCars(); _scrollController.addListener(_onScroll);
_loadCars(reset: true);
} }
@override @override
void dispose() { void dispose() {
_scrollController.dispose();
_searchController.dispose(); _searchController.dispose();
super.dispose(); super.dispose();
} }
/// Public method so other screens can trigger a refresh. /// 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<void> _loadCars({bool reset = false}) async {
if (reset) {
_page = 0;
_hasMore = true;
_selectedIds.clear();
_selectionMode = false;
}
if (!_hasMore && !reset) return;
Future<void> _loadCars() async {
setState(() { setState(() {
_isLoading = true; if (reset || _cars.isEmpty) {
_error = null; _isLoading = true;
} else {
_isLoadingMore = true;
}
if (reset) _error = null;
}); });
try { try {
final from = _page * _pageSize;
final to = from + _pageSize - 1;
final data = await supabase final data = await supabase
.from('hotwheels') .from('hotwheels')
.select( .select(
'id, created_at, hw_id, notes, user_image_url, global_cars(name, series, year, color)') 'id, created_at, hw_id, notes, user_image_url, global_cars(name, series, year, color)')
.eq('collection_id', widget.collectionId) .eq('collection_id', widget.collectionId)
.order('created_at', ascending: false); .order('created_at', ascending: false)
.range(from, to);
final rows = List<Map<String, dynamic>>.from(data); final rows = List<Map<String, dynamic>>.from(data);
final withSignedUrls = await Future.wait( final withSignedUrls = await Future.wait(
@ -74,14 +111,18 @@ class GarageScreenState extends State<GarageScreen> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_cars = withSignedUrls; _cars = reset ? withSignedUrls : [..._cars, ...withSignedUrls];
_hasMore = withSignedUrls.length == _pageSize;
if (_hasMore) _page += 1;
_isLoading = false; _isLoading = false;
_isLoadingMore = false;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = e.toString(); _error = e.toString();
_isLoading = false; _isLoading = false;
_isLoadingMore = false;
}); });
} }
} }
@ -102,14 +143,27 @@ class GarageScreenState extends State<GarageScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController,
slivers: [ slivers: [
// Header
SliverAppBar( SliverAppBar(
expandedHeight: 140, expandedHeight: 140,
pinned: true, 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( flexibleSpace: FlexibleSpaceBar(
titlePadding: titlePadding: const EdgeInsets.only(left: 20, bottom: 16),
const EdgeInsets.only(left: 20, bottom: 16),
title: Text( title: Text(
widget.collectionName, widget.collectionName,
style: TextStyle( style: TextStyle(
@ -126,9 +180,7 @@ class GarageScreenState extends State<GarageScreen> {
), ),
), ),
background: Container( background: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(gradient: AppColors.brandGradient),
gradient: AppColors.brandGradient,
),
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Padding( child: Padding(
@ -143,8 +195,6 @@ class GarageScreenState extends State<GarageScreen> {
), ),
), ),
), ),
// Stats bar
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
@ -165,8 +215,6 @@ class GarageScreenState extends State<GarageScreen> {
), ),
), ),
), ),
// Search bar
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
@ -189,8 +237,6 @@ class GarageScreenState extends State<GarageScreen> {
), ),
), ),
), ),
// Content
if (_isLoading) if (_isLoading)
const SliverFillRemaining( const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
@ -199,7 +245,7 @@ class GarageScreenState extends State<GarageScreen> {
SliverFillRemaining( SliverFillRemaining(
child: _ErrorView( child: _ErrorView(
message: _error!, message: _error!,
onRetry: _loadCars, onRetry: () => _loadCars(reset: true),
), ),
) )
else if (_filteredCars.isEmpty) else if (_filteredCars.isEmpty)
@ -227,18 +273,60 @@ class GarageScreenState extends State<GarageScreen> {
year: global?['year'] as int?, year: global?['year'] as int?,
color: global?['color'] as String?, color: global?['color'] as String?,
imageUrl: car['signed_image_url'] as String?, imageUrl: car['signed_image_url'] as String?,
isSelected: _selectedIds.contains(car['id'] as int),
addedAt: car['created_at'] != null addedAt: car['created_at'] != null
? DateTime.tryParse(car['created_at']) ? DateTime.tryParse(car['created_at'])
: null, : null,
onTap: () => _showCarDetails(car), onTap: () => _selectionMode
? _toggleCarSelection(car)
: _showCarDetails(car),
onLongPress: () => _toggleCarSelection(car),
); );
}, },
childCount: _filteredCars.length, 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<GarageScreen> {
return '$count'; return '$count';
} }
void _toggleSelectionMode(bool enabled) {
setState(() {
_selectionMode = enabled;
if (!enabled) {
_selectedIds.clear();
}
});
}
void _toggleCarSelection(Map<String, dynamic> 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<void> _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<bool>(
context: context,
builder: (_) => StatefulBuilder(
builder: (context, setSheetState) => AlertDialog(
title: const Text('Move Selected Cars'),
content: DropdownButtonFormField<String>(
initialValue: targetId,
decoration: const InputDecoration(
labelText: 'Target collection',
),
items: candidates
.map(
(c) => DropdownMenuItem<String>(
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<String, dynamic> car) { void _showCarDetails(Map<String, dynamic> car) {
if (_selectionMode) {
_toggleCarSelection(car);
return;
}
final global = car['global_cars'] as Map<String, dynamic>?; final global = car['global_cars'] as Map<String, dynamic>?;
final hwId = car['hw_id'] as String? ?? '???'; final hwId = car['hw_id'] as String? ?? '???';
final name = global?['name'] as String?; final name = global?['name'] as String?;
@ -459,7 +645,7 @@ class GarageScreenState extends State<GarageScreen> {
showGlobalSnackBar('Photo updated!'); showGlobalSnackBar('Photo updated!');
if (sheetContext.mounted) Navigator.pop(sheetContext); if (sheetContext.mounted) Navigator.pop(sheetContext);
_loadCars(); // refresh grid _loadCars(reset: true); // refresh grid
} catch (e) { } catch (e) {
showGlobalSnackBar('Failed to save: $e', isError: true); showGlobalSnackBar('Failed to save: $e', isError: true);
} }
@ -482,7 +668,7 @@ class GarageScreenState extends State<GarageScreen> {
showGlobalSnackBar('Car updated!'); showGlobalSnackBar('Car updated!');
if (sheetContext.mounted) Navigator.pop(sheetContext); if (sheetContext.mounted) Navigator.pop(sheetContext);
_loadCars(); _loadCars(reset: true);
} catch (e) { } catch (e) {
showGlobalSnackBar('Failed to update: $e', isError: true); showGlobalSnackBar('Failed to update: $e', isError: true);
} }
@ -527,7 +713,7 @@ class GarageScreenState extends State<GarageScreen> {
Navigator.pop(sheetContext); // close bottom sheet Navigator.pop(sheetContext); // close bottom sheet
} }
showGlobalSnackBar('${car['hw_id']} removed from your garage.'); showGlobalSnackBar('${car['hw_id']} removed from your garage.');
_loadCars(); _loadCars(reset: true);
} catch (e) { } catch (e) {
showGlobalSnackBar('Failed to remove: $e', isError: true); showGlobalSnackBar('Failed to remove: $e', isError: true);
} }

View file

@ -11,6 +11,8 @@ class CarCard extends StatelessWidget {
final String? imageUrl; final String? imageUrl;
final DateTime? addedAt; final DateTime? addedAt;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool isSelected;
const CarCard({ const CarCard({
super.key, super.key,
@ -22,6 +24,8 @@ class CarCard extends StatelessWidget {
this.imageUrl, this.imageUrl,
this.addedAt, this.addedAt,
this.onTap, this.onTap,
this.onLongPress,
this.isSelected = false,
}); });
@override @override
@ -31,8 +35,12 @@ class CarCard extends StatelessWidget {
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
color: isSelected
? AppColors.orange.withValues(alpha: 0.18)
: Theme.of(context).cardTheme.color,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -70,6 +78,25 @@ class CarCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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 // HW ID badge
Container( Container(
padding: padding: