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:
parent
1240b22f29
commit
225ce4631c
2 changed files with 237 additions and 24 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue