first commit

This commit is contained in:
Lucas Saburido
2026-05-13 16:26:45 +01:00
commit cabf2025cd
252 changed files with 13524 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../domain/models/feed_post_model.dart';
class FeedService {
const FeedService(this._client);
final SupabaseClient _client;
User get _currentUser {
final user = _client.auth.currentUser;
if (user == null) {
throw StateError('No authenticated user');
}
return user;
}
Future<List<FeedPostModel>> fetchFeed() async {
final user = _currentUser;
final posts = await _client
.from('posts')
.select('id,user_id,caption,image_url,likes_count,created_at')
.order('created_at', ascending: false);
if (posts.isEmpty) return const [];
final postRows = List<Map<String, dynamic>>.from(posts);
final userIds = postRows
.map((e) => e['user_id'] as String?)
.whereType<String>()
.toSet()
.toList();
final profiles = userIds.isEmpty
? <Map<String, dynamic>>[]
: List<Map<String, dynamic>>.from(await _client
.from('profiles')
.select('user_id,username,avatar_url')
.inFilter('user_id', userIds));
final profileByUserId = {
for (final profile in profiles) (profile['user_id'] as String): profile,
};
final postIds = postRows.map((e) => e['id'] as String).toList();
final myLikes = postIds.isEmpty
? <Map<String, dynamic>>[]
: List<Map<String, dynamic>>.from(await _client
.from('post_likes')
.select('post_id')
.eq('user_id', user.id)
.inFilter('post_id', postIds));
final likedPostIds =
myLikes.map((e) => e['post_id'] as String).toSet();
return postRows.map((row) {
final profile = profileByUserId[row['user_id']] ?? const {};
return FeedPostModel(
id: row['id'] as String,
userId: row['user_id'] as String,
caption: (row['caption'] as String?) ?? '',
imageUrl: (row['image_url'] as String?) ?? '',
createdAt: DateTime.parse(row['created_at'] as String),
likesCount: (row['likes_count'] as int?) ?? 0,
isLiked: likedPostIds.contains(row['id']),
username: (profile['username'] as String?) ?? 'riot_user',
avatarUrl: (profile['avatar_url'] as String?) ?? '',
);
}).toList();
}
Future<void> createPost({
required String caption,
required Uint8List imageBytes,
required String extension,
}) async {
final user = _currentUser;
final ts = DateTime.now().millisecondsSinceEpoch;
final random = Random().nextInt(99999);
final path = 'posts/${user.id}/$ts-$random.$extension';
await _client.storage.from('post-images').uploadBinary(
path,
imageBytes,
fileOptions: const FileOptions(upsert: false),
);
final imageUrl = _client.storage.from('post-images').getPublicUrl(path);
await _client.from('posts').insert({
'user_id': user.id,
'caption': caption.trim(),
'image_url': imageUrl,
'likes_count': 0,
});
}
Future<void> toggleLike({
required String postId,
required bool currentlyLiked,
required int currentLikesCount,
}) async {
final user = _currentUser;
if (currentlyLiked) {
await _client
.from('post_likes')
.delete()
.eq('post_id', postId)
.eq('user_id', user.id);
await _client.from('posts').update({
'likes_count': max(0, currentLikesCount - 1),
}).eq('id', postId);
return;
}
await _client.from('post_likes').upsert({
'post_id': postId,
'user_id': user.id,
});
await _client.from('posts').update({
'likes_count': currentLikesCount + 1,
}).eq('id', postId);
}
Future<void> deleteOwnPost(String postId) async {
final user = _currentUser;
await _client
.from('posts')
.delete()
.eq('id', postId)
.eq('user_id', user.id);
}
}

View File

