You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
713 lines
22 KiB
713 lines
22 KiB
import 'package:flutter/material.dart';
|
|
import '../../core/service_locator.dart';
|
|
import '../../core/logger.dart';
|
|
import '../../data/recipes/recipe_service.dart';
|
|
import '../../data/recipes/models/recipe_model.dart';
|
|
import '../shared/primary_app_bar.dart';
|
|
import '../add_recipe/add_recipe_screen.dart';
|
|
import '../navigation/main_navigation_scaffold.dart';
|
|
import '../navigation/material3_page_route.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
|
|
/// Home screen showing recipes overview, stats, tags, and favorites.
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
RecipeService? _recipeService;
|
|
List<RecipeModel> _allRecipes = [];
|
|
List<RecipeModel> _favoriteRecipes = [];
|
|
List<RecipeModel> _recentRecipes = [];
|
|
Map<String, int> _tagCounts = {};
|
|
bool _isLoading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeService();
|
|
}
|
|
|
|
Future<void> _initializeService() async {
|
|
try {
|
|
_recipeService = ServiceLocator.instance.recipeService;
|
|
if (_recipeService != null) {
|
|
await _loadData();
|
|
}
|
|
} catch (e) {
|
|
Logger.error('Failed to initialize home screen', e);
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadData() async {
|
|
if (_recipeService == null) return;
|
|
|
|
try {
|
|
final allRecipes = await _recipeService!.getAllRecipes();
|
|
final favorites = await _recipeService!.getFavouriteRecipes();
|
|
|
|
// Calculate tag counts
|
|
final tagCounts = <String, int>{};
|
|
for (final recipe in allRecipes) {
|
|
for (final tag in recipe.tags) {
|
|
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
// Sort tags by count (most popular first)
|
|
final sortedTags = tagCounts.entries.toList()
|
|
..sort((a, b) => b.value.compareTo(a.value));
|
|
|
|
// Get recent recipes (last 6)
|
|
final recent = allRecipes.take(6).toList();
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_allRecipes = allRecipes;
|
|
_favoriteRecipes = favorites;
|
|
_recentRecipes = recent;
|
|
_tagCounts = Map.fromEntries(sortedTags);
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
Logger.error('Failed to load home data', e);
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _navigateToRecipe(RecipeModel recipe) async {
|
|
final result = await Navigator.push(
|
|
context,
|
|
Material3PageRoute(
|
|
child: AddRecipeScreen(recipe: recipe, viewMode: true),
|
|
useEmphasized: true, // Use emphasized easing for more fluid feel
|
|
),
|
|
);
|
|
// Reload data if recipe was edited
|
|
if (result == true) {
|
|
await _loadData();
|
|
}
|
|
}
|
|
|
|
void _navigateToRecipes() {
|
|
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
|
|
scaffold?.navigateToRecipes();
|
|
}
|
|
|
|
void _navigateToFavourites() async {
|
|
final scaffold = context.findAncestorStateOfType<MainNavigationScaffoldState>();
|
|
final hasChanges = await scaffold?.navigateToFavourites() ?? false;
|
|
if (hasChanges) {
|
|
await _loadData();
|
|
}
|
|
}
|
|
|
|
void _navigateToTag(String tag) {
|
|
// Navigate to recipes screen with tag filter
|
|
// For now, just navigate to recipes - could be enhanced to filter by tag
|
|
_navigateToRecipes();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: PrimaryAppBar(title: 'Home'),
|
|
body: _isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: RefreshIndicator(
|
|
onRefresh: _loadData,
|
|
child: _allRecipes.isEmpty
|
|
? _buildEmptyState()
|
|
: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Stats Section
|
|
_buildStatsSection(),
|
|
const SizedBox(height: 24),
|
|
|
|
// Featured Recipes (Recent)
|
|
if (_recentRecipes.isNotEmpty) ...[
|
|
_buildSectionHeader(
|
|
'Recent Recipes',
|
|
onSeeAll: _navigateToRecipes,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildFeaturedRecipes(),
|
|
const SizedBox(height: 24),
|
|
],
|
|
|
|
// Popular Tags
|
|
if (_tagCounts.isNotEmpty) ...[
|
|
_buildSectionHeader('Popular Tags'),
|
|
const SizedBox(height: 12),
|
|
_buildTagsSection(),
|
|
const SizedBox(height: 24),
|
|
],
|
|
|
|
// Quick Favorites
|
|
if (_favoriteRecipes.isNotEmpty) ...[
|
|
_buildSectionHeader(
|
|
'Favorites',
|
|
onSeeAll: _navigateToFavourites,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildFavoritesSection(),
|
|
const SizedBox(height: 24),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.restaurant_menu,
|
|
size: 80,
|
|
color: Colors.grey[400],
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
'No recipes yet',
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Start by adding your first recipe!',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Colors.grey[500],
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
ElevatedButton.icon(
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const AddRecipeScreen(),
|
|
),
|
|
).then((_) => _loadData());
|
|
},
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Add Recipe'),
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatsSection() {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
icon: Icons.restaurant_menu,
|
|
label: 'Total Recipes',
|
|
value: '${_allRecipes.length}',
|
|
color: Colors.blue,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
icon: Icons.favorite,
|
|
label: 'Favorites',
|
|
value: '${_favoriteRecipes.length}',
|
|
color: Colors.red,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
icon: Icons.local_offer,
|
|
label: 'Tags',
|
|
value: '${_tagCounts.length}',
|
|
color: Colors.green,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatCard({
|
|
required IconData icon,
|
|
required String label,
|
|
required String value,
|
|
required Color color,
|
|
}) {
|
|
return Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, color: color, size: 24),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
value,
|
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionHeader(String title, {VoidCallback? onSeeAll}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
if (onSeeAll != null)
|
|
TextButton(
|
|
onPressed: onSeeAll,
|
|
child: const Text('See All'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFeaturedRecipes() {
|
|
return SizedBox(
|
|
height: 240,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
itemCount: _recentRecipes.length,
|
|
itemBuilder: (context, index) {
|
|
final recipe = _recentRecipes[index];
|
|
return _buildRecipeCard(recipe, isHorizontal: true);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRecipeCard(RecipeModel recipe, {bool isHorizontal = false}) {
|
|
final hasMedia = recipe.imageUrls.isNotEmpty || recipe.videoUrls.isNotEmpty;
|
|
final firstImage = recipe.imageUrls.isNotEmpty
|
|
? recipe.imageUrls.first
|
|
: null;
|
|
final firstVideo = recipe.imageUrls.isEmpty && recipe.videoUrls.isNotEmpty
|
|
? recipe.videoUrls.first
|
|
: null;
|
|
|
|
return GestureDetector(
|
|
onTap: () => _navigateToRecipe(recipe),
|
|
child: Container(
|
|
width: isHorizontal ? 280 : double.infinity,
|
|
margin: EdgeInsets.only(
|
|
right: isHorizontal ? 12 : 0,
|
|
bottom: isHorizontal ? 0 : 12,
|
|
),
|
|
child: Card(
|
|
elevation: 3,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Image/Video thumbnail
|
|
ClipRRect(
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
|
child: Container(
|
|
height: isHorizontal ? 130 : 180,
|
|
width: double.infinity,
|
|
color: Colors.grey[200],
|
|
child: hasMedia
|
|
? (firstImage != null
|
|
? Image.network(
|
|
firstImage,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) =>
|
|
_buildPlaceholder(),
|
|
)
|
|
: firstVideo != null
|
|
? _VideoThumbnailPreview(videoUrl: firstVideo)
|
|
: _buildPlaceholder())
|
|
: _buildPlaceholder(),
|
|
),
|
|
),
|
|
// Content
|
|
Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
recipe.title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 15,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
if (recipe.rating > 0) ...[
|
|
Icon(
|
|
Icons.star,
|
|
size: 16,
|
|
color: Colors.amber,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${recipe.rating}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
if (recipe.isFavourite) ...[
|
|
if (recipe.rating > 0) const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.favorite,
|
|
size: 16,
|
|
color: Colors.red,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaceholder() {
|
|
return Container(
|
|
color: Colors.grey[200],
|
|
child: const Center(
|
|
child: Icon(
|
|
Icons.restaurant_menu,
|
|
size: 48,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTagsSection() {
|
|
final topTags = _tagCounts.entries.take(10).toList();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: topTags.map((entry) {
|
|
return ActionChip(
|
|
avatar: CircleAvatar(
|
|
backgroundColor: Colors.blue.withValues(alpha: 0.2),
|
|
radius: 12,
|
|
child: Text(
|
|
'${entry.value}',
|
|
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
label: Text(entry.key),
|
|
onPressed: () => _navigateToTag(entry.key),
|
|
backgroundColor: Colors.blue.withValues(alpha: 0.1),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFavoritesSection() {
|
|
final favoritesToShow = _favoriteRecipes.take(3).toList();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
children: favoritesToShow.map((recipe) {
|
|
return _buildFavoriteCard(recipe);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFavoriteCard(RecipeModel recipe) {
|
|
final hasMedia = recipe.imageUrls.isNotEmpty || recipe.videoUrls.isNotEmpty;
|
|
final firstImage = recipe.imageUrls.isNotEmpty
|
|
? recipe.imageUrls.first
|
|
: null;
|
|
final firstVideo = recipe.imageUrls.isEmpty && recipe.videoUrls.isNotEmpty
|
|
? recipe.videoUrls.first
|
|
: null;
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: InkWell(
|
|
onTap: () => _navigateToRecipe(recipe),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
// Thumbnail
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
width: 80,
|
|
height: 80,
|
|
color: Colors.grey[200],
|
|
child: hasMedia
|
|
? (firstImage != null
|
|
? Image.network(
|
|
firstImage,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) =>
|
|
_buildPlaceholder(),
|
|
)
|
|
: firstVideo != null
|
|
? _VideoThumbnailPreview(videoUrl: firstVideo)
|
|
: _buildPlaceholder())
|
|
: _buildPlaceholder(),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Content
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
recipe.title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (recipe.description != null && recipe.description!.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
recipe.description!,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Colors.grey[600],
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
if (recipe.rating > 0) ...[
|
|
Icon(Icons.star, size: 16, color: Colors.amber),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${recipe.rating}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
if (recipe.tags.isNotEmpty) ...[
|
|
if (recipe.rating > 0) const SizedBox(width: 12),
|
|
Icon(Icons.local_offer, size: 14, color: Colors.grey[600]),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${recipe.tags.length}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.favorite,
|
|
color: Colors.red,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Widget that displays a video thumbnail preview with a play button overlay.
|
|
class _VideoThumbnailPreview extends StatefulWidget {
|
|
final String videoUrl;
|
|
|
|
const _VideoThumbnailPreview({
|
|
required this.videoUrl,
|
|
});
|
|
|
|
@override
|
|
State<_VideoThumbnailPreview> createState() => _VideoThumbnailPreviewState();
|
|
}
|
|
|
|
class _VideoThumbnailPreviewState extends State<_VideoThumbnailPreview> {
|
|
VideoPlayerController? _controller;
|
|
bool _isInitialized = false;
|
|
bool _hasError = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_extractThumbnail();
|
|
}
|
|
|
|
Future<void> _extractThumbnail() async {
|
|
try {
|
|
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl));
|
|
await _controller!.initialize();
|
|
|
|
if (mounted && _controller != null) {
|
|
await _controller!.seekTo(Duration.zero);
|
|
_controller!.pause();
|
|
|
|
setState(() {
|
|
_isInitialized = true;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
Logger.warning('Failed to extract video thumbnail: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_hasError = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_hasError || !_isInitialized || _controller == null) {
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Container(
|
|
color: Colors.black87,
|
|
child: const Center(
|
|
child: Icon(
|
|
Icons.play_circle_filled,
|
|
size: 40,
|
|
color: Colors.white70,
|
|
),
|
|
),
|
|
),
|
|
Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.play_arrow,
|
|
size: 32,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: _controller!.value.aspectRatio,
|
|
child: VideoPlayer(_controller!),
|
|
),
|
|
Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.play_arrow,
|
|
size: 32,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|