first commit

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

View File

@@ -0,0 +1,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);
}
}

View 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;
}

View 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;
}

View 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;
}

View 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,
),
],
),
],
),
],
),
),
);
}
}

View File

@@ -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);
});
}
}

View 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)),
),
],
),
);
}
}