@@ -0,0 +1,152 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../domain/models/feed_post_model.dart';
class PostService {
const PostService(this._client);
final SupabaseClient _client;
User get _currentUser {
final user = _client.auth.currentUser;
if (user == null) {
throw StateError('No authenticated user');
}
return user;
}
/// Fetches the global feed in chronological order.
Future<List<FeedPostModel>> fetchFeed() async {
final user = _currentUser;
// Fetch posts with user profile data using joins if possible,
// but sticking to the established pattern for consistency and safety.
final posts = await _client
.from('posts')
.select('id, user_id, caption, image_url, likes_count, created_at')
.order('created_at', ascending: false);
if (posts.isEmpty) return const [];
final postRows = List<Map<String, dynamic>>.from(posts);
final userIds = postRows
.map((e) => e['user_id'] as String?)
.whereType<String>()
.toSet()
.toList();
// Fetch profiles for the users in the feed
final profiles = userIds.isEmpty
? <Map<String, dynamic>>[]
: List<Map<String, dynamic>>.from(await _client
.from('profiles')
.select('user_id, username, avatar_url')
.inFilter('user_id', userIds));
final profileByUserId = {
for (final profile in profiles) (profile['user_id'] as String): profile,
};
// Fetch the current user's likes for these posts to set isLiked
final postIds = postRows.map((e) => e['id'] as String).toList();
final myLikes = postIds.isEmpty
? <Map<String, dynamic>>[]
: List<Map<String, dynamic>>.from(await _client
.from('post_likes')
.select('post_id')
.eq('user_id', user.id)
.inFilter('post_id', postIds));
final likedPostIds = myLikes.map((e) => e['post_id'] as String).toSet();
return postRows.map((row) {
final profile = profileByUserId[row['user_id']] ?? const {};
return FeedPostModel(
id: row['id'] as String,
userId: row['user_id'] as String,
caption: (row['caption'] as String?) ?? '',
imageUrl: (row['image_url'] as String?) ?? '',
createdAt: DateTime.parse(row['created_at'] as String),
likesCount: (row['likes_count'] as int?) ?? 0,
isLiked: likedPostIds.contains(row['id']),
username: (profile['username'] as String?) ?? 'riot_user',
avatarUrl: (profile['avatar_url'] as String?) ?? '',
);
}).toList();
}
/// Uploads a new post with an image and optional caption.
Future<void> uploadPost({
required String caption,
required Uint8List imageBytes,
required String extension,
}) async {
final user = _currentUser;
final ts = DateTime.now().millisecondsSinceEpoch;
final random = Random().nextInt(99999);
final fileName = '$ts-$random.$extension';
final path = 'posts/${user.id}/$fileName';
// Upload image to Supabase Storage
await _client.storage.from('post-images').uploadBinary(
path,
imageBytes,
fileOptions: const FileOptions(upsert: false),
);
final imageUrl = _client.storage.from('post-images').getPublicUrl(path);
// Insert post record into PostgreSQL
await _client.from('posts').insert({
'user_id': user.id,
'caption': caption.trim(),
'image_url': imageUrl,
'likes_count': 0,
});
}
/// Toggles a like on a post.
Future<void> toggleLike({
required String postId,
required bool currentlyLiked,
required int currentLikesCount,
}) async {
final user = _currentUser;
if (currentlyLiked) {
// Unlike
await _client
.from('post_likes')
.delete()
.eq('post_id', postId)
.eq('user_id', user.id);
await _client.from('posts').update({
'likes_count': max(0, currentLikesCount - 1),
}).eq('id', postId);
} else {
// Like
await _client.from('post_likes').upsert({
'post_id': postId,
'user_id': user.id,
});
await _client.from('posts').update({
'likes_count': currentLikesCount + 1,
}).eq('id', postId);
}
}
/// Deletes a post belonging to the current user.
Future<void> deletePost(String postId) async {
final user = _currentUser;
await _client
.from('posts')
.delete()
.eq('id', postId)
.eq('user_id', user.id);
}
}

View File

@@ -0,0 +1,40 @@
class FeedPostModel {
const FeedPostModel({
required this.id,
required this.userId,
required this.caption,
required this.imageUrl,
required this.createdAt,
required this.likesCount,
required this.isLiked,
required this.username,
required this.avatarUrl,
});
final String id;
final String userId;
final String caption;
final String imageUrl;
final DateTime createdAt;
final int likesCount;
final bool isLiked;
final String username;
final String avatarUrl;
FeedPostModel copyWith({
int? likesCount,
bool? isLiked,
}) {
return FeedPostModel(
id: id,
userId: userId,
caption: caption,
imageUrl: imageUrl,
createdAt: createdAt,
likesCount: likesCount ?? this.likesCount,
isLiked: isLiked ?? this.isLiked,
username: username,
avatarUrl: avatarUrl,
);
}
}

View File

