CPD-Gitty/trainerbox/lib/screens/search_tab.dart

684 lines
27 KiB
Dart

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'training_detail_screen.dart';
import '../models/categories.dart';
/// The SearchTab displays a searchable and filterable list of training exercises.
class SearchTab extends StatefulWidget {
/// If true, enables selection mode for choosing exercises.
final bool selectMode;
/// Remaining time for selection mode (optional).
final int? remainingTime;
const SearchTab({
super.key,
this.selectMode = false,
this.remainingTime,
});
@override
State<SearchTab> createState() => _SearchTabState();
}
class _SearchTabState extends State<SearchTab> {
// Controller for the search input field.
final TextEditingController _searchController = TextEditingController();
// List of available exercise categories.
List<String> get _categories => kTrainingCategories.map((c) => c.name).toList();
// Currently selected category for filtering.
String? _selectedCategory;
// Current search term entered by the user.
String _searchTerm = '';
// Indicates if the user is a trainer.
bool _isTrainer = false;
// Indicates if the trainer check has completed.
bool _trainerChecked = false;
// Set of favorite exercise IDs.
Set<String> _favorites = {};
// Filter state variables
double _minDuration = 0;
double _maxDuration = 120;
String? _selectedYear;
double _minRating = 0.0;
// Temporary filter state for modal
double _tempMinDuration = 0;
double _tempMaxDuration = 120;
String? _tempSelectedYear;
double _tempMinRating = 0.0;
// Level categories for filter
final List<String> _levelCategories = [
'Bambini',
'F-Jugend',
'E-Jugend',
'D-Jugend',
'C-Jugend',
'B-Jugend',
'A-Jugend',
'Herren',
];
@override
void initState() {
super.initState();
// Listen for changes in the search field and update the search term.
_searchController.addListener(() {
setState(() {
_searchTerm = _searchController.text.trim();
});
});
_checkIfTrainer();
_loadFavorites();
}
/// Checks if the current user is a trainer and updates state.
Future<void> _checkIfTrainer() async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final doc = await FirebaseFirestore.instance.collection('User').doc(user.uid).get();
setState(() {
_isTrainer = doc.data()?['role'] == 'trainer';
_trainerChecked = true;
});
}
/// Loads the user's favorite exercises from Firestore.
Future<void> _loadFavorites() async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final doc = await FirebaseFirestore.instance.collection('User').doc(user.uid).get();
final data = doc.data();
if (data != null && data['favorites'] != null) {
setState(() {
_favorites = Set<String>.from(data['favorites']);
});
}
}
/// Shows a dialog for creating a new training exercise (trainer only).
void _showCreateTrainingDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => _CreateTrainingDialog(categories: _categories),
).then((_) => setState(() {})); // Refresh after adding
}
/// Toggles the favorite status of an exercise for the current user.
Future<void> _toggleFavorite(String trainingId, bool isFavorite) async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
if (isFavorite) {
await FirebaseFirestore.instance.collection('User').doc(user.uid).update({
'favorites': FieldValue.arrayRemove([trainingId]),
});
} else {
await FirebaseFirestore.instance.collection('User').doc(user.uid).update({
'favorites': FieldValue.arrayUnion([trainingId]),
});
}
await _loadFavorites(); // Update favorites after toggling
}
void _openFilterModal() async {
// Set temp values to current filter state
_tempMinDuration = _minDuration;
_tempMaxDuration = _maxDuration;
_tempSelectedYear = _selectedYear;
_tempMinRating = _minRating;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 24,
),
child: StatefulBuilder(
builder: (context, setModalState) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Filter', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// Duration filter
Row(
children: [
const Text('Dauer:'),
Expanded(
child: RangeSlider(
values: RangeValues(_tempMinDuration, _tempMaxDuration),
min: 0,
max: 300,
divisions: 30,
labels: RangeLabels('${_tempMinDuration.toInt()} min', '${_tempMaxDuration.toInt()} min'),
onChanged: (values) {
setModalState(() {
_tempMinDuration = values.start;
_tempMaxDuration = values.end;
});
},
),
),
],
),
// Year/Level filter
Row(
children: [
const Text('Level:'),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<String>(
value: _tempSelectedYear,
hint: const Text('Alle'),
items: [
const DropdownMenuItem<String>(value: null, child: Text('Alle')),
..._levelCategories.map((y) => DropdownMenuItem(value: y, child: Text(y))),
],
onChanged: (value) {
setModalState(() {
_tempSelectedYear = value;
});
},
),
),
],
),
// Minimum rating filter
Row(
children: [
const Text('Mindestbewertung:'),
Expanded(
child: Slider(
value: _tempMinRating,
min: 0,
max: 5,
divisions: 10,
label: _tempMinRating.toStringAsFixed(1),
onChanged: (value) {
setModalState(() {
_tempMinRating = value;
});
},
),
),
Text(_tempMinRating.toStringAsFixed(1)),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () {
setState(() {
_minDuration = _tempMinDuration;
_maxDuration = _tempMaxDuration;
_selectedYear = _tempSelectedYear;
_minRating = _tempMinRating;
});
Navigator.pop(context);
},
child: const Text('Anwenden'),
),
],
),
const SizedBox(height: 8),
],
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Suche'),
actions: [
if (_isTrainer && _trainerChecked)
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showCreateTrainingDialog(context),
),
],
),
body: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: 'Suche nach Übungen...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
// Category filter chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Kategorien',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
// Filter button opens the modal with advanced filters
ElevatedButton.icon(
onPressed: _openFilterModal,
icon: const Icon(Icons.filter_list),
label: const Text('Filter'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 14),
),
),
],
),
const SizedBox(height: 12),
// Horizontal scrollable category chips
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
// "Alle" chip
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: FilterChip(
label: const Text('Alle'),
selected: _selectedCategory == null,
onSelected: (selected) {
setState(() {
_selectedCategory = null;
});
},
),
),
// Category chips
..._categories.map((category) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: FilterChip(
label: Text(category),
selected: _selectedCategory == category,
onSelected: (selected) {
setState(() {
_selectedCategory = selected ? category : null;
});
},
),
)).toList(),
],
),
),
],
),
),
const SizedBox(height: 16),
// Exercise list
Expanded(
child: FutureBuilder<QuerySnapshot>(
future: FirebaseFirestore.instance.collection('Training').get(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Fehler: ${snapshot.error}'));
}
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
return const Center(child: Text('Keine Übungen gefunden'));
}
// Filter exercises by search term and category.
var exercises = snapshot.data!.docs.where((doc) {
final data = doc.data() as Map<String, dynamic>;
final title = data['title']?.toString().toLowerCase() ?? '';
final description = data['description']?.toString().toLowerCase() ?? '';
final category = data['category']?.toString() ?? '';
final duration = (data['duration'] as num?)?.toInt() ?? 0;
final year = data['year']?.toString() ?? '';
final rating = (data['rating overall'] as num?)?.toDouble() ?? 0.0;
final searchTerm = _searchTerm.toLowerCase();
final matchesSearch = title.contains(searchTerm) || description.contains(searchTerm);
final matchesCategory = _selectedCategory == null || category == _selectedCategory;
final matchesDuration = duration >= _minDuration && duration <= _maxDuration;
final matchesYear = _selectedYear == null || _selectedYear == '' || year == _selectedYear;
final matchesRating = rating >= _minRating;
return matchesSearch && matchesCategory && matchesDuration && matchesYear && matchesRating;
}).toList();
if (exercises.isEmpty) {
return const Center(child: Text('Keine Übungen gefunden'));
}
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
childAspectRatio: 0.75,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: exercises.length,
itemBuilder: (context, index) {
final doc = exercises[index];
final data = doc.data() as Map<String, dynamic>;
final duration = (data['duration'] as num?)?.toInt() ?? 0;
// Disable selection if duration exceeds remaining time in select mode.
final isDisabled = widget.selectMode && duration > (widget.remainingTime ?? 0);
return Card(
child: Stack(
children: [
InkWell(
onTap: isDisabled
? null
: () {
if (widget.selectMode) {
// Return selected exercise data in select mode.
Navigator.pop(context, {
'id': doc.id,
'title': data['title']?.toString() ?? 'Unbekannte Übung',
'description': data['description']?.toString() ?? '',
'duration': duration,
});
} else {
// Navigate to exercise detail screen.
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TrainingDetailScreen(trainingId: doc.id),
),
);
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container(
color: Colors.grey[200],
child: const Center(
child: Icon(Icons.fitness_center, size: 50),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data['title']?.toString() ?? 'Unbekannte Übung',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
(data['rating overall'] ?? 0.0).toStringAsFixed(1),
style: const TextStyle(fontSize: 12),
),
],
),
const SizedBox(height: 4),
Text(
'Dauer: ${duration} Minuten',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
if (isDisabled)
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text(
'Passt nicht in die verbleibende Zeit',
style: TextStyle(
color: Colors.orange,
fontSize: 12,
),
),
),
],
),
),
],
),
),
// Favorite icon button (toggle favorite status).
Positioned(
top: 4,
right: 4,
child: IconButton(
icon: Icon(
_favorites.contains(doc.id) ? Icons.favorite : Icons.favorite_border,
color: _favorites.contains(doc.id) ? Colors.red : Colors.grey,
),
onPressed: () => _toggleFavorite(doc.id, _favorites.contains(doc.id)),
),
),
],
),
);
},
);
},
),
),
],
),
);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
}
/// Dialog for creating a new training exercise.
class _CreateTrainingDialog extends StatefulWidget {
final List<String> categories;
const _CreateTrainingDialog({required this.categories});
@override
State<_CreateTrainingDialog> createState() => _CreateTrainingDialogState();
}
class _CreateTrainingDialogState extends State<_CreateTrainingDialog> {
// Form key for validating the create training form.
final _formKey = GlobalKey<FormState>();
// Selected category for the new training.
String? _category;
// Title of the new training.
String? _title;
// Description of the new training.
String? _description;
// Duration of the new training.
int? _duration;
// Difficulty level or year.
String? _year;
// Indicates if the dialog is loading (creating training).
bool _loading = false;
// Selected image file for the training.
File? _imageFile;
// Image picker instance.
final _picker = ImagePicker();
// Level categories for dropdown
final List<String> _levelCategories = [
'Bambini',
'F-Jugend',
'E-Jugend',
'D-Jugend',
'C-Jugend',
'B-Jugend',
'A-Jugend',
'Herren',
];
/// Opens the image picker to select an image from the gallery.
Future<void> _pickImage() async {
final pickedFile = await _picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
setState(() {
_imageFile = File(pickedFile.path);
});
}
}
/// Uploads the selected image to Firebase Storage and returns the download URL.
Future<String?> _uploadImage() async {
if (_imageFile == null) return null;
final storageRef = FirebaseStorage.instance
.ref()
.child('training_images')
.child('${DateTime.now().millisecondsSinceEpoch}.jpg');
try {
final uploadTask = await storageRef.putFile(_imageFile!);
return await uploadTask.ref.getDownloadURL();
} catch (e) {
print('Error uploading image: $e');
return null;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Neues Training erstellen'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_imageFile != null)
Container(
height: 200,
width: double.infinity,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(_imageFile!),
fit: BoxFit.cover,
),
),
),
ElevatedButton.icon(
onPressed: _pickImage,
icon: const Icon(Icons.image),
label: Text(_imageFile == null ? 'Bild auswählen' : 'Bild ändern'),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _category,
items: widget.categories
.map((cat) => DropdownMenuItem(value: cat, child: Text(cat)))
.toList(),
onChanged: (v) => setState(() => _category = v),
decoration: const InputDecoration(labelText: 'Kategorie'),
validator: (v) => v == null ? 'Kategorie wählen' : null,
),
TextFormField(
decoration: const InputDecoration(labelText: 'Titel'),
onChanged: (v) => _title = v,
validator: (v) => v == null || v.isEmpty ? 'Titel angeben' : null,
),
TextFormField(
decoration: const InputDecoration(labelText: 'Beschreibung'),
onChanged: (v) => _description = v,
validator: (v) => v == null || v.isEmpty ? 'Beschreibung angeben' : null,
maxLines: 2,
),
TextFormField(
decoration: const InputDecoration(labelText: 'Dauer (Minuten)'),
keyboardType: TextInputType.number,
onChanged: (v) => _duration = int.tryParse(v),
validator: (v) => v == null || int.tryParse(v) == null ? 'Zahl angeben' : null,
),
DropdownButtonFormField<String>(
value: _year,
items: _levelCategories
.map((level) => DropdownMenuItem(value: level, child: Text(level)))
.toList(),
onChanged: (v) => setState(() => _year = v),
decoration: const InputDecoration(labelText: 'Level'),
validator: (v) => v == null ? 'Level angeben' : null,
),
],
),
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: _loading
? null
: () async {
if (_formKey.currentState!.validate()) {
setState(() => _loading = true);
try {
final imageUrl = await _uploadImage();
await FirebaseFirestore.instance.collection('Training').add({
'category': _category,
'title': _title,
'description': _description,
'duration': _duration,
'picture': imageUrl,
'rating overall': 0.0,
'year': _year,
'ratings': [], // Array für einzelne Bewertungen
});
Navigator.pop(context);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Erstellen: $e')),
);
} finally {
setState(() => _loading = false);
}
}
},
child: _loading ? const CircularProgressIndicator() : const Text('Erstellen'),
),
],
);
}
}