609 lines
20 KiB
Dart
609 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../main.dart';
|
|
import '../services/collection_service.dart';
|
|
import '../services/main_collection_sync.dart';
|
|
import '../theme/app_colors.dart';
|
|
import '../utils/error_utils.dart';
|
|
import '../utils/preferences_utils.dart';
|
|
import 'garage_screen.dart';
|
|
import 'manage_collection_screen.dart';
|
|
|
|
/// Lists all collections the current user is a member of.
|
|
class CollectionsScreen extends StatefulWidget {
|
|
const CollectionsScreen({super.key});
|
|
|
|
@override
|
|
State<CollectionsScreen> createState() => CollectionsScreenState();
|
|
}
|
|
|
|
class CollectionsScreenState extends State<CollectionsScreen>
|
|
with WidgetsBindingObserver {
|
|
List<Collection> _collections = [];
|
|
bool _isLoading = true;
|
|
bool _isLoadInFlight = false;
|
|
bool _reloadQueued = false;
|
|
String? _error;
|
|
String? _activeCollectionId;
|
|
DateTime _lastLoadedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
MainCollectionSync.changeToken.addListener(_handleSyncChanged);
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
MainCollectionSync.changeToken.removeListener(_handleSyncChanged);
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleSyncChanged() {
|
|
if (!mounted) return;
|
|
_load(showLoading: false);
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
refreshIfStale(maxAge: const Duration(seconds: 3));
|
|
}
|
|
}
|
|
|
|
/// Public so other tabs can trigger a refresh.
|
|
void refresh() => _load();
|
|
|
|
void refreshIfStale({Duration maxAge = const Duration(seconds: 10)}) {
|
|
if (DateTime.now().difference(_lastLoadedAt) > maxAge) {
|
|
_load(showLoading: false);
|
|
}
|
|
}
|
|
|
|
Future<void> _load({bool showLoading = true}) async {
|
|
if (_isLoadInFlight) {
|
|
_reloadQueued = true;
|
|
return;
|
|
}
|
|
|
|
_isLoadInFlight = true;
|
|
|
|
if (showLoading) {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
}
|
|
|
|
try {
|
|
var list = await CollectionService.getMyCollections();
|
|
if (list.isEmpty && supabase.auth.currentUser != null) {
|
|
await CollectionService.ensureDefaultCollection();
|
|
list = await CollectionService.getMyCollections();
|
|
}
|
|
final userId = supabase.auth.currentUser?.id;
|
|
if (userId == null) {
|
|
throw const AuthRequiredException(
|
|
'You must be signed in to load collections.',
|
|
);
|
|
}
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final persisted = await readActiveCollectionId(prefs, userId: userId);
|
|
|
|
String? activeId = persisted;
|
|
if (activeId == null && list.isNotEmpty) {
|
|
activeId = list.first.id;
|
|
}
|
|
if (activeId != null && !list.any((c) => c.id == activeId)) {
|
|
activeId = list.isNotEmpty ? list.first.id : null;
|
|
}
|
|
|
|
final shouldNotifySync = activeId != null && activeId != persisted;
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_collections = list;
|
|
_activeCollectionId = activeId;
|
|
_isLoading = false;
|
|
_lastLoadedAt = DateTime.now();
|
|
});
|
|
|
|
if (activeId != null) {
|
|
await writeActiveCollectionId(
|
|
prefs,
|
|
userId: userId,
|
|
collectionId: activeId,
|
|
);
|
|
if (shouldNotifySync) {
|
|
MainCollectionSync.notifyChanged();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = userMessageForError(
|
|
e,
|
|
fallback: 'Failed to load collections. Please try again.',
|
|
);
|
|
_isLoading = false;
|
|
});
|
|
logError('collections.load', e);
|
|
} finally {
|
|
_isLoadInFlight = false;
|
|
if (_reloadQueued) {
|
|
_reloadQueued = false;
|
|
Future<void>.microtask(() => _load(showLoading: false));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _createCollection() async {
|
|
final formKey = GlobalKey<FormState>();
|
|
final nameCtrl = TextEditingController();
|
|
final descCtrl = TextEditingController();
|
|
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
icon: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: const BoxDecoration(
|
|
gradient: AppColors.brandGradient,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.add, color: Colors.white, size: 28),
|
|
),
|
|
title: const Text('New Collection'),
|
|
content: Form(
|
|
key: formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextFormField(
|
|
controller: nameCtrl,
|
|
autofocus: true,
|
|
maxLength: 50,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Name',
|
|
hintText: 'e.g. Die-Cast Cars, Matchbox…',
|
|
),
|
|
validator: (value) {
|
|
final trimmed = value?.trim() ?? '';
|
|
if (trimmed.isEmpty) return 'Name is required';
|
|
if (trimmed.length < 2) return 'Name must be at least 2 characters';
|
|
if (trimmed.length > 50) return 'Name must be 50 characters or fewer';
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: descCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description (optional)',
|
|
hintText: 'What is this collection for?',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
if (formKey.currentState!.validate()) {
|
|
Navigator.pop(context, true);
|
|
}
|
|
},
|
|
child: const Text('Create'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (result != true) return;
|
|
|
|
try {
|
|
await CollectionService.create(
|
|
name: nameCtrl.text.trim(),
|
|
description: descCtrl.text.trim(),
|
|
);
|
|
showGlobalSuccess('Collection created!');
|
|
_load();
|
|
} catch (e) {
|
|
showGlobalError(
|
|
e,
|
|
fallback: 'Could not create collection. Please try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _openCollection(Collection c) {
|
|
navigatorKey.currentState!.push(
|
|
MaterialPageRoute(
|
|
builder: (_) => GarageScreen(
|
|
collectionId: c.id,
|
|
collectionName: c.name,
|
|
userRole: c.role,
|
|
),
|
|
),
|
|
).then((_) => _load(showLoading: false)); // refresh counts when coming back
|
|
}
|
|
|
|
void _manageCollection(Collection c) {
|
|
navigatorKey.currentState!.push(
|
|
MaterialPageRoute(
|
|
builder: (_) => ManageCollectionScreen(collection: c),
|
|
),
|
|
).then((_) => _load(showLoading: false));
|
|
}
|
|
|
|
Future<void> _setActiveCollection(String collectionId) async {
|
|
if (collectionId == _activeCollectionId) {
|
|
return;
|
|
}
|
|
|
|
final userId = supabase.auth.currentUser?.id;
|
|
if (userId == null) {
|
|
showGlobalSnackBar('Please sign in again.', isError: true);
|
|
return;
|
|
}
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await writeActiveCollectionId(
|
|
prefs,
|
|
userId: userId,
|
|
collectionId: collectionId,
|
|
);
|
|
|
|
final shouldNotifySync = collectionId != _activeCollectionId;
|
|
|
|
if (!mounted) return;
|
|
setState(() => _activeCollectionId = collectionId);
|
|
if (shouldNotifySync) {
|
|
MainCollectionSync.notifyChanged();
|
|
}
|
|
showGlobalInfo('Main collection set for scanning.');
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: RefreshIndicator(
|
|
onRefresh: _load,
|
|
child: CustomScrollView(
|
|
slivers: [
|
|
// ── Header ──
|
|
SliverAppBar(
|
|
expandedHeight: 140,
|
|
pinned: true,
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
titlePadding: const EdgeInsets.only(left: 20, bottom: 16),
|
|
title: Text(
|
|
'Collections',
|
|
style: TextStyle(
|
|
fontFamily: 'Poppins',
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 22,
|
|
color: Colors.white,
|
|
shadows: [
|
|
Shadow(
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
blurRadius: 4,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
background: Container(
|
|
decoration: BoxDecoration(
|
|
image: const DecorationImage(
|
|
image: AssetImage('assets/img/login_bg.jpg'),
|
|
fit: BoxFit.cover,
|
|
),
|
|
color: Colors.black.withValues(alpha: 0.15),
|
|
),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.black.withValues(alpha: 0.3),
|
|
Colors.black.withValues(alpha: 0.55),
|
|
],
|
|
),
|
|
),
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 24),
|
|
child: Icon(
|
|
Icons.collections_bookmark,
|
|
size: 72,
|
|
color: Colors.white.withValues(alpha: 0.18),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// ── Content ──
|
|
if (_isLoading)
|
|
const SliverFillRemaining(
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
else if (_error != null)
|
|
SliverFillRemaining(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline,
|
|
size: 56, color: AppColors.error),
|
|
const SizedBox(height: 16),
|
|
Text(_error!,
|
|
textAlign: TextAlign.center,
|
|
style:
|
|
const TextStyle(color: AppColors.textSecondary)),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton.icon(
|
|
onPressed: _load,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else if (_collections.isEmpty)
|
|
SliverFillRemaining(
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(40),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.collections_bookmark_outlined,
|
|
size: 64, color: AppColors.textHint),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'No collections yet',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Create your first collection to start tracking!',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: AppColors.textHint),
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed: _createCollection,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Create Collection'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
final c = _collections[index];
|
|
return _CollectionCard(
|
|
collection: c,
|
|
isActive: _activeCollectionId == c.id,
|
|
onSetActive: () => _setActiveCollection(c.id),
|
|
onTap: () => _openCollection(c),
|
|
onManage: () => _manageCollection(c),
|
|
);
|
|
},
|
|
childCount: _collections.length,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
floatingActionButton: _collections.isNotEmpty
|
|
? FloatingActionButton(
|
|
onPressed: _createCollection,
|
|
child: const Icon(Icons.add),
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Collection Card ──────────────────────────────────────────────────
|
|
|
|
class _CollectionCard extends StatelessWidget {
|
|
final Collection collection;
|
|
final bool isActive;
|
|
final VoidCallback onSetActive;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onManage;
|
|
|
|
const _CollectionCard({
|
|
required this.collection,
|
|
required this.isActive,
|
|
required this.onSetActive,
|
|
required this.onTap,
|
|
required this.onManage,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = collection;
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
// Icon
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
gradient: c.isOwner
|
|
? AppColors.brandGradient
|
|
: const LinearGradient(
|
|
colors: [AppColors.navy, AppColors.navyLight],
|
|
),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Icon(
|
|
c.isOwner
|
|
? Icons.collections_bookmark
|
|
: Icons.group,
|
|
color: Colors.white,
|
|
size: 28,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
|
|
// Info
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
c.name,
|
|
style: const TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
_MiniStat(
|
|
icon: Icons.directions_car,
|
|
value: '${c.itemCount}'),
|
|
const SizedBox(width: 16),
|
|
_MiniStat(
|
|
icon: Icons.people,
|
|
value: '${c.memberCount}'),
|
|
const SizedBox(width: 16),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: c.isOwner
|
|
? AppColors.orange.withValues(alpha: 0.15)
|
|
: c.isViewer
|
|
? AppColors.textHint.withValues(alpha: 0.15)
|
|
: AppColors.navy.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
c.isOwner
|
|
? 'Owner'
|
|
: c.isViewer
|
|
? 'Viewer'
|
|
: 'Member',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: c.isOwner
|
|
? AppColors.orange
|
|
: c.isViewer
|
|
? AppColors.textSecondary
|
|
: AppColors.navy,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
if (isActive)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.success.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: const Text(
|
|
'Main',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.success,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
Column(
|
|
children: [
|
|
IconButton(
|
|
tooltip: isActive
|
|
? 'Already main collection'
|
|
: 'Make main collection',
|
|
icon: Icon(
|
|
isActive ? Icons.my_location : Icons.location_searching,
|
|
color: isActive ? AppColors.success : AppColors.textHint,
|
|
),
|
|
onPressed: isActive ? null : onSetActive,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.settings_outlined,
|
|
color: AppColors.textHint),
|
|
onPressed: onManage,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MiniStat extends StatelessWidget {
|
|
final IconData icon;
|
|
final String value;
|
|
const _MiniStat({required this.icon, required this.value});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 14, color: AppColors.textHint),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|