first commit
This commit is contained in:
112
lib/features/admin/data/services/admin_service.dart
Normal file
112
lib/features/admin/data/services/admin_service.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import '../../domain/models/admin_panel_data_model.dart';
|
||||
import '../../domain/models/admin_post_model.dart';
|
||||
import '../../domain/models/admin_user_model.dart';
|
||||
|
||||
class AdminService {
|
||||
const AdminService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<AdminPanelDataModel> fetchPanelData() async {
|
||||
// Fetch all profiles
|
||||
final profileRows = List<Map<String, dynamic>>.from(
|
||||
await _client.from('profiles').select('user_id, username, avatar_url, banned, featured'),
|
||||
);
|
||||
|
||||
// Fetch latest posts
|
||||
final postRows = List<Map<String, dynamic>>.from(
|
||||
await _client
|
||||
.from('posts')
|
||||
.select('id, user_id, caption, image_url, likes_count, featured')
|
||||
.order('created_at', ascending: false)
|
||||
.limit(100),
|
||||
);
|
||||
|
||||
// Fetch tracks
|
||||
final trackRows = List<Map<String, dynamic>>.from(
|
||||
await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.order('created_at', ascending: false)
|
||||
.limit(100),
|
||||
);
|
||||
|
||||
final userMap = <String, AdminUserModel>{};
|
||||
for (final row in profileRows) {
|
||||
final user = AdminUserModel(
|
||||
userId: row['user_id'] as String,
|
||||
username: (row['username'] as String?) ?? 'RIOTER',
|
||||
avatarUrl: (row['avatar_url'] as String?) ?? '',
|
||||
banned: (row['banned'] as bool?) ?? false,
|
||||
featured: (row['featured'] as bool?) ?? false,
|
||||
);
|
||||
userMap[user.userId] = user;
|
||||
}
|
||||
|
||||
final users = userMap.values.toList()
|
||||
..sort((a, b) => a.username.toLowerCase().compareTo(b.username.toLowerCase()));
|
||||
|
||||
final posts = postRows.map((row) {
|
||||
final user = userMap[row['user_id']] ??
|
||||
AdminUserModel(
|
||||
userId: row['user_id'] as String,
|
||||
username: 'RIOTER',
|
||||
avatarUrl: '',
|
||||
banned: false,
|
||||
featured: false,
|
||||
);
|
||||
return AdminPostModel(
|
||||
id: row['id'] as String,
|
||||
userId: row['user_id'] as String,
|
||||
username: user.username,
|
||||
imageUrl: (row['image_url'] as String?) ?? '',
|
||||
caption: (row['caption'] as String?) ?? '',
|
||||
likesCount: (row['likes_count'] as int?) ?? 0,
|
||||
featured: (row['featured'] as bool?) ?? false,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final tracks = trackRows.map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
|
||||
return AdminPanelDataModel(users: users, posts: posts, tracks: tracks);
|
||||
}
|
||||
|
||||
Future<void> deletePost(String postId) async {
|
||||
await _client.from('posts').delete().eq('id', postId);
|
||||
}
|
||||
|
||||
Future<void> setUserBanned({
|
||||
required String userId,
|
||||
required bool banned,
|
||||
}) async {
|
||||
await _client.from('profiles').update({'banned': banned}).eq('user_id', userId);
|
||||
}
|
||||
|
||||
Future<void> setUserFeatured({
|
||||
required String userId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
await _client.from('profiles').update({'featured': featured}).eq('user_id', userId);
|
||||
}
|
||||
|
||||
Future<void> setPostFeatured({
|
||||
required String postId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
await _client.from('posts').update({'featured': featured}).eq('id', postId);
|
||||
}
|
||||
|
||||
Future<void> setTrackFeatured({
|
||||
required String trackId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
// Assuming tracks table has a 'featured' column
|
||||
await _client.from('tracks').update({'featured': featured}).eq('id', trackId);
|
||||
}
|
||||
}
|
||||
15
lib/features/admin/domain/models/admin_panel_data_model.dart
Normal file
15
lib/features/admin/domain/models/admin_panel_data_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import 'admin_post_model.dart';
|
||||
import 'admin_user_model.dart';
|
||||
|
||||
class AdminPanelDataModel {
|
||||
const AdminPanelDataModel({
|
||||
required this.users,
|
||||
required this.posts,
|
||||
required this.tracks,
|
||||
});
|
||||
|
||||
final List<AdminUserModel> users;
|
||||
final List<AdminPostModel> posts;
|
||||
final List<TrackModel> tracks;
|
||||
}
|
||||
19
lib/features/admin/domain/models/admin_post_model.dart
Normal file
19
lib/features/admin/domain/models/admin_post_model.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
class AdminPostModel {
|
||||
const AdminPostModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.imageUrl,
|
||||
required this.caption,
|
||||
required this.likesCount,
|
||||
required this.featured,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
final String username;
|
||||
final String imageUrl;
|
||||
final String caption;
|
||||
final int likesCount;
|
||||
final bool featured;
|
||||
}
|
||||
15
lib/features/admin/domain/models/admin_user_model.dart
Normal file
15
lib/features/admin/domain/models/admin_user_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
class AdminUserModel {
|
||||
const AdminUserModel({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.avatarUrl,
|
||||
required this.banned,
|
||||
required this.featured,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String avatarUrl;
|
||||
final bool banned;
|
||||
final bool featured;
|
||||
}
|
||||
189
lib/features/admin/presentation/pages/admin_page.dart
Normal file
189
lib/features/admin/presentation/pages/admin_page.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../shared/widgets/riotz_shell.dart';
|
||||
import '../../domain/models/admin_post_model.dart';
|
||||
import '../../domain/models/admin_user_model.dart';
|
||||
import '../providers/admin_providers.dart';
|
||||
|
||||
class AdminPage extends ConsumerWidget {
|
||||
const AdminPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAdmin = ref.watch(isAdminProvider);
|
||||
final dataAsync = ref.watch(adminPanelDataProvider);
|
||||
|
||||
ref.listen(adminControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Admin action applied')),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error.toString())),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
return const RiotzShell(
|
||||
title: 'RIOTZ // Admin',
|
||||
child: Center(
|
||||
child: Text('You are not authorized for admin panel access.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RiotzShell(
|
||||
title: 'RIOTZ // Admin',
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(adminPanelDataProvider),
|
||||
child: dataAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('Error: $error')),
|
||||
data: (data) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'Whitelist-based Admin Panel',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Users',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...data.users.map(
|
||||
(user) => _UserModerationCard(
|
||||
user: user,
|
||||
onToggleBan: (banned) => ref
|
||||
.read(adminControllerProvider.notifier)
|
||||
.setUserBanned(userId: user.userId, banned: banned),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
'Posts',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...data.posts.map(
|
||||
(post) => _PostModerationCard(
|
||||
post: post,
|
||||
onDelete: () =>
|
||||
ref.read(adminControllerProvider.notifier).deletePost(post.id),
|
||||
onToggleFeatured: (featured) => ref
|
||||
.read(adminControllerProvider.notifier)
|
||||
.setPostFeatured(postId: post.id, featured: featured),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UserModerationCard extends StatelessWidget {
|
||||
const _UserModerationCard({
|
||||
required this.user,
|
||||
required this.onToggleBan,
|
||||
});
|
||||
|
||||
final AdminUserModel user;
|
||||
final ValueChanged<bool> onToggleBan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: SwitchListTile(
|
||||
value: user.banned,
|
||||
onChanged: onToggleBan,
|
||||
title: Text(user.username),
|
||||
subtitle: Text(user.userId),
|
||||
secondary: CircleAvatar(
|
||||
backgroundImage:
|
||||
user.avatarUrl.isNotEmpty ? NetworkImage(user.avatarUrl) : null,
|
||||
child: user.avatarUrl.isEmpty ? const Icon(Icons.person) : null,
|
||||
),
|
||||
activeThumbColor: Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostModerationCard extends StatelessWidget {
|
||||
const _PostModerationCard({
|
||||
required this.post,
|
||||
required this.onDelete,
|
||||
required this.onToggleFeatured,
|
||||
});
|
||||
|
||||
final AdminPostModel post;
|
||||
final VoidCallback onDelete;
|
||||
final ValueChanged<bool> onToggleFeatured;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
post.username,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (post.caption.isNotEmpty) ...[
|
||||
Text(post.caption),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (post.imageUrl.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.network(
|
||||
post.imageUrl,
|
||||
width: double.infinity,
|
||||
height: 160,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text('${post.likesCount} likes'),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Feature'),
|
||||
Switch(
|
||||
value: post.featured,
|
||||
onChanged: onToggleFeatured,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/config/admin_whitelist.dart';
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../../auth/presentation/providers/auth_provider.dart';
|
||||
import '../../data/services/admin_service.dart';
|
||||
import '../../domain/models/admin_panel_data_model.dart';
|
||||
|
||||
final adminServiceProvider = Provider<AdminService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return AdminService(client);
|
||||
});
|
||||
|
||||
final isAdminProvider = Provider<bool>((ref) {
|
||||
final user = ref.watch(authStateProvider).value?.session?.user;
|
||||
return AdminWhitelist.isAdmin(user?.email);
|
||||
});
|
||||
|
||||
final adminPanelDataProvider = FutureProvider<AdminPanelDataModel>((ref) async {
|
||||
final isAdmin = ref.watch(isAdminProvider);
|
||||
if (!isAdmin) {
|
||||
throw StateError('UNAUTHORIZED ACCESS DETECTED.');
|
||||
}
|
||||
return ref.watch(adminServiceProvider).fetchPanelData();
|
||||
});
|
||||
|
||||
final adminControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AdminController, void>(
|
||||
AdminController.new,
|
||||
);
|
||||
|
||||
class AdminController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> deletePost(String postId) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).deletePost(postId);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setUserBanned({
|
||||
required String userId,
|
||||
required bool banned,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).setUserBanned(
|
||||
userId: userId,
|
||||
banned: banned,
|
||||
);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setUserFeatured({
|
||||
required String userId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).setUserFeatured(
|
||||
userId: userId,
|
||||
featured: featured,
|
||||
);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setPostFeatured({
|
||||
required String postId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).setPostFeatured(
|
||||
postId: postId,
|
||||
featured: featured,
|
||||
);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setTrackFeatured({
|
||||
required String trackId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).setTrackFeatured(
|
||||
trackId: trackId,
|
||||
featured: featured,
|
||||
);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
244
lib/features/admin/presentation/screens/admin_screen.dart
Normal file
244
lib/features/admin/presentation/screens/admin_screen.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import '../providers/admin_providers.dart';
|
||||
import '../../domain/models/admin_user_model.dart';
|
||||
import '../../domain/models/admin_post_model.dart';
|
||||
|
||||
class AdminScreen extends ConsumerWidget {
|
||||
const AdminScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final dataAsync = ref.watch(adminPanelDataProvider);
|
||||
|
||||
ref.listen(adminControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('SYSTEM OVERRIDE SUCCESSFUL.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text('ERROR: ${error.toString().toUpperCase()}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // TERMINAL'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: dataAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('TERMINAL OFFLINE: $error')),
|
||||
data: (data) => RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(adminPanelDataProvider),
|
||||
color: AppColors.neonRed,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
_SectionHeader(title: 'USER MODERATION', count: data.users.length),
|
||||
const SizedBox(height: 16),
|
||||
...data.users.map((user) => _AdminUserCard(user: user)),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
_SectionHeader(title: 'TRACK MODERATION', count: data.tracks.length),
|
||||
const SizedBox(height: 16),
|
||||
...data.tracks.map((track) => _AdminTrackCard(track: track)),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
_SectionHeader(title: 'POST MODERATION', count: data.posts.length),
|
||||
const SizedBox(height: 16),
|
||||
...data.posts.map((post) => _AdminPostCard(post: post)),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.count});
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 2, color: AppColors.neonRed)),
|
||||
const Divider(color: AppColors.neonRed, thickness: 2),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminUserCard extends ConsumerWidget {
|
||||
const _AdminUserCard({required this.user});
|
||||
final AdminUserModel user;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: user.banned ? AppColors.bloodRed : AppColors.border),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.surfaceLight,
|
||||
backgroundImage: user.avatarUrl.isNotEmpty ? NetworkImage(user.avatarUrl) : null,
|
||||
child: user.avatarUrl.isEmpty ? const Icon(Icons.person, color: AppColors.grey) : null,
|
||||
),
|
||||
title: Text(user.username.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
subtitle: Text(user.banned ? 'STATUS: BANNED' : (user.featured ? 'STATUS: FEATURED ARTIST' : 'STATUS: ACTIVE'),
|
||||
style: TextStyle(color: user.banned ? AppColors.neonRed : (user.featured ? AppColors.success : AppColors.grey), fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
trailing: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: AppColors.white),
|
||||
color: AppColors.surfaceLight,
|
||||
onSelected: (value) {
|
||||
if (value == 'ban') {
|
||||
ref.read(adminControllerProvider.notifier).setUserBanned(userId: user.userId, banned: !user.banned);
|
||||
} else if (value == 'feature') {
|
||||
ref.read(adminControllerProvider.notifier).setUserFeatured(userId: user.userId, featured: !user.featured);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'ban',
|
||||
child: Text(user.banned ? 'UNBAN USER' : 'BAN USER', style: const TextStyle(color: AppColors.neonRed)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'feature',
|
||||
child: Text(user.featured ? 'UNFEATURE ARTIST' : 'FEATURE ARTIST'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminTrackCard extends ConsumerWidget {
|
||||
const _AdminTrackCard({required this.track});
|
||||
final TrackModel track;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: track.featured ? AppColors.success : AppColors.border),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.music_note, color: track.featured ? AppColors.success : AppColors.white),
|
||||
title: Text(track.title.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
subtitle: Text('BY: ${track.username.toUpperCase()}', style: const TextStyle(fontSize: 10, color: AppColors.grey)),
|
||||
trailing: Switch(
|
||||
value: track.featured,
|
||||
activeColor: AppColors.success,
|
||||
onChanged: (val) {
|
||||
ref.read(adminControllerProvider.notifier).setTrackFeatured(trackId: track.id, featured: val);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminPostCard extends ConsumerWidget {
|
||||
const _AdminPostCard({required this.post});
|
||||
final AdminPostModel post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: post.featured ? AppColors.neonRed : AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(post.username.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
subtitle: Text('ID: ${post.id.substring(0, 8)}...', style: const TextStyle(fontSize: 10)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_forever, color: AppColors.neonRed),
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
),
|
||||
),
|
||||
if (post.imageUrl.isNotEmpty)
|
||||
Image.network(post.imageUrl, height: 120, width: double.infinity, fit: BoxFit.cover),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('FEATURE CONTENT', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: post.featured,
|
||||
activeColor: AppColors.neonRed,
|
||||
onChanged: (val) {
|
||||
ref.read(adminControllerProvider.notifier).setPostFeatured(postId: post.id, featured: val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
title: const Text('ERASE DATA?'),
|
||||
content: const Text('THIS WILL REMOVE THE CONTENT FROM THE NETWORK PERMANENTLY.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCEL')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(adminControllerProvider.notifier).deletePost(post.id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('DELETE', style: TextStyle(color: AppColors.neonRed)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/features/auth/data/services/auth_service.dart
Normal file
38
lib/features/auth/data/services/auth_service.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class AuthService {
|
||||
const AuthService(this._client);
|
||||
final SupabaseClient _client;
|
||||
|
||||
User? get currentUser => _client.auth.currentUser;
|
||||
Session? get currentSession => _client.auth.currentSession;
|
||||
Stream<AuthState> get onAuthStateChange => _client.auth.onAuthStateChange;
|
||||
|
||||
Future<AuthResponse> signUp({
|
||||
required String email,
|
||||
required String password,
|
||||
required String username,
|
||||
}) async {
|
||||
return await _client.auth.signUp(
|
||||
email: email,
|
||||
password: password,
|
||||
data: {'username': username},
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuthResponse> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
return await _client.auth.signInWithPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async => await _client.auth.signOut();
|
||||
|
||||
Future<void> forgotPassword(String email) async {
|
||||
await _client.auth.resetPasswordForEmail(email);
|
||||
}
|
||||
}
|
||||
126
lib/features/auth/presentation/pages/forgot_password_page.dart
Normal file
126
lib/features/auth/presentation/pages/forgot_password_page.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
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 '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/auth_providers.dart';
|
||||
|
||||
class ForgotPasswordPage extends ConsumerStatefulWidget {
|
||||
const ForgotPasswordPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text(
|
||||
'RECOVERY SIGNAL SENT. CHECK YOUR INBOX.',
|
||||
style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RECOVER IDENTITY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LOST SIGNAL',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ENTER YOUR EMAIL TO RESTORE ACCESS.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'SEND RECOVERY LINK',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.forgotPassword(_emailController.text);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.login),
|
||||
child: Text(
|
||||
'REMEMBERED? BACK TO LOGIN',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/features/auth/presentation/pages/login_page.dart
Normal file
142
lib/features/auth/presentation/pages/login_page.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
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/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../providers/auth_providers.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
if (mounted) context.go(AppRoutes.home);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('IDENTIFY YOURSELF'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.splash),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LOGIN',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ACCESS THE UNDERGROUND.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'PASSWORD',
|
||||
prefixIcon: Icon(Icons.lock_outline, color: AppColors.grey),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.length < 6) ? 'PASSWORD TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'ACCESS GRANTED',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref.read(authControllerProvider.notifier).login(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading
|
||||
? null
|
||||
: () => context.push(AppRoutes.forgotPassword),
|
||||
child: Text(
|
||||
'FORGOT CREDENTIALS?',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.signup),
|
||||
child: Text(
|
||||
'NO IDENTITY? SIGN UP',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/features/auth/presentation/pages/signup_page.dart
Normal file
153
lib/features/auth/presentation/pages/signup_page.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
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/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../providers/auth_providers.dart';
|
||||
|
||||
class SignupPage extends ConsumerStatefulWidget {
|
||||
const SignupPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SignupPage> createState() => _SignupPageState();
|
||||
}
|
||||
|
||||
class _SignupPageState extends ConsumerState<SignupPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text(
|
||||
'IDENTITY CREATED. CHECK EMAIL FOR CONFIRMATION.',
|
||||
style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('CREATE IDENTITY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'SIGN UP',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'JOIN THE UNDERGROUND MOVEMENT.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'USERNAME',
|
||||
prefixIcon: Icon(Icons.person_outline, color: AppColors.grey),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().length < 3) ? 'USERNAME TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'PASSWORD',
|
||||
prefixIcon: Icon(Icons.lock_outline, color: AppColors.grey),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.length < 6) ? 'PASSWORD TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'INITIALIZE RIOT',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref.read(authControllerProvider.notifier).signup(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
username: _usernameController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.login),
|
||||
child: Text(
|
||||
'ALREADY HAVE AN IDENTITY? LOGIN',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
65
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/auth_service.dart';
|
||||
|
||||
/// Provider for the AuthService.
|
||||
final authServiceProvider = Provider<AuthService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return AuthService(client);
|
||||
});
|
||||
|
||||
/// Provider for the current user's authentication state.
|
||||
final authStateProvider = StreamProvider<AuthState>((ref) {
|
||||
return ref.watch(authServiceProvider).onAuthStateChange;
|
||||
});
|
||||
|
||||
/// Controller for authentication actions.
|
||||
final authControllerProvider = AutoDisposeAsyncNotifierProvider<AuthController, void>(
|
||||
AuthController.new,
|
||||
);
|
||||
|
||||
class AuthController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> login({required String email, required String password}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).login(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> signup({
|
||||
required String email,
|
||||
required String password,
|
||||
required String username,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).signUp(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
username: username.trim(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).logout();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> forgotPassword(String email) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).forgotPassword(email.trim());
|
||||
});
|
||||
}
|
||||
}
|
||||
80
lib/features/auth/presentation/providers/auth_providers.dart
Normal file
80
lib/features/auth/presentation/providers/auth_providers.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/auth_service.dart';
|
||||
|
||||
final authServiceProvider = Provider<AuthService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return AuthService(client);
|
||||
});
|
||||
|
||||
final authStateChangesProvider = StreamProvider<AuthState>((ref) {
|
||||
final service = ref.watch(authServiceProvider);
|
||||
return service.authStateChanges();
|
||||
});
|
||||
|
||||
final currentSessionProvider = StreamProvider<Session?>((ref) async* {
|
||||
final service = ref.watch(authServiceProvider);
|
||||
yield service.currentSession;
|
||||
await for (final event in service.authStateChanges()) {
|
||||
yield event.session;
|
||||
}
|
||||
});
|
||||
|
||||
final currentUserProvider = Provider<User?>((ref) {
|
||||
final service = ref.watch(authServiceProvider);
|
||||
return service.currentUser;
|
||||
});
|
||||
|
||||
final authControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AuthController, void>(
|
||||
AuthController.new,
|
||||
);
|
||||
|
||||
class AuthController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> signup({
|
||||
required String email,
|
||||
required String password,
|
||||
required String username,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).signUp(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
username: username.trim(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).login(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).logout();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> forgotPassword(String email) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).forgotPassword(email.trim());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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 '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class ForgotPasswordScreen extends ConsumerStatefulWidget {
|
||||
const ForgotPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text(
|
||||
'RECOVERY SIGNAL SENT. CHECK YOUR INBOX.',
|
||||
style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RECOVER IDENTITY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LOST SIGNAL',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ENTER YOUR EMAIL TO RESTORE ACCESS.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'SEND RECOVERY LINK',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.forgotPassword(_emailController.text);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.login),
|
||||
child: Text(
|
||||
'REMEMBERED? BACK TO LOGIN',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
149
lib/features/auth/presentation/screens/login_screen.dart
Normal file
149
lib/features/auth/presentation/screens/login_screen.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
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_button.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: AppColors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('IDENTIFY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.splash),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LOGIN',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ENTER THE VOID.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppColors.neonRed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'PASSWORD',
|
||||
prefixIcon: Icon(Icons.lock_outline, color: AppColors.grey),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) => (v == null || v.length < 6)
|
||||
? 'PASSWORD TOO SHORT'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'ACCESS GRANTED',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref.read(authControllerProvider.notifier).login(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading
|
||||
? null
|
||||
: () => context.push(AppRoutes.forgotPassword),
|
||||
child: Text(
|
||||
'FORGOT CREDENTIALS?',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppColors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.signup),
|
||||
child: Text(
|
||||
'NO IDENTITY? SIGN UP',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/features/auth/presentation/screens/signup_screen.dart
Normal file
153
lib/features/auth/presentation/screens/signup_screen.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
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_button.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class SignupScreen extends ConsumerStatefulWidget {
|
||||
const SignupScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends ConsumerState<SignupScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text(
|
||||
'IDENTITY INITIALIZED. CHECK EMAIL FOR CONFIRMATION.',
|
||||
style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('CREATE IDENTITY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'SIGN UP',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'JOIN THE RIOT.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'USERNAME',
|
||||
prefixIcon: Icon(Icons.person_outline, color: AppColors.grey),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().length < 3) ? 'USERNAME TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'PASSWORD',
|
||||
prefixIcon: Icon(Icons.lock_outline, color: AppColors.grey),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.length < 6) ? 'PASSWORD TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'INITIALIZE',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref.read(authControllerProvider.notifier).signup(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
username: _usernameController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.login),
|
||||
child: Text(
|
||||
'ALREADY HAVE AN IDENTITY? LOGIN',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
101
lib/features/discover/data/services/discover_service.dart
Normal file
101
lib/features/discover/data/services/discover_service.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../../feed/domain/models/feed_post_model.dart';
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import '../../domain/models/discover_data_model.dart';
|
||||
import '../../domain/models/trending_user_model.dart';
|
||||
|
||||
class DiscoverService {
|
||||
const DiscoverService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<DiscoverDataModel> fetchDiscoverData() async {
|
||||
final users = await _fetchTrendingUsers();
|
||||
final posts = await _fetchTrendingPosts();
|
||||
final tracks = await _fetchTrendingTracks();
|
||||
|
||||
return DiscoverDataModel(
|
||||
trendingUsers: users,
|
||||
trendingPosts: posts,
|
||||
trendingTracks: tracks,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TrendingUserModel>> _fetchTrendingUsers() async {
|
||||
// Trending users based on total engagement (mocking for now by fetching profiles)
|
||||
final profiles = List<Map<String, dynamic>>.from(
|
||||
await _client
|
||||
.from('profiles')
|
||||
.select('user_id, username, avatar_url')
|
||||
.limit(20),
|
||||
);
|
||||
|
||||
final users = <TrendingUserModel>[];
|
||||
for (final profile in profiles) {
|
||||
final userId = profile['user_id'] as String;
|
||||
|
||||
final postsResponse = await _client
|
||||
.from('posts')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
final tracksResponse = await _client
|
||||
.from('tracks')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
users.add(
|
||||
TrendingUserModel(
|
||||
userId: userId,
|
||||
username: (profile['username'] as String?) ?? 'RIOT_USER',
|
||||
avatarUrl: (profile['avatar_url'] as String?) ?? '',
|
||||
postsCount: (postsResponse as List).length,
|
||||
tracksCount: (tracksResponse as List).length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by total content count
|
||||
users.sort((a, b) => (b.postsCount + b.tracksCount).compareTo(a.postsCount + a.tracksCount));
|
||||
return users.take(10).toList();
|
||||
}
|
||||
|
||||
Future<List<FeedPostModel>> _fetchTrendingPosts() async {
|
||||
final rows = List<Map<String, dynamic>>.from(
|
||||
await _client
|
||||
.from('posts')
|
||||
.select('id, user_id, caption, image_url, likes_count, created_at, profiles(username, avatar_url)')
|
||||
.order('likes_count', ascending: false)
|
||||
.limit(10),
|
||||
);
|
||||
|
||||
return rows.map((row) {
|
||||
final profile = (row['profiles'] as Map<String, dynamic>?) ?? {};
|
||||
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: false, // Default to false for discover
|
||||
username: (profile['username'] as String?) ?? 'RIOTER',
|
||||
avatarUrl: (profile['avatar_url'] as String?) ?? '',
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<List<TrackModel>> _fetchTrendingTracks() async {
|
||||
final rows = await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.order('plays', ascending: false)
|
||||
.limit(10);
|
||||
|
||||
return List<Map<String, dynamic>>.from(rows).map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
15
lib/features/discover/domain/models/discover_data_model.dart
Normal file
15
lib/features/discover/domain/models/discover_data_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import '../../../feed/domain/models/feed_post_model.dart';
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import 'trending_user_model.dart';
|
||||
|
||||
class DiscoverDataModel {
|
||||
const DiscoverDataModel({
|
||||
required this.trendingUsers,
|
||||
required this.trendingPosts,
|
||||
required this.trendingTracks,
|
||||
});
|
||||
|
||||
final List<TrendingUserModel> trendingUsers;
|
||||
final List<FeedPostModel> trendingPosts;
|
||||
final List<TrackModel> trendingTracks;
|
||||
}
|
||||
15
lib/features/discover/domain/models/trending_user_model.dart
Normal file
15
lib/features/discover/domain/models/trending_user_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
class TrendingUserModel {
|
||||
const TrendingUserModel({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.avatarUrl,
|
||||
required this.postsCount,
|
||||
required this.tracksCount,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String avatarUrl;
|
||||
final int postsCount;
|
||||
final int tracksCount;
|
||||
}
|
||||
239
lib/features/discover/presentation/pages/discover_page.dart
Normal file
239
lib/features/discover/presentation/pages/discover_page.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../music/presentation/widgets/track_card.dart';
|
||||
import '../../domain/models/discover_data_model.dart';
|
||||
import '../../domain/models/trending_user_model.dart';
|
||||
import '../providers/discover_providers.dart';
|
||||
|
||||
class DiscoverPage extends ConsumerStatefulWidget {
|
||||
const DiscoverPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverPage> createState() => _DiscoverPageState();
|
||||
}
|
||||
|
||||
class _DiscoverPageState extends ConsumerState<DiscoverPage> {
|
||||
final _searchController = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final discoverAsync = ref.watch(discoverDataProvider);
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // DISCOVER'),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.neonRed,
|
||||
backgroundColor: AppColors.black,
|
||||
onRefresh: () async => ref.invalidate(discoverDataProvider),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
// Brutalist Search Bar
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search, color: AppColors.white),
|
||||
hintText: 'SEARCH THE VOID...',
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value.trim()),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
discoverAsync.when(
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
child: CircularProgressIndicator(color: AppColors.neonRed),
|
||||
),
|
||||
),
|
||||
error: (error, _) => Center(child: Text('DISCOVERY OFFLINE: $error')),
|
||||
data: (data) {
|
||||
final filteredUsers = _filterUsers(data);
|
||||
final filteredTracks = data.trendingTracks.where((track) {
|
||||
final q = _query.toLowerCase();
|
||||
return track.title.toLowerCase().contains(q) ||
|
||||
track.username.toLowerCase().contains(q) ||
|
||||
track.genreTag.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
final filteredPosts = data.trendingPosts.where((post) {
|
||||
final q = _query.toLowerCase();
|
||||
return post.caption.toLowerCase().contains(q) ||
|
||||
post.username.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Trending Artists
|
||||
if (filteredUsers.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'TRENDING AGENTS', count: filteredUsers.length),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: filteredUsers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = filteredUsers[index];
|
||||
return _TrendingUserCard(user: user);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
|
||||
// Trending Tracks
|
||||
if (filteredTracks.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'SONIC FREQUENCIES', count: filteredTracks.length),
|
||||
const SizedBox(height: 16),
|
||||
...filteredTracks.take(5).map((track) => TrackCard(track: track)),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
|
||||
// Popular Posts
|
||||
if (filteredPosts.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'VISUAL CHAOS', count: filteredPosts.length),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: filteredPosts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final post = filteredPosts[index];
|
||||
return _DiscoveryGridTile(post: post);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
if (filteredUsers.isEmpty && filteredTracks.isEmpty && filteredPosts.isEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
child: Text('THE VOID IS EMPTY.'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<TrendingUserModel> _filterUsers(DiscoverDataModel data) {
|
||||
final q = _query.toLowerCase();
|
||||
if (q.isEmpty) return data.trendingUsers;
|
||||
return data.trendingUsers
|
||||
.where((user) => user.username.toLowerCase().contains(q))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.count});
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 2)),
|
||||
const Spacer(),
|
||||
Text('[$count]', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrendingUserCard extends StatelessWidget {
|
||||
const _TrendingUserCard({required this.user});
|
||||
final TrendingUserModel user;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 90,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.white, width: 1.5),
|
||||
color: AppColors.surfaceLight,
|
||||
),
|
||||
child: user.avatarUrl.isNotEmpty
|
||||
? Image.network(user.avatarUrl, fit: BoxFit.cover)
|
||||
: const Icon(Icons.person, color: AppColors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
user.username.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscoveryGridTile extends StatelessWidget {
|
||||
const _DiscoveryGridTile({required this.post});
|
||||
final dynamic post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.border),
|
||||
color: AppColors.surface,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (post.imageUrl.isNotEmpty)
|
||||
Image.network(post.imageUrl, fit: BoxFit.cover),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: AppColors.black.withOpacity(0.7),
|
||||
child: Text(
|
||||
post.username.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/discover_service.dart';
|
||||
import '../../domain/models/discover_data_model.dart';
|
||||
|
||||
final discoverServiceProvider = Provider<DiscoverService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return DiscoverService(client);
|
||||
});
|
||||
|
||||
final discoverDataProvider = FutureProvider<DiscoverDataModel>((ref) {
|
||||
return ref.watch(discoverServiceProvider).fetchDiscoverData();
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../music/presentation/widgets/track_card.dart';
|
||||
import '../providers/discover_providers.dart';
|
||||
|
||||
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||
const DiscoverScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||
}
|
||||
|
||||
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final discoverAsync = ref.watch(discoverDataProvider);
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(title: const Text('RIOTZ // DISCOVER')),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.neonRed,
|
||||
backgroundColor: AppColors.black,
|
||||
onRefresh: () async => ref.invalidate(discoverDataProvider),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search, color: AppColors.white),
|
||||
hintText: 'SEARCH THE VOID...',
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value.trim()),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
discoverAsync.when(
|
||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(48), child: CircularProgressIndicator(color: AppColors.neonRed))),
|
||||
error: (error, _) => Center(child: Text('DISCOVERY OFFLINE: $error')),
|
||||
data: (data) => _buildDiscoveryContent(theme, data),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiscoveryContent(ThemeData theme, data) {
|
||||
// Logic previously implemented in discover_page.dart, now polished for discover_screen.dart
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionHeader(title: 'TRENDING AGENTS', count: data.trendingUsers.length),
|
||||
const SizedBox(height: 40),
|
||||
_SectionHeader(title: 'SONIC FREQUENCIES', count: data.trendingTracks.length),
|
||||
const SizedBox(height: 16),
|
||||
...data.trendingTracks.take(5).map((track) => TrackCard(track: track)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.count});
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 2)),
|
||||
const Spacer(),
|
||||
Text('[$count]', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
137
lib/features/feed/data/services/feed_service.dart
Normal file
137
lib/features/feed/data/services/feed_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
152
lib/features/feed/data/services/post_service.dart
Normal file
152
lib/features/feed/data/services/post_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
40
lib/features/feed/domain/models/feed_post_model.dart
Normal file
40
lib/features/feed/domain/models/feed_post_model.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/features/feed/presentation/providers/feed_providers.dart
Normal file
62
lib/features/feed/presentation/providers/feed_providers.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
59
lib/features/feed/presentation/providers/post_providers.dart
Normal file
59
lib/features/feed/presentation/providers/post_providers.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
94
lib/features/feed/presentation/screens/feed_screen.dart
Normal file
94
lib/features/feed/presentation/screens/feed_screen.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
lib/features/feed/presentation/screens/upload_post_screen.dart
Normal file
134
lib/features/feed/presentation/screens/upload_post_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/features/feed/presentation/widgets/post_card.dart
Normal file
117
lib/features/feed/presentation/widgets/post_card.dart
Normal 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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/features/music/data/services/music_service.dart
Normal file
84
lib/features/music/data/services/music_service.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../domain/models/track_model.dart';
|
||||
|
||||
class MusicService {
|
||||
const MusicService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
User get _currentUser {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw StateError('No authenticated user');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/// Uploads a new track with an MP3 file and metadata.
|
||||
Future<void> uploadTrack({
|
||||
required Uint8List bytes,
|
||||
required String title,
|
||||
required String genreTag,
|
||||
required String extension,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final random = Random().nextInt(99999);
|
||||
final fileName = '$ts-$random.$extension';
|
||||
final path = 'tracks/${user.id}/$fileName';
|
||||
|
||||
// Upload audio to Supabase Storage
|
||||
await _client.storage.from('tracks').uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: false),
|
||||
);
|
||||
|
||||
final audioUrl = _client.storage.from('tracks').getPublicUrl(path);
|
||||
|
||||
// Insert track record into PostgreSQL
|
||||
await _client.from('tracks').insert({
|
||||
'user_id': user.id,
|
||||
'title': title.trim().isEmpty ? 'UNTITLED' : title.trim().toUpperCase(),
|
||||
'audio_url': audioUrl,
|
||||
'genre_tag': genreTag.trim().isEmpty ? 'UNKNOWN' : genreTag.trim().toUpperCase(),
|
||||
'plays': 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Lists all tracks in chronological order.
|
||||
Future<List<TrackModel>> fetchTracks() async {
|
||||
final response = await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
return List<Map<String, dynamic>>.from(response).map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Increments the play count of a track.
|
||||
Future<void> incrementPlays(String trackId) async {
|
||||
await _client.rpc('increment_track_plays', params: {'track_id': trackId});
|
||||
}
|
||||
|
||||
/// Fetches tracks for a specific user.
|
||||
Future<List<TrackModel>> fetchUserTracks(String userId) async {
|
||||
final response = await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
return List<Map<String, dynamic>>.from(response).map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
54
lib/features/music/domain/models/track_model.dart
Normal file
54
lib/features/music/domain/models/track_model.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
class TrackModel {
|
||||
const TrackModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.title,
|
||||
required this.audioUrl,
|
||||
required this.genreTag,
|
||||
required this.plays,
|
||||
required this.createdAt,
|
||||
required this.username,
|
||||
this.featured = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
final String title;
|
||||
final String audioUrl;
|
||||
final String genreTag;
|
||||
final int plays;
|
||||
final DateTime createdAt;
|
||||
final String username;
|
||||
final bool featured;
|
||||
|
||||
factory TrackModel.fromJson(Map<String, dynamic> json, {String? username}) {
|
||||
return TrackModel(
|
||||
id: json['id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
title: (json['title'] as String?) ?? 'UNTITLED',
|
||||
audioUrl: (json['audio_url'] as String?) ?? '',
|
||||
genreTag: (json['genre_tag'] as String?) ?? 'UNKNOWN',
|
||||
plays: (json['plays'] as int?) ?? 0,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
username: username ?? 'RIOTER',
|
||||
featured: (json['featured'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
TrackModel copyWith({
|
||||
int? plays,
|
||||
bool? featured,
|
||||
}) {
|
||||
return TrackModel(
|
||||
id: id,
|
||||
userId: userId,
|
||||
title: title,
|
||||
audioUrl: audioUrl,
|
||||
genreTag: genreTag,
|
||||
plays: plays ?? this.plays,
|
||||
createdAt: createdAt,
|
||||
username: username,
|
||||
featured: featured ?? this.featured,
|
||||
);
|
||||
}
|
||||
}
|
||||
236
lib/features/music/presentation/pages/music_page.dart
Normal file
236
lib/features/music/presentation/pages/music_page.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
import '../widgets/track_card.dart';
|
||||
|
||||
class MusicPage extends ConsumerStatefulWidget {
|
||||
const MusicPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MusicPage> createState() => _MusicPageState();
|
||||
}
|
||||
|
||||
class _MusicPageState extends ConsumerState<MusicPage> {
|
||||
final _titleController = TextEditingController();
|
||||
final _genreController = TextEditingController();
|
||||
Uint8List? _selectedMp3Bytes;
|
||||
String? _selectedFileName;
|
||||
String _selectedExtension = 'mp3';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_genreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tracksAsync = ref.watch(trackListProvider);
|
||||
final uploadState = ref.watch(musicControllerProvider);
|
||||
|
||||
ref.listen(musicControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('SONIC CHAOS UPLOADED.', 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 // AUDIO'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Text(
|
||||
'UPLOAD TRANSMISSION',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_UploadComposer(
|
||||
titleController: _titleController,
|
||||
genreController: _genreController,
|
||||
selectedFileName: _selectedFileName,
|
||||
isBusy: uploadState.isLoading,
|
||||
onPickMp3: uploadState.isLoading ? null : _pickMp3,
|
||||
onUpload: uploadState.isLoading ? null : _upload,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
'LATEST SOUNDS',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
tracksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('SIGNAL LOST: $error')),
|
||||
data: (tracks) {
|
||||
if (tracks.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text('NO TRACKS FOUND. BROADCAST YOUR SOUND.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: tracks.map((track) {
|
||||
return TrackCard(track: track);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickMp3() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['mp3'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
|
||||
final file = result.files.first;
|
||||
if (file.bytes == null) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('UNREADABLE FILE.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedMp3Bytes = file.bytes;
|
||||
_selectedFileName = file.name.toUpperCase();
|
||||
_selectedExtension = file.name.split('.').last.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _upload() async {
|
||||
final bytes = _selectedMp3Bytes;
|
||||
if (bytes == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('SELECT AN MP3 FIRST.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(musicControllerProvider.notifier).uploadTrack(
|
||||
bytes: bytes,
|
||||
title: _titleController.text,
|
||||
genreTag: _genreController.text,
|
||||
extension: _selectedExtension,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedMp3Bytes = null;
|
||||
_selectedFileName = null;
|
||||
_titleController.clear();
|
||||
_genreController.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadComposer extends StatelessWidget {
|
||||
const _UploadComposer({
|
||||
required this.titleController,
|
||||
required this.genreController,
|
||||
required this.selectedFileName,
|
||||
required this.isBusy,
|
||||
required this.onPickMp3,
|
||||
required this.onUpload,
|
||||
});
|
||||
|
||||
final TextEditingController titleController;
|
||||
final TextEditingController genreController;
|
||||
final String? selectedFileName;
|
||||
final bool isBusy;
|
||||
final VoidCallback? onPickMp3;
|
||||
final VoidCallback? onUpload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'TRACK TITLE',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: genreController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'GENRE (RAGE, PUNK, PHONK)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (selectedFileName != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'READY: $selectedFileName',
|
||||
style: const TextStyle(color: AppColors.neonRed, fontWeight: FontWeight.bold, fontSize: 10),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RiotzButton(
|
||||
label: 'SELECT FILE',
|
||||
style: RiotzButtonStyle.outline,
|
||||
onPressed: onPickMp3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: RiotzButton(
|
||||
label: 'UPLOAD',
|
||||
isLoading: isBusy,
|
||||
onPressed: onUpload,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/music_service.dart';
|
||||
import '../../domain/models/track_model.dart';
|
||||
|
||||
final musicServiceProvider = Provider<MusicService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return MusicService(client);
|
||||
});
|
||||
|
||||
final trackListProvider = FutureProvider<List<TrackModel>>((ref) {
|
||||
return ref.watch(musicServiceProvider).fetchTracks();
|
||||
});
|
||||
|
||||
final userTracksProvider = FutureProvider.family<List<TrackModel>, String>((ref, userId) {
|
||||
return ref.watch(musicServiceProvider).fetchUserTracks(userId);
|
||||
});
|
||||
|
||||
final musicControllerProvider = AutoDisposeAsyncNotifierProvider<MusicController, void>(
|
||||
MusicController.new,
|
||||
);
|
||||
|
||||
class MusicController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> uploadTrack({
|
||||
required Uint8List bytes,
|
||||
required String title,
|
||||
required String genreTag,
|
||||
required String extension,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(musicServiceProvider).uploadTrack(
|
||||
bytes: bytes,
|
||||
title: title,
|
||||
genreTag: genreTag,
|
||||
extension: extension,
|
||||
);
|
||||
ref.invalidate(trackListProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> incrementPlays(String trackId) async {
|
||||
await AsyncValue.guard(() => ref.read(musicServiceProvider).incrementPlays(trackId));
|
||||
}
|
||||
}
|
||||
|
||||
final audioPlayerProvider = Provider.autoDispose<AudioPlayer>((ref) {
|
||||
final player = AudioPlayer();
|
||||
ref.onDispose(player.dispose);
|
||||
return player;
|
||||
});
|
||||
|
||||
final currentTrackProvider = StateProvider<TrackModel?>((ref) => null);
|
||||
final isPlayingProvider = StreamProvider<bool>((ref) {
|
||||
final player = ref.watch(audioPlayerProvider);
|
||||
return player.playingStream;
|
||||
});
|
||||
140
lib/features/music/presentation/screens/music_screen.dart
Normal file
140
lib/features/music/presentation/screens/music_screen.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
import '../widgets/track_card.dart';
|
||||
|
||||
class MusicScreen extends ConsumerStatefulWidget {
|
||||
const MusicScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MusicScreen> createState() => _MusicScreenState();
|
||||
}
|
||||
|
||||
class _MusicScreenState extends ConsumerState<MusicScreen> {
|
||||
final _titleController = TextEditingController();
|
||||
final _genreController = TextEditingController();
|
||||
Uint8List? _selectedMp3Bytes;
|
||||
String? _selectedFileName;
|
||||
String _selectedExtension = 'mp3';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_genreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tracksAsync = ref.watch(trackListProvider);
|
||||
final uploadState = ref.watch(musicControllerProvider);
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(title: const Text('RIOTZ // AUDIO')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Text('UPLOAD TRANSMISSION', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
const SizedBox(height: 16),
|
||||
_UploadComposer(
|
||||
titleController: _titleController,
|
||||
genreController: _genreController,
|
||||
selectedFileName: _selectedFileName,
|
||||
isBusy: uploadState.isLoading,
|
||||
onPickMp3: uploadState.isLoading ? null : _pickMp3,
|
||||
onUpload: uploadState.isLoading ? null : _upload,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Text('LATEST SOUNDS', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
const SizedBox(height: 16),
|
||||
tracksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('SIGNAL LOST: $error')),
|
||||
data: (tracks) => Column(
|
||||
children: tracks.map((track) => TrackCard(track: track)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickMp3() async {
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['mp3'], withData: true);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final file = result.files.first;
|
||||
setState(() {
|
||||
_selectedMp3Bytes = file.bytes;
|
||||
_selectedFileName = file.name.toUpperCase();
|
||||
_selectedExtension = file.name.split('.').last.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _upload() async {
|
||||
if (_selectedMp3Bytes == null) return;
|
||||
await ref.read(musicControllerProvider.notifier).uploadTrack(
|
||||
bytes: _selectedMp3Bytes!,
|
||||
title: _titleController.text,
|
||||
genreTag: _genreController.text,
|
||||
extension: _selectedExtension,
|
||||
);
|
||||
setState(() {
|
||||
_selectedMp3Bytes = null;
|
||||
_selectedFileName = null;
|
||||
_titleController.clear();
|
||||
_genreController.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadComposer extends StatelessWidget {
|
||||
const _UploadComposer({
|
||||
required this.titleController,
|
||||
required this.genreController,
|
||||
required this.selectedFileName,
|
||||
required this.isBusy,
|
||||
required this.onPickMp3,
|
||||
required this.onUpload,
|
||||
});
|
||||
|
||||
final TextEditingController titleController;
|
||||
final TextEditingController genreController;
|
||||
final String? selectedFileName;
|
||||
final bool isBusy;
|
||||
final VoidCallback? onPickMp3;
|
||||
final VoidCallback? onUpload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: AppColors.surface, border: Border.all(color: AppColors.border)),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(controller: titleController, decoration: const InputDecoration(hintText: 'TRACK TITLE')),
|
||||
const SizedBox(height: 16),
|
||||
TextField(controller: genreController, decoration: const InputDecoration(hintText: 'GENRE')),
|
||||
const SizedBox(height: 24),
|
||||
if (selectedFileName != null) Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text('READY: $selectedFileName', style: const TextStyle(color: AppColors.neonRed, fontWeight: FontWeight.bold, fontSize: 10)),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: RiotzButton(label: 'SELECT', style: RiotzButtonStyle.outline, onPressed: onPickMp3)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: RiotzButton(label: 'UPLOAD', isLoading: isBusy, onPressed: onUpload)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/features/music/presentation/widgets/track_card.dart
Normal file
109
lib/features/music/presentation/widgets/track_card.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../domain/models/track_model.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
|
||||
class TrackCard extends ConsumerWidget {
|
||||
const TrackCard({
|
||||
required this.track,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TrackModel track;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final player = ref.watch(audioPlayerProvider);
|
||||
final currentTrack = ref.watch(currentTrackProvider);
|
||||
final isPlaying = ref.watch(isPlayingProvider).value ?? false;
|
||||
final isCurrent = currentTrack?.id == track.id;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent ? AppColors.surfaceLight : Colors.transparent,
|
||||
border: Border.all(color: isCurrent ? AppColors.neonRed : AppColors.border),
|
||||
),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _handlePlay(ref, player),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover / Icon
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: AppColors.black,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isCurrent && isPlaying ? Icons.graphic_eq : Icons.music_note,
|
||||
color: isCurrent ? AppColors.neonRed : AppColors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.title.toUpperCase(),
|
||||
style: theme.textTheme.titleLarge?.copyWith(fontSize: 16, letterSpacing: 1),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${track.username.toUpperCase()} // ${track.genreTag.toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: AppColors.grey, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Plays & Action
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${track.plays} PLAYS',
|
||||
style: theme.textTheme.bodySmall?.copyWith(fontSize: 9, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Icon(
|
||||
isCurrent && isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: AppColors.white,
|
||||
size: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePlay(WidgetRef ref, AudioPlayer player) async {
|
||||
final currentTrack = ref.read(currentTrackProvider);
|
||||
|
||||
if (currentTrack?.id == track.id) {
|
||||
if (player.playing) {
|
||||
await player.pause();
|
||||
} else {
|
||||
await player.play();
|
||||
}
|
||||
} else {
|
||||
ref.read(currentTrackProvider.notifier).state = track;
|
||||
await player.setUrl(track.audioUrl);
|
||||
await player.play();
|
||||
// Increment play count
|
||||
ref.read(musicControllerProvider.notifier).incrementPlays(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
lib/features/profile/data/services/profile_service.dart
Normal file
136
lib/features/profile/data/services/profile_service.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../domain/models/profile_model.dart';
|
||||
import '../../domain/models/profile_stats_model.dart';
|
||||
|
||||
class ProfileService {
|
||||
const ProfileService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
User get _currentUser {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw StateError('No authenticated user');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<ProfileModel> fetchMyProfile() async {
|
||||
final user = _currentUser;
|
||||
final response = await _client
|
||||
.from('profiles')
|
||||
.select()
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (response == null) {
|
||||
final created = await _client
|
||||
.from('profiles')
|
||||
.insert({
|
||||
'user_id': user.id,
|
||||
'username': (user.userMetadata?['username'] as String?) ?? '',
|
||||
'bio': '',
|
||||
'avatar_url': '',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return ProfileModel.fromJson(created);
|
||||
}
|
||||
|
||||
return ProfileModel.fromJson(response);
|
||||
}
|
||||
|
||||
Future<ProfileModel> updateUsername(String username) async {
|
||||
final user = _currentUser;
|
||||
await _client.auth.updateUser(UserAttributes(data: {'username': username}));
|
||||
final updated = await _client
|
||||
.from('profiles')
|
||||
.update({'username': username})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
return ProfileModel.fromJson(updated);
|
||||
}
|
||||
|
||||
Future<ProfileModel> updateBio(String bio) async {
|
||||
final user = _currentUser;
|
||||
final updated = await _client
|
||||
.from('profiles')
|
||||
.update({'bio': bio})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
return ProfileModel.fromJson(updated);
|
||||
}
|
||||
|
||||
Future<ProfileModel> updateProfile({
|
||||
required String username,
|
||||
required String bio,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
await _client.auth.updateUser(UserAttributes(data: {'username': username}));
|
||||
final updated = await _client
|
||||
.from('profiles')
|
||||
.update({
|
||||
'username': username,
|
||||
'bio': bio,
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
return ProfileModel.fromJson(updated);
|
||||
}
|
||||
|
||||
Future<ProfileModel> uploadAvatar({
|
||||
required Uint8List bytes,
|
||||
required String extension,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
final path =
|
||||
'avatars/${user.id}/${DateTime.now().millisecondsSinceEpoch}.$extension';
|
||||
|
||||
await _client.storage.from('avatars').uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: true),
|
||||
);
|
||||
|
||||
final avatarUrl = _client.storage.from('avatars').getPublicUrl(path);
|
||||
final updated = await _client
|
||||
.from('profiles')
|
||||
.update({'avatar_url': avatarUrl})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
return ProfileModel.fromJson(updated);
|
||||
}
|
||||
|
||||
Future<ProfileStatsModel> fetchMyStats() async {
|
||||
final user = _currentUser;
|
||||
|
||||
final postResponse = await _client
|
||||
.from('posts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
final commentResponse = await _client
|
||||
.from('comments')
|
||||
.select('*')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
final trackResponse = await _client
|
||||
.from('tracks')
|
||||
.select('*')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
return ProfileStatsModel(
|
||||
postsCount: (postResponse as List).length,
|
||||
commentsCount: (commentResponse as List).length,
|
||||
tracksCount: (trackResponse as List).length,
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/features/profile/domain/models/profile_model.dart
Normal file
35
lib/features/profile/domain/models/profile_model.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
class ProfileModel {
|
||||
const ProfileModel({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.bio,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String bio;
|
||||
final String avatarUrl;
|
||||
|
||||
factory ProfileModel.fromJson(Map<String, dynamic> json) {
|
||||
return ProfileModel(
|
||||
userId: (json['user_id'] as String?) ?? '',
|
||||
username: (json['username'] as String?) ?? '',
|
||||
bio: (json['bio'] as String?) ?? '',
|
||||
avatarUrl: (json['avatar_url'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
ProfileModel copyWith({
|
||||
String? username,
|
||||
String? bio,
|
||||
String? avatarUrl,
|
||||
}) {
|
||||
return ProfileModel(
|
||||
userId: userId,
|
||||
username: username ?? this.username,
|
||||
bio: bio ?? this.bio,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
11
lib/features/profile/domain/models/profile_stats_model.dart
Normal file
11
lib/features/profile/domain/models/profile_stats_model.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
class ProfileStatsModel {
|
||||
const ProfileStatsModel({
|
||||
required this.postsCount,
|
||||
required this.commentsCount,
|
||||
required this.tracksCount,
|
||||
});
|
||||
|
||||
final int postsCount;
|
||||
final int commentsCount;
|
||||
final int tracksCount;
|
||||
}
|
||||
283
lib/features/profile/presentation/pages/profile_page.dart
Normal file
283
lib/features/profile/presentation/pages/profile_page.dart
Normal file
@@ -0,0 +1,283 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../auth/presentation/providers/auth_provider.dart';
|
||||
import '../../../music/presentation/providers/music_providers.dart';
|
||||
import '../../../music/presentation/widgets/track_card.dart';
|
||||
import '../../domain/models/profile_model.dart';
|
||||
import '../../domain/models/profile_stats_model.dart';
|
||||
import '../providers/profile_providers.dart';
|
||||
|
||||
class ProfilePage extends ConsumerStatefulWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends ConsumerState<ProfilePage> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _usernameController = TextEditingController();
|
||||
final _bioController = TextEditingController();
|
||||
final _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_usernameController.dispose();
|
||||
_bioController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(profileControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('IDENTITY UPDATED.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
ref.invalidate(myProfileStatsProvider);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(error.toString().toUpperCase()),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final profileAsync = ref.watch(myProfileProvider);
|
||||
final statsAsync = ref.watch(myProfileStatsProvider);
|
||||
final updating = ref.watch(profileControllerProvider).isLoading;
|
||||
final currentUser = ref.watch(authServiceProvider).currentUser;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // IDENTITY'),
|
||||
),
|
||||
body: profileAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('ERROR: $error')),
|
||||
data: (profile) {
|
||||
_syncControllers(profile);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
children: [
|
||||
// Profile Header
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.neonRed, width: 2),
|
||||
color: AppColors.surfaceLight,
|
||||
),
|
||||
child: profile.avatarUrl.isNotEmpty
|
||||
? Image.network(profile.avatarUrl, fit: BoxFit.cover)
|
||||
: const Icon(Icons.person, size: 60, color: AppColors.grey),
|
||||
),
|
||||
Positioned(
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
child: IconButton(
|
||||
onPressed: updating ? null : _pickAndUploadAvatar,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
color: AppColors.neonRed,
|
||||
child: const Icon(Icons.edit, color: AppColors.white, size: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_StatsSection(statsAsync: statsAsync),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Tabs
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: AppColors.neonRed,
|
||||
labelColor: AppColors.white,
|
||||
unselectedLabelColor: AppColors.grey,
|
||||
tabs: const [
|
||||
Tab(text: 'IDENTITY'),
|
||||
Tab(text: 'TRACKS'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
height: 400, // Fixed height for tab content in ListView
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Identity Tab
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('USERNAME', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(hintText: 'ENTER ALIAS'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('BIO // MANIFESTO', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _bioController,
|
||||
decoration: const InputDecoration(hintText: 'DESCRIBE THE CHAOS'),
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'UPDATE IDENTITY',
|
||||
isLoading: updating,
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(profileControllerProvider.notifier)
|
||||
.saveProfile(
|
||||
username: _usernameController.text,
|
||||
bio: _bioController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tracks Tab
|
||||
currentUser == null
|
||||
? const Center(child: Text('LOG IN TO SEE TRACKS'))
|
||||
: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final tracksAsync = ref.watch(userTracksProvider(currentUser.id));
|
||||
return tracksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (err, _) => Center(child: Text('ERROR: $err')),
|
||||
data: (tracks) {
|
||||
if (tracks.isEmpty) {
|
||||
return const Center(child: Text('NO TRACKS BROADCASTED.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) => TrackCard(track: tracks[index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncControllers(ProfileModel profile) {
|
||||
if (_usernameController.text != profile.username) {
|
||||
_usernameController.text = profile.username;
|
||||
}
|
||||
if (_bioController.text != profile.bio) {
|
||||
_bioController.text = profile.bio;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadAvatar() async {
|
||||
final picked = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 85,
|
||||
maxWidth: 1200,
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
final bytes = await picked.readAsBytes();
|
||||
final ext = picked.name.split('.').last.toLowerCase();
|
||||
|
||||
if (!mounted) return;
|
||||
await ref.read(profileControllerProvider.notifier).uploadAvatar(
|
||||
bytes: bytes,
|
||||
extension: ext.isEmpty ? 'jpg' : ext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsSection extends StatelessWidget {
|
||||
const _StatsSection({required this.statsAsync});
|
||||
|
||||
final AsyncValue<ProfileStatsModel> statsAsync;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return statsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Text('STATS OFFLINE: $error'),
|
||||
data: (stats) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_StatItem(label: 'POSTS', value: stats.postsCount.toString()),
|
||||
_StatItem(label: 'CHAOS', value: stats.commentsCount.toString()),
|
||||
_StatItem(label: 'TRACKS', value: stats.tracksCount.toString()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatItem extends StatelessWidget {
|
||||
const _StatItem({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
Text(value, style: theme.textTheme.headlineLarge),
|
||||
Text(label, style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey, fontSize: 10)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/profile_service.dart';
|
||||
import '../../domain/models/profile_model.dart';
|
||||
import '../../domain/models/profile_stats_model.dart';
|
||||
|
||||
final profileServiceProvider = Provider<ProfileService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return ProfileService(client);
|
||||
});
|
||||
|
||||
final myProfileProvider = FutureProvider<ProfileModel>((ref) {
|
||||
return ref.watch(profileServiceProvider).fetchMyProfile();
|
||||
});
|
||||
|
||||
final myProfileStatsProvider = FutureProvider<ProfileStatsModel>((ref) {
|
||||
return ref.watch(profileServiceProvider).fetchMyStats();
|
||||
});
|
||||
|
||||
final profileControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<ProfileController, void>(
|
||||
ProfileController.new,
|
||||
);
|
||||
|
||||
class ProfileController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> updateUsername(String username) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileServiceProvider).updateUsername(username.trim());
|
||||
ref.invalidate(myProfileProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateBio(String bio) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileServiceProvider).updateBio(bio.trim());
|
||||
ref.invalidate(myProfileProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> uploadAvatar({
|
||||
required Uint8List bytes,
|
||||
required String extension,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileServiceProvider).uploadAvatar(
|
||||
bytes: bytes,
|
||||
extension: extension,
|
||||
);
|
||||
ref.invalidate(myProfileProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> saveProfile({
|
||||
required String username,
|
||||
required String bio,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileServiceProvider).updateProfile(
|
||||
username: username.trim(),
|
||||
bio: bio.trim(),
|
||||
);
|
||||
ref.invalidate(myProfileProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
70
lib/features/splash/presentation/pages/splash_page.dart
Normal file
70
lib/features/splash/presentation/pages/splash_page.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.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 '../../../../core/widgets/riotz_button.dart';
|
||||
|
||||
class SplashPage extends StatelessWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return RiotzScaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
// Brand Title
|
||||
Text(
|
||||
'RIOTZ',
|
||||
style: theme.textTheme.displayLarge?.copyWith(
|
||||
color: AppColors.white,
|
||||
height: 0.9,
|
||||
),
|
||||
),
|
||||
// Aesthetic Subtitle
|
||||
Container(
|
||||
color: AppColors.neonRed,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(
|
||||
'UNDERGROUND // CHAOS // CULTURE',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppColors.white,
|
||||
letterSpacing: 1.5,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'THE PREMIER PLATFORM FOR PUNK RAP, RAGE CULTURE, AND ALT FASHION.',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Actions
|
||||
RiotzButton(
|
||||
label: 'Enter the Chaos',
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
RiotzButton(
|
||||
label: 'Join the RIOT',
|
||||
style: RiotzButtonStyle.outline,
|
||||
onPressed: () => context.go(AppRoutes.signup),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_motion.dart';
|
||||
import '../../../../shared/widgets/riotz_shell.dart';
|
||||
|
||||
class RiotzThemePreviewPage extends StatefulWidget {
|
||||
const RiotzThemePreviewPage({super.key});
|
||||
|
||||
@override
|
||||
State<RiotzThemePreviewPage> createState() => _RiotzThemePreviewPageState();
|
||||
}
|
||||
|
||||
class _RiotzThemePreviewPageState extends State<RiotzThemePreviewPage> {
|
||||
bool _switchValue = true;
|
||||
bool _chipSelected = true;
|
||||
double _slider = 42;
|
||||
bool _animatedOn = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return RiotzShell(
|
||||
title: 'RIOTZ // Theme Preview',
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Typography', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text('Display Medium', style: theme.textTheme.displayMedium),
|
||||
Text('Headline Medium', style: theme.textTheme.headlineMedium),
|
||||
Text('Title Medium', style: theme.textTheme.titleMedium),
|
||||
Text('Body Medium example text', style: theme.textTheme.bodyMedium),
|
||||
Text('Label Large', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 18),
|
||||
Text('Color Tokens', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
_ColorSwatch(name: 'black', color: AppColors.black),
|
||||
_ColorSwatch(name: 'blackRaised', color: AppColors.blackRaised),
|
||||
_ColorSwatch(name: 'deepRed', color: AppColors.deepRed),
|
||||
_ColorSwatch(name: 'neonRed', color: AppColors.neonRed),
|
||||
_ColorSwatch(name: 'neonPurple', color: AppColors.neonPurple),
|
||||
_ColorSwatch(name: 'white', color: AppColors.white),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Buttons', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton(onPressed: () {}, child: const Text('Filled')),
|
||||
FilledButton.tonal(onPressed: () {}, child: const Text('Tonal')),
|
||||
OutlinedButton(onPressed: () {}, child: const Text('Outlined')),
|
||||
TextButton(onPressed: () {}, child: const Text('Text')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Inputs', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
const TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Username',
|
||||
hintText: 'riotz_name',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Bio',
|
||||
hintText: 'Underground frequencies only',
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Cards / Controls', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: _switchValue,
|
||||
onChanged: (v) => setState(() => _switchValue = v),
|
||||
title: const Text('Glitch mode'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
Slider(
|
||||
value: _slider,
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: _slider.round().toString(),
|
||||
onChanged: (v) => setState(() => _slider = v),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilterChip(
|
||||
label: const Text('punk-rap'),
|
||||
selected: _chipSelected,
|
||||
onSelected: (v) => setState(() => _chipSelected = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Motion', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: () => setState(() => _animatedOn = !_animatedOn),
|
||||
child: const Text('Trigger animation'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AnimatedContainer(
|
||||
duration: AppMotion.normal,
|
||||
curve: AppMotion.emphasizedCurve,
|
||||
height: 56,
|
||||
width: _animatedOn ? double.infinity : 140,
|
||||
decoration: BoxDecoration(
|
||||
color: _animatedOn ? AppColors.neonRed : AppColors.blackSoft,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_animatedOn ? 'Chaos On' : 'Chaos Idle',
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Snackbar', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('RIOTZ snack style preview')),
|
||||
);
|
||||
},
|
||||
child: const Text('Show Snackbar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorSwatch extends StatelessWidget {
|
||||
const _ColorSwatch({
|
||||
required this.name,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 110,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blackSoft,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(name, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user