@@ -0,0 +1,62 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/supabase/supabase_providers.dart';
import '../../data/services/feed_service.dart';
import '../../domain/models/feed_post_model.dart';
final feedServiceProvider = Provider<FeedService>((ref) {
final client = ref.watch(supabaseProvider);
return FeedService(client);
});
final feedPostsProvider = FutureProvider<List<FeedPostModel>>((ref) {
return ref.watch(feedServiceProvider).fetchFeed();
});
final feedControllerProvider =
AutoDisposeAsyncNotifierProvider<FeedController, void>(
FeedController.new,
);
class FeedController extends AutoDisposeAsyncNotifier<void> {
@override
Future<void> build() async {}
Future<void> createPost({
required String caption,
required Uint8List imageBytes,
required String extension,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(feedServiceProvider).createPost(
caption: caption,
imageBytes: imageBytes,
extension: extension,
);
ref.invalidate(feedPostsProvider);
});
}
Future<void> toggleLike(FeedPostModel post) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(feedServiceProvider).toggleLike(
postId: post.id,
currentlyLiked: post.isLiked,
currentLikesCount: post.likesCount,
);
ref.invalidate(feedPostsProvider);
});
}
Future<void> deleteOwnPost(String postId) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(feedServiceProvider).deleteOwnPost(postId);
ref.invalidate(feedPostsProvider);
});
}
}

View File

@@ -0,0 +1,59 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/supabase/supabase_providers.dart';
import '../../data/services/post_service.dart';
import '../../domain/models/feed_post_model.dart';
final postServiceProvider = Provider<PostService>((ref) {
final client = ref.watch(supabaseProvider);
return PostService(client);
});
final feedPostsProvider = FutureProvider<List<FeedPostModel>>((ref) {
return ref.watch(postServiceProvider).fetchFeed();
});
final postControllerProvider = AutoDisposeAsyncNotifierProvider<PostController, void>(
PostController.new,
);
class PostController extends AutoDisposeAsyncNotifier<void> {
@override
Future<void> build() async {}
Future<void> createPost({
required String caption,
required Uint8List imageBytes,
required String extension,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(postServiceProvider).uploadPost(
caption: caption,
imageBytes: imageBytes,
extension: extension,
);
ref.invalidate(feedPostsProvider);
});
}
Future<void> toggleLike(FeedPostModel post) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(postServiceProvider).toggleLike(
postId: post.id,
currentlyLiked: post.isLiked,
currentLikesCount: post.likesCount,
);
ref.invalidate(feedPostsProvider);
});
}
Future<void> deletePost(String postId) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(postServiceProvider).deletePost(postId);
ref.invalidate(feedPostsProvider);
});
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/router/app_routes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/riotz_scaffold.dart';
import '../../../auth/presentation/providers/auth_provider.dart';
import '../providers/post_providers.dart';
import '../widgets/post_card.dart';
class FeedScreen extends ConsumerWidget {
const FeedScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final postsAsync = ref.watch(feedPostsProvider);
final currentUser = ref.watch(authServiceProvider).currentUser;
return RiotzScaffold(
appBar: AppBar(
title: const Text('RIOTZ // FEED'),
actions: [
IconButton(
icon: const Icon(Icons.add_box_outlined, color: AppColors.white),
onPressed: () => context.push(AppRoutes.uploadPost),
),
],
),
body: RefreshIndicator(
color: AppColors.neonRed,
backgroundColor: AppColors.black,
onRefresh: () async => ref.refresh(feedPostsProvider),
child: postsAsync.when(
data: (posts) {
if (posts.isEmpty) {
return const Center(
child: Text('NO CHAOS YET. START A RIOT.'),
);
}
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return PostCard(
post: post,
isOwnPost: post.userId == currentUser?.id,
onLike: () => ref.read(postControllerProvider.notifier).toggleLike(post),
onDelete: () => _confirmDelete(context, ref, post.id),
);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: AppColors.neonRed),
),
error: (error, _) => Center(
child: Text('SIGNAL LOST: $error'),
),
),
),
floatingActionButton: FloatingActionButton(
backgroundColor: AppColors.neonRed,
child: const Icon(Icons.add, color: AppColors.white),
onPressed: () => context.push(AppRoutes.uploadPost),
),
);
}
void _confirmDelete(BuildContext context, WidgetRef ref, String postId) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('ERASE TRANSMISSION?'),
content: const Text('THIS ACTION CANNOT BE UNDONE.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('CANCEL', style: TextStyle(color: AppColors.white)),
),
TextButton(
onPressed: () {
ref.read(postControllerProvider.notifier).deletePost(postId);
Navigator.pop(context);
},
child: const Text('DELETE', style: TextStyle(color: AppColors.neonRed)),
),
],
),
);
}
}

View File

@@ -0,0 +1,134 @@
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/theme/app_colors.dart';
import '../../../../core/widgets/riotz_button.dart';
import '../../../../core/widgets/riotz_scaffold.dart';
import '../providers/post_providers.dart';
class UploadPostScreen extends ConsumerStatefulWidget {
const UploadPostScreen({super.key});
@override
ConsumerState<UploadPostScreen> createState() => _UploadPostScreenState();
}
class _UploadPostScreenState extends ConsumerState<UploadPostScreen> {
final _captionController = TextEditingController();
final _picker = ImagePicker();
Uint8List? _imageBytes;
String? _extension;
@override
void dispose() {
_captionController.dispose();
super.dispose();
}
Future<void> _pickImage() async {
final image = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
maxWidth: 1080,
);
if (image != null) {
final bytes = await image.readAsBytes();
setState(() {
_imageBytes = bytes;
_extension = image.name.split('.').last.toLowerCase();
});
}
}
Future<void> _upload() async {
if (_imageBytes == null) return;
await ref.read(postControllerProvider.notifier).createPost(
caption: _captionController.text,
imageBytes: _imageBytes!,
extension: _extension ?? 'jpg',
);
if (mounted) {
context.pop();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loading = ref.watch(postControllerProvider).isLoading;
return RiotzScaffold(
appBar: AppBar(
title: const Text('NEW TRANSMISSION'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.pop(),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Picker Area
GestureDetector(
onTap: loading ? null : _pickImage,
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.border, width: 2),
),
child: _imageBytes != null
? Image.memory(_imageBytes!, fit: BoxFit.cover)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.add_a_photo_outlined, size: 48, color: AppColors.grey),
const SizedBox(height: 12),
Text(
'SELECT VISUALS',
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey),
),
],
),
),
),
),
const SizedBox(height: 32),
// Caption Field
Text(
'MANIFESTO',
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
),
const SizedBox(height: 8),
TextField(
controller: _captionController,
maxLines: 4,
decoration: const InputDecoration(
hintText: 'DESCRIBE THE CHAOS...',
),
),
const SizedBox(height: 48),
// Post Button
RiotzButton(
label: 'PROPAGATE',
isLoading: loading,
onPressed: _imageBytes == null ? null : _upload,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/riotz_card.dart';
import '../../domain/models/feed_post_model.dart';
class PostCard extends StatelessWidget {
const PostCard({
required this.post,
required this.isOwnPost,
required this.onLike,
required this.onDelete,
super.key,
});
final FeedPostModel post;
final bool isOwnPost;
final VoidCallback onLike;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final timeStr = DateFormat('HH:mm // dd.MM.yy').format(post.createdAt);
return RiotzCard(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.surfaceLight,
border: Border.all(color: AppColors.border),
),
child: post.avatarUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: post.avatarUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(Icons.person, color: AppColors.grey),
)
: const Icon(Icons.person, color: AppColors.grey),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(post.username.toUpperCase(), style: theme.textTheme.labelLarge),
Text(timeStr, style: theme.textTheme.bodySmall),
],
),
),
if (isOwnPost)
IconButton(
icon: const Icon(Icons.close, color: AppColors.grey, size: 20),
onPressed: onDelete,
),
],
),
),
// Visuals
CachedNetworkImage(
imageUrl: post.imageUrl,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
height: 300,
color: AppColors.surfaceLight,
child: const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
),
),
// Footer
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
onPressed: onLike,
icon: Icon(
post.isLiked ? Icons.favorite : Icons.favorite_border,
color: post.isLiked ? AppColors.neonRed : AppColors.white,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 8),
Text('${post.likesCount}', style: theme.textTheme.titleMedium),
const SizedBox(width: 24),
const Icon(Icons.chat_bubble_outline, color: AppColors.white, size: 20),
],
),
if (post.caption.isNotEmpty) ...[
const SizedBox(height: 12),
Text(post.caption, style: theme.textTheme.bodyLarge),
],
],
),
),
],
),
);
}
}