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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

View File

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