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: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<GarageScreen> {
List<Map<String, dynamic>> _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<int> _selectedIds = <int>{};
@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<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(() {
_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<Map<String, dynamic>>.from(data);
final withSignedUrls = await Future.wait(
@ -74,14 +111,18 @@ class GarageScreenState extends State<GarageScreen> {
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<GarageScreen> {
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<GarageScreen> {
),
),
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<GarageScreen> {
),
),
),
// Stats bar
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
@ -165,8 +215,6 @@ class GarageScreenState extends State<GarageScreen> {
),
),
),
// Search bar
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
@ -189,8 +237,6 @@ class GarageScreenState extends State<GarageScreen> {
),
),
),
// Content
if (_isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
@ -199,7 +245,7 @@ class GarageScreenState extends State<GarageScreen> {
SliverFillRemaining(
child: _ErrorView(
message: _error!,
onRetry: _loadCars,
onRetry: () => _loadCars(reset: true),
),
)
else if (_filteredCars.isEmpty)
@ -227,18 +273,60 @@ class GarageScreenState extends State<GarageScreen> {
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<GarageScreen> {
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) {
if (_selectionMode) {
_toggleCarSelection(car);
return;
}
final global = car['global_cars'] as Map<String, dynamic>?;
final hwId = car['hw_id'] as String? ?? '???';
final name = global?['name'] as String?;
@ -459,7 +645,7 @@ class GarageScreenState extends State<GarageScreen> {
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<GarageScreen> {
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<GarageScreen> {
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);
}

View file

@ -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: