first commit
This commit is contained in:
347
lib/features/home/presentation/pages/home_page.dart
Normal file
347
lib/features/home/presentation/pages/home_page.dart
Normal file
@@ -0,0 +1,347 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../features/auth/presentation/providers/auth_providers.dart';
|
||||
import '../../../../features/admin/presentation/providers/admin_providers.dart';
|
||||
import '../../../../features/feed/domain/models/feed_post_model.dart';
|
||||
import '../../../../features/feed/presentation/providers/feed_providers.dart';
|
||||
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
final _captionController = TextEditingController();
|
||||
final _picker = ImagePicker();
|
||||
Uint8List? _selectedImageBytes;
|
||||
String _selectedImageExt = 'jpg';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_captionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isAdmin = ref.watch(isAdminProvider);
|
||||
final feedAsync = ref.watch(feedPostsProvider);
|
||||
final feedActionState = ref.watch(feedControllerProvider);
|
||||
final isBusy = feedActionState.isLoading;
|
||||
|
||||
ref.listen(feedControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('CHAOS PROPAGATED.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(error.toString().toUpperCase()),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // FEED'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout, color: AppColors.white),
|
||||
onPressed: () async {
|
||||
await ref.read(authControllerProvider.notifier).logout();
|
||||
if (context.mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.neonRed,
|
||||
backgroundColor: AppColors.black,
|
||||
onRefresh: () async => ref.invalidate(feedPostsProvider),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
children: [
|
||||
Text(
|
||||
'WELCOME, ${user?.email?.split('@').first.toUpperCase() ?? 'RIOTER'}',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Composer
|
||||
_PostComposer(
|
||||
captionController: _captionController,
|
||||
selectedImageBytes: _selectedImageBytes,
|
||||
onPickImage: isBusy ? null : _pickImage,
|
||||
onCreatePost: isBusy ? null : _createPost,
|
||||
isBusy: isBusy,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Navigation Shortcuts (Chaotic Grid)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_NavChip(label: 'PROFILE', onTap: () => context.push(AppRoutes.profile)),
|
||||
_NavChip(label: 'MUSIC', onTap: () => context.push(AppRoutes.music)),
|
||||
_NavChip(label: 'DISCOVER', onTap: () => context.push(AppRoutes.discover)),
|
||||
if (isAdmin) _NavChip(label: 'ADMIN', onTap: () => context.push(AppRoutes.admin), isAccent: true),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
feedAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('SYSTEM ERROR: $error')),
|
||||
data: (posts) {
|
||||
if (posts.isEmpty) {
|
||||
return const Center(child: Text('NO CHAOS YET. START A RIOT.'));
|
||||
}
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: posts.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 24),
|
||||
itemBuilder: (context, index) {
|
||||
final post = posts[index];
|
||||
return _FeedPostCard(
|
||||
post: post,
|
||||
isOwnPost: user?.id == post.userId,
|
||||
onLike: () => ref.read(feedControllerProvider.notifier).toggleLike(post),
|
||||
onDelete: () => ref.read(feedControllerProvider.notifier).deleteOwnPost(post.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final image = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 80);
|
||||
if (image == null) return;
|
||||
final bytes = await image.readAsBytes();
|
||||
setState(() {
|
||||
_selectedImageBytes = bytes;
|
||||
_selectedImageExt = image.name.split('.').last;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createPost() async {
|
||||
if (_selectedImageBytes == null) return;
|
||||
await ref.read(feedControllerProvider.notifier).createPost(
|
||||
caption: _captionController.text,
|
||||
imageBytes: _selectedImageBytes!,
|
||||
extension: _selectedImageExt,
|
||||
);
|
||||
_captionController.clear();
|
||||
setState(() => _selectedImageBytes = null);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavChip extends StatelessWidget {
|
||||
const _NavChip({required this.label, required this.onTap, this.isAccent = false});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool isAccent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: isAccent ? AppColors.neonRed : AppColors.white),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isAccent ? AppColors.neonRed : AppColors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostComposer extends StatelessWidget {
|
||||
const _PostComposer({
|
||||
required this.captionController,
|
||||
required this.selectedImageBytes,
|
||||
required this.onPickImage,
|
||||
required this.onCreatePost,
|
||||
required this.isBusy,
|
||||
});
|
||||
|
||||
final TextEditingController captionController;
|
||||
final Uint8List? selectedImageBytes;
|
||||
final VoidCallback? onPickImage;
|
||||
final VoidCallback? onCreatePost;
|
||||
final bool isBusy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.border),
|
||||
color: AppColors.surface,
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: captionController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'WHAT\'S THE WORD ON THE STREET?',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
),
|
||||
if (selectedImageBytes != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Image.memory(selectedImageBytes!, height: 200, width: double.infinity, fit: BoxFit.cover),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_a_photo_outlined, color: AppColors.white),
|
||||
onPressed: onPickImage,
|
||||
),
|
||||
const Spacer(),
|
||||
RiotzButton(
|
||||
label: 'POST',
|
||||
onPressed: onCreatePost,
|
||||
isLoading: isBusy,
|
||||
fullWidth: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedPostCard extends StatelessWidget {
|
||||
const _FeedPostCard({
|
||||
required this.post,
|
||||
required this.isOwnPost,
|
||||
required this.onLike,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final FeedPostModel post;
|
||||
final bool isOwnPost;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: post.avatarUrl.isNotEmpty
|
||||
? Image.network(post.avatarUrl, fit: BoxFit.cover)
|
||||
: const Icon(Icons.person, color: AppColors.grey),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(post.username.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
Text('RIOTER', style: theme.textTheme.bodySmall?.copyWith(color: AppColors.neonRed, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (isOwnPost)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert, color: AppColors.grey),
|
||||
onPressed: onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (post.imageUrl.isNotEmpty)
|
||||
Image.network(post.imageUrl, width: double.infinity, fit: BoxFit.cover),
|
||||
const SizedBox(height: 16),
|
||||
if (post.caption.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(post.caption, style: theme.textTheme.bodyLarge),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(
|
||||
post.isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
color: post.isLiked ? AppColors.neonRed : AppColors.white,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onLike,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${post.likesCount}', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(width: 24),
|
||||
const Icon(Icons.chat_bubble_outline, color: AppColors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('0', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user