hwhub/lib/screens/collections_screen.dart

555 lines
18 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 '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 {
static const _activeCollectionPrefKey = 'active_collection_id';
List<Collection> _collections = [];
bool _isLoading = true;
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();
}
@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();
}
}
Future<void> _load() async {
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 prefs = await SharedPreferences.getInstance();
final persisted = prefs.getString(_activeCollectionPrefKey);
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 prefs.setString(_activeCollectionPrefKey, activeId);
if (shouldNotifySync) {
MainCollectionSync.notifyChanged();
}
}
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = 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. Hot Wheels, 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(),
);
showGlobalSnackBar('Collection created!');
_load();
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
}
}
void _openCollection(Collection c) {
navigatorKey.currentState!.push(
MaterialPageRoute(
builder: (_) => GarageScreen(
collectionId: c.id,
collectionName: c.name,
userRole: c.role,
),
),
).then((_) => _load()); // refresh counts when coming back
}
void _manageCollection(Collection c) {
navigatorKey.currentState!.push(
MaterialPageRoute(
builder: (_) => ManageCollectionScreen(collection: c),
),
).then((_) => _load());
}
Future<void> _setActiveCollection(String collectionId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_activeCollectionPrefKey, collectionId);
if (!mounted) return;
setState(() => _activeCollectionId = collectionId);
MainCollectionSync.notifyChanged();
showGlobalSnackBar('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,
),
),
],
);
}
}