first commit
This commit is contained in:
21
lib/app/app.dart
Normal file
21
lib/app/app.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/router/app_router.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
|
||||
class RiotzApp extends ConsumerWidget {
|
||||
const RiotzApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'RIOTZ',
|
||||
theme: AppTheme.dark,
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
lib/core/config/admin_whitelist.dart
Normal file
17
lib/core/config/admin_whitelist.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
class AdminWhitelist {
|
||||
const AdminWhitelist._();
|
||||
|
||||
// Whitelist of admin emails.
|
||||
// In a real production app, this should be handled via database roles (RBAC).
|
||||
static const Set<String> emails = {
|
||||
'admin@riotz.com',
|
||||
'root@riotz.com',
|
||||
'creator@riotz.com',
|
||||
};
|
||||
|
||||
/// Checks if a user is an admin based on their email.
|
||||
static bool isAdmin(String? email) {
|
||||
if (email == null) return false;
|
||||
return emails.contains(email.toLowerCase());
|
||||
}
|
||||
}
|
||||
22
lib/core/config/supabase_config.dart
Normal file
22
lib/core/config/supabase_config.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class SupabaseConfig {
|
||||
const SupabaseConfig._();
|
||||
|
||||
static const _url = String.fromEnvironment('SUPABASE_URL');
|
||||
static const _anonKey = String.fromEnvironment('SUPABASE_ANON_KEY');
|
||||
|
||||
static Future<void> initialize() async {
|
||||
if (_url.isEmpty || _anonKey.isEmpty) {
|
||||
throw StateError(
|
||||
'Missing Supabase env values. Provide SUPABASE_URL and SUPABASE_ANON_KEY '
|
||||
'using --dart-define.',
|
||||
);
|
||||
}
|
||||
|
||||
await Supabase.initialize(
|
||||
url: _url,
|
||||
anonKey: _anonKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
14
lib/core/models/result.dart
Normal file
14
lib/core/models/result.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
sealed class Result<T> {
|
||||
const Result();
|
||||
}
|
||||
|
||||
class Success<T> extends Result<T> {
|
||||
final T data;
|
||||
const Success(this.data);
|
||||
}
|
||||
|
||||
class Failure<T> extends Result<T> {
|
||||
final String message;
|
||||
final dynamic error;
|
||||
const Failure(this.message, [this.error]);
|
||||
}
|
||||
107
lib/core/router/app_router.dart
Normal file
107
lib/core/router/app_router.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../features/auth/presentation/screens/forgot_password_screen.dart';
|
||||
import '../../features/auth/presentation/screens/login_screen.dart';
|
||||
import '../../features/auth/presentation/screens/signup_screen.dart';
|
||||
import '../../features/admin/presentation/screens/admin_screen.dart';
|
||||
import '../../features/discover/presentation/pages/discover_page.dart';
|
||||
import '../../features/feed/presentation/screens/feed_screen.dart';
|
||||
import '../../features/feed/presentation/screens/upload_post_screen.dart';
|
||||
import '../../features/music/presentation/pages/music_page.dart';
|
||||
import '../../features/profile/presentation/pages/profile_page.dart';
|
||||
import '../../features/splash/presentation/pages/splash_page.dart';
|
||||
import '../../features/theme_preview/presentation/pages/riotz_theme_preview_page.dart';
|
||||
import '../supabase/supabase_providers.dart';
|
||||
import 'app_routes.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
final authStateStream = client.auth.onAuthStateChange;
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: AppRoutes.splash,
|
||||
refreshListenable: GoRouterRefreshStream(authStateStream),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.splash,
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.login,
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.signup,
|
||||
builder: (context, state) => const SignupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.forgotPassword,
|
||||
builder: (context, state) => const ForgotPasswordScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.home,
|
||||
builder: (context, state) => const FeedScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.uploadPost,
|
||||
builder: (context, state) => const UploadPostScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.profile,
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.music,
|
||||
builder: (context, state) => const MusicPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.discover,
|
||||
builder: (context, state) => const DiscoverPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.admin,
|
||||
builder: (context, state) => const AdminScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.themePreview,
|
||||
builder: (context, state) => const RiotzThemePreviewPage(),
|
||||
),
|
||||
],
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn = client.auth.currentUser != null;
|
||||
final isSplash = state.matchedLocation == AppRoutes.splash;
|
||||
final isAuthRoute = state.matchedLocation == AppRoutes.login ||
|
||||
state.matchedLocation == AppRoutes.signup ||
|
||||
state.matchedLocation == AppRoutes.forgotPassword;
|
||||
|
||||
if (!isLoggedIn && !isSplash && !isAuthRoute) {
|
||||
return AppRoutes.login;
|
||||
}
|
||||
|
||||
if (isLoggedIn && (isSplash || isAuthRoute)) {
|
||||
return AppRoutes.home;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
class GoRouterRefreshStream extends ChangeNotifier {
|
||||
GoRouterRefreshStream(Stream<AuthState> stream) {
|
||||
_subscription = stream.asBroadcastStream().listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
late final StreamSubscription<AuthState> _subscription;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
15
lib/core/router/app_routes.dart
Normal file
15
lib/core/router/app_routes.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
class AppRoutes {
|
||||
const AppRoutes._();
|
||||
|
||||
static const splash = '/';
|
||||
static const login = '/auth/login';
|
||||
static const signup = '/auth/signup';
|
||||
static const forgotPassword = '/auth/forgot-password';
|
||||
static const home = '/home';
|
||||
static const uploadPost = '/upload-post';
|
||||
static const profile = '/profile';
|
||||
static const music = '/music';
|
||||
static const discover = '/discover';
|
||||
static const admin = '/admin';
|
||||
static const themePreview = '/theme-preview';
|
||||
}
|
||||
27
lib/core/services/storage_service.dart
Normal file
27
lib/core/services/storage_service.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final storageServiceProvider = Provider<StorageService>((ref) {
|
||||
return const StorageService(FlutterSecureStorage());
|
||||
});
|
||||
|
||||
class StorageService {
|
||||
const StorageService(this._storage);
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
Future<void> write(String key, String value) async {
|
||||
await _storage.write(key: key, value: value);
|
||||
}
|
||||
|
||||
Future<String?> read(String key) async {
|
||||
return await _storage.read(key: key);
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async {
|
||||
await _storage.delete(key: key);
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
}
|
||||
6
lib/core/supabase/supabase_providers.dart
Normal file
6
lib/core/supabase/supabase_providers.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
final supabaseProvider = Provider<SupabaseClient>(
|
||||
(ref) => Supabase.instance.client,
|
||||
);
|
||||
96
lib/core/theme/app_animations.dart
Normal file
96
lib/core/theme/app_animations.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppAnimations {
|
||||
const AppAnimations._();
|
||||
|
||||
/// A standard fade transition
|
||||
static Widget fade({
|
||||
required Widget child,
|
||||
Duration duration = const Duration(milliseconds: 300),
|
||||
}) {
|
||||
return AnimatedSwitcher(
|
||||
duration: duration,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// A slide transition from the bottom (Brutalist style)
|
||||
static Widget slideIn({
|
||||
required Widget child,
|
||||
Offset begin = const Offset(0, 0.1),
|
||||
Duration duration = const Duration(milliseconds: 400),
|
||||
}) {
|
||||
return TweenAnimationBuilder<Offset>(
|
||||
tween: Tween<Offset>(begin: begin, end: Offset.zero),
|
||||
duration: duration,
|
||||
curve: Curves.easeOutQuart,
|
||||
builder: (context, offset, child) {
|
||||
return FractionalTranslation(
|
||||
translation: offset,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// A simple "Glitch" effect using staggered offsets and opacities
|
||||
/// This simulates an underground/grunge signal interference.
|
||||
static Widget glitch({required Widget child}) {
|
||||
return _GlitchWidget(child: child);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlitchWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _GlitchWidget({required this.child});
|
||||
|
||||
@override
|
||||
State<_GlitchWidget> createState() => _GlitchWidgetState();
|
||||
}
|
||||
|
||||
class _GlitchWidgetState extends State<_GlitchWidget> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final double glitchFactor = _controller.value;
|
||||
// Only glitch occasionally
|
||||
if (glitchFactor > 0.9) {
|
||||
return Stack(
|
||||
children: [
|
||||
Transform.translate(
|
||||
offset: const Offset(2, 0),
|
||||
child: Opacity(opacity: 0.5, child: widget.child),
|
||||
),
|
||||
Transform.translate(
|
||||
offset: const Offset(-2, 1),
|
||||
child: Opacity(opacity: 0.5, child: widget.child),
|
||||
),
|
||||
widget.child,
|
||||
],
|
||||
);
|
||||
}
|
||||
return widget.child;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/core/theme/app_colors.dart
Normal file
35
lib/core/theme/app_colors.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
const AppColors._();
|
||||
|
||||
// Core Palette
|
||||
static const black = Color(0xFF000000); // Pitch black
|
||||
static const blackRaised = Color(0xFF1A1A1A); // Slightly raised black
|
||||
static const blackSoft = Color(0xFF0F0F0F); // Soft black for backgrounds
|
||||
static const darkGrey = Color(0xFF0A0A0A);
|
||||
static const white = Color(0xFFFFFFFF);
|
||||
static const offWhite = Color(0xFFEBEBEB); // For body text readability
|
||||
|
||||
// RIOTZ Red Tones (Aggressive & Premium)
|
||||
static const neonRed = Color(0xFFFF0033); // Primary accent
|
||||
static const bloodRed = Color(0xFF8B0000); // Secondary
|
||||
static const deepRed = Color(0xFF4A0000); // Muted backgrounds
|
||||
|
||||
// Purple Accents
|
||||
static const neonPurple = Color(0xFF9D00FF); // Neon purple accent
|
||||
|
||||
// Surfaces & Borders
|
||||
static const surface = Color(0xFF0D0D0D); // Elevated surfaces
|
||||
static const surfaceLight = Color(0xFF1A1A1A);
|
||||
static const border = Color(0xFF262626); // Brutalist outlines
|
||||
|
||||
// Neutral / Muted
|
||||
static const grey = Color(0xFF757575);
|
||||
static const greyDark = Color(0xFF424242);
|
||||
static const greyMuted = Color(0xFF212121);
|
||||
|
||||
// Semantic
|
||||
static const error = Color(0xFFFF0033);
|
||||
static const success = Color(0xFF00FF66); // Acid green for contrast
|
||||
}
|
||||
12
lib/core/theme/app_motion.dart
Normal file
12
lib/core/theme/app_motion.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppMotion {
|
||||
const AppMotion._();
|
||||
|
||||
static const fast = Duration(milliseconds: 120);
|
||||
static const normal = Duration(milliseconds: 220);
|
||||
static const slow = Duration(milliseconds: 360);
|
||||
|
||||
static const standardCurve = Curves.easeOutCubic;
|
||||
static const emphasizedCurve = Curves.easeOutQuart;
|
||||
}
|
||||
131
lib/core/theme/app_theme.dart
Normal file
131
lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
import 'app_typography.dart';
|
||||
import 'app_motion.dart';
|
||||
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
static ThemeData get dark {
|
||||
final textTheme = AppTypography.darkTextTheme();
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: AppColors.black,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: AppColors.neonRed,
|
||||
secondary: AppColors.bloodRed,
|
||||
surface: AppColors.surface,
|
||||
onPrimary: AppColors.white,
|
||||
onSecondary: AppColors.white,
|
||||
onSurface: AppColors.white,
|
||||
error: AppColors.error,
|
||||
outline: AppColors.border,
|
||||
),
|
||||
textTheme: textTheme,
|
||||
|
||||
// App Bar Theme - Centralized & Brutalist
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.black,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
iconTheme: const IconThemeData(color: AppColors.white, size: 20),
|
||||
titleTextStyle: textTheme.headlineMedium?.copyWith(
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
|
||||
// Card Theme - Sharp Edges, Subtle Borders
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
side: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
),
|
||||
|
||||
// Input Decoration - Industrial / Terminal style
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
hintStyle: textTheme.bodySmall?.copyWith(color: AppColors.greyDark),
|
||||
labelStyle: textTheme.bodyMedium?.copyWith(color: AppColors.grey),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: AppColors.neonRed, width: 1.5),
|
||||
),
|
||||
errorBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
borderSide: BorderSide(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
|
||||
// Button Themes
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.neonRed,
|
||||
foregroundColor: AppColors.white,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
textStyle: textTheme.labelLarge,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
),
|
||||
),
|
||||
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.white,
|
||||
side: const BorderSide(color: AppColors.white, width: 1.5),
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
textStyle: textTheme.labelLarge,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
),
|
||||
),
|
||||
|
||||
// Navigation Bar - Custom Riotz Feel
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: AppColors.black,
|
||||
indicatorColor: AppColors.neonRed.withOpacity(0.1),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return textTheme.bodySmall?.copyWith(color: AppColors.neonRed, fontWeight: FontWeight.bold);
|
||||
}
|
||||
return textTheme.bodySmall?.copyWith(color: AppColors.grey);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const IconThemeData(color: AppColors.neonRed, size: 24);
|
||||
}
|
||||
return const IconThemeData(color: AppColors.grey, size: 24);
|
||||
}),
|
||||
),
|
||||
|
||||
// Dialog Theme
|
||||
dialogTheme: const DialogThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
side: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
),
|
||||
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.border,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
91
lib/core/theme/app_typography.dart
Normal file
91
lib/core/theme/app_typography.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTypography {
|
||||
const AppTypography._();
|
||||
|
||||
// Primary heading font: Aggressive, industrial, and bold
|
||||
static String get headingFont => GoogleFonts.bebasNeue().fontFamily!;
|
||||
|
||||
// Body font: Monospace or clean Sans for that "terminal/underground" feel
|
||||
static String get bodyFont => GoogleFonts.inter().fontFamily!;
|
||||
static String get monoFont => GoogleFonts.jetBrainsMono().fontFamily!;
|
||||
|
||||
static TextTheme darkTextTheme() {
|
||||
return TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
fontFamily: headingFont,
|
||||
fontSize: 72,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.white,
|
||||
letterSpacing: -1.0,
|
||||
height: 0.9,
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontFamily: headingFont,
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.white,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.0,
|
||||
),
|
||||
headlineLarge: TextStyle(
|
||||
fontFamily: headingFont,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.neonRed,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontFamily: headingFont,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.white,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.white,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.white,
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: AppColors.offWhite,
|
||||
height: 1.5,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontFamily: bodyFont,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: AppColors.offWhite,
|
||||
height: 1.4,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
fontFamily: monoFont,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.grey,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: headingFont,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.white,
|
||||
letterSpacing: 2.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/core/widgets/riotz_button.dart
Normal file
76
lib/core/widgets/riotz_button.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
enum RiotzButtonStyle { primary, secondary, outline }
|
||||
|
||||
class RiotzButton extends StatelessWidget {
|
||||
const RiotzButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.style = RiotzButtonStyle.primary,
|
||||
this.isLoading = false,
|
||||
this.fullWidth = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final RiotzButtonStyle style;
|
||||
final bool isLoading;
|
||||
final bool fullWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
Widget content = isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.white),
|
||||
)
|
||||
: Text(label.toUpperCase(), style: theme.textTheme.labelLarge);
|
||||
|
||||
if (fullWidth) {
|
||||
content = Center(child: content);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: isLoading ? null : onPressed,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: _getBgColor(),
|
||||
border: Border.all(
|
||||
color: _getBorderColor(),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getBgColor() {
|
||||
switch (style) {
|
||||
case RiotzButtonStyle.primary:
|
||||
return AppColors.neonRed;
|
||||
case RiotzButtonStyle.secondary:
|
||||
return AppColors.bloodRed;
|
||||
case RiotzButtonStyle.outline:
|
||||
return Colors.transparent;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getBorderColor() {
|
||||
switch (style) {
|
||||
case RiotzButtonStyle.primary:
|
||||
return AppColors.neonRed;
|
||||
case RiotzButtonStyle.secondary:
|
||||
return AppColors.bloodRed;
|
||||
case RiotzButtonStyle.outline:
|
||||
return AppColors.white;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
lib/core/widgets/riotz_card.dart
Normal file
35
lib/core/widgets/riotz_card.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class RiotzCard extends StatelessWidget {
|
||||
const RiotzCard({
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.onTap,
|
||||
this.isAccent = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final VoidCallback? onTap;
|
||||
final bool isAccent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(
|
||||
color: isAccent ? AppColors.neonRed : AppColors.border,
|
||||
width: isAccent ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/core/widgets/riotz_scaffold.dart
Normal file
68
lib/core/widgets/riotz_scaffold.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class RiotzScaffold extends StatelessWidget {
|
||||
const RiotzScaffold({
|
||||
required this.body,
|
||||
this.appBar,
|
||||
this.bottomNavigationBar,
|
||||
this.floatingActionButton,
|
||||
this.useSafeArea = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget body;
|
||||
final PreferredSizeWidget? appBar;
|
||||
final Widget? bottomNavigationBar;
|
||||
final Widget? floatingActionButton;
|
||||
final bool useSafeArea;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Base Layer: Deep Black
|
||||
Container(color: AppColors.black),
|
||||
|
||||
// Aesthetic Layer: Subtle Brutalist Gradient
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.black,
|
||||
AppColors.deepRed.withOpacity(0.05),
|
||||
AppColors.black,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Texture Layer: Grunge Overlay
|
||||
// Note: Add 'assets/textures/noise.png' to pubspec and uncomment below
|
||||
/*
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.03,
|
||||
child: Image.asset(
|
||||
'assets/textures/noise.png',
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
),
|
||||
*/
|
||||
|
||||
// Content Layer
|
||||
useSafeArea ? SafeArea(child: body) : body,
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
floatingActionButton: floatingActionButton,
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/features/admin/data/services/admin_service.dart
Normal file
112
lib/features/admin/data/services/admin_service.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import '../../domain/models/admin_panel_data_model.dart';
|
||||
import '../../domain/models/admin_post_model.dart';
|
||||
import '../../domain/models/admin_user_model.dart';
|
||||
|
||||
class AdminService {
|
||||
const AdminService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<AdminPanelDataModel> fetchPanelData() async {
|
||||
// Fetch all profiles
|
||||
final profileRows = List<Map<String, dynamic>>.from(
|
||||
await _client.from('profiles').select('user_id, username, avatar_url, banned, featured'),
|
||||
);
|
||||
|
||||
// Fetch latest posts
|
||||
final postRows = List<Map<String, dynamic>>.from(
|
||||
await _client
|
||||
.from('posts')
|
||||
.select('id, user_id, caption, image_url, likes_count, featured')
|
||||
.order('created_at', ascending: false)
|
||||
.limit(100),
|
||||
);
|
||||
|
||||
// Fetch tracks
|
||||
final trackRows = List<Map<String, dynamic>>.from(
|
||||
await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.order('created_at', ascending: false)
|
||||
.limit(100),
|
||||
);
|
||||
|
||||
final userMap = <String, AdminUserModel>{};
|
||||
for (final row in profileRows) {
|
||||
final user = AdminUserModel(
|
||||
userId: row['user_id'] as String,
|
||||
username: (row['username'] as String?) ?? 'RIOTER',
|
||||
avatarUrl: (row['avatar_url'] as String?) ?? '',
|
||||
banned: (row['banned'] as bool?) ?? false,
|
||||
featured: (row['featured'] as bool?) ?? false,
|
||||
);
|
||||
userMap[user.userId] = user;
|
||||
}
|
||||
|
||||
final users = userMap.values.toList()
|
||||
..sort((a, b) => a.username.toLowerCase().compareTo(b.username.toLowerCase()));
|
||||
|
||||
final posts = postRows.map((row) {
|
||||
final user = userMap[row['user_id']] ??
|
||||
AdminUserModel(
|
||||
userId: row['user_id'] as String,
|
||||
username: 'RIOTER',
|
||||
avatarUrl: '',
|
||||
banned: false,
|
||||
featured: false,
|
||||
);
|
||||
return AdminPostModel(
|
||||
id: row['id'] as String,
|
||||
userId: row['user_id'] as String,
|
||||
username: user.username,
|
||||
imageUrl: (row['image_url'] as String?) ?? '',
|
||||
caption: (row['caption'] as String?) ?? '',
|
||||
likesCount: (row['likes_count'] as int?) ?? 0,
|
||||
featured: (row['featured'] as bool?) ?? false,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final tracks = trackRows.map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
|
||||
return AdminPanelDataModel(users: users, posts: posts, tracks: tracks);
|
||||
}
|
||||
|
||||
Future<void> deletePost(String postId) async {
|
||||
await _client.from('posts').delete().eq('id', postId);
|
||||
}
|
||||
|
||||
Future<void> setUserBanned({
|
||||
required String userId,
|
||||
required bool banned,
|
||||
}) async {
|
||||
await _client.from('profiles').update({'banned': banned}).eq('user_id', userId);
|
||||
}
|
||||
|
||||
Future<void> setUserFeatured({
|
||||
required String userId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
await _client.from('profiles').update({'featured': featured}).eq('user_id', userId);
|
||||
}
|
||||
|
||||
Future<void> setPostFeatured({
|
||||
required String postId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
await _client.from('posts').update({'featured': featured}).eq('id', postId);
|
||||
}
|
||||
|
||||
Future<void> setTrackFeatured({
|
||||
required String trackId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
// Assuming tracks table has a 'featured' column
|
||||
await _client.from('tracks').update({'featured': featured}).eq('id', trackId);
|
||||
}
|
||||
}
|
||||
15
lib/features/admin/domain/models/admin_panel_data_model.dart
Normal file
15
lib/features/admin/domain/models/admin_panel_data_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import 'admin_post_model.dart';
|
||||
import 'admin_user_model.dart';
|
||||
|
||||
class AdminPanelDataModel {
|
||||
const AdminPanelDataModel({
|
||||
required this.users,
|
||||
required this.posts,
|
||||
required this.tracks,
|
||||
});
|
||||
|
||||
final List<AdminUserModel> users;
|
||||
final List<AdminPostModel> posts;
|
||||
final List<TrackModel> tracks;
|
||||
}
|
||||
19
lib/features/admin/domain/models/admin_post_model.dart
Normal file
19
lib/features/admin/domain/models/admin_post_model.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
class AdminPostModel {
|
||||
const AdminPostModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.imageUrl,
|
||||
required this.caption,
|
||||
required this.likesCount,
|
||||
required this.featured,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
final String username;
|
||||
final String imageUrl;
|
||||
final String caption;
|
||||
final int likesCount;
|
||||
final bool featured;
|
||||
}
|
||||
15
lib/features/admin/domain/models/admin_user_model.dart
Normal file
15
lib/features/admin/domain/models/admin_user_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
class AdminUserModel {
|
||||
const AdminUserModel({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.avatarUrl,
|
||||
required this.banned,
|
||||
required this.featured,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String avatarUrl;
|
||||
final bool banned;
|
||||
final bool featured;
|
||||
}
|
||||
189
lib/features/admin/presentation/pages/admin_page.dart
Normal file
189
lib/features/admin/presentation/pages/admin_page.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../shared/widgets/riotz_shell.dart';
|
||||
import '../../domain/models/admin_post_model.dart';
|
||||
import '../../domain/models/admin_user_model.dart';
|
||||
import '../providers/admin_providers.dart';
|
||||
|
||||
class AdminPage extends ConsumerWidget {
|
||||
const AdminPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAdmin = ref.watch(isAdminProvider);
|
||||
final dataAsync = ref.watch(adminPanelDataProvider);
|
||||
|
||||
ref.listen(adminControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Admin action applied')),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error.toString())),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
return const RiotzShell(
|
||||
title: 'RIOTZ // Admin',
|
||||
child: Center(
|
||||
child: Text('You are not authorized for admin panel access.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RiotzShell(
|
||||
title: 'RIOTZ // Admin',
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(adminPanelDataProvider),
|
||||
child: dataAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('Error: $error')),
|
||||
data: (data) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'Whitelist-based Admin Panel',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Users',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...data.users.map(
|
||||
(user) => _UserModerationCard(
|
||||
user: user,
|
||||
onToggleBan: (banned) => ref
|
||||
.read(adminControllerProvider.notifier)
|
||||
.setUserBanned(userId: user.userId, banned: banned),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
'Posts',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...data.posts.map(
|
||||
(post) => _PostModerationCard(
|
||||
post: post,
|
||||
onDelete: () =>
|
||||
ref.read(adminControllerProvider.notifier).deletePost(post.id),
|
||||
onToggleFeatured: (featured) => ref
|
||||
.read(adminControllerProvider.notifier)
|
||||
.setPostFeatured(postId: post.id, featured: featured),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UserModerationCard extends StatelessWidget {
|
||||
const _UserModerationCard({
|
||||
required this.user,
|
||||
required this.onToggleBan,
|
||||
});
|
||||
|
||||
final AdminUserModel user;
|
||||
final ValueChanged<bool> onToggleBan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: SwitchListTile(
|
||||
value: user.banned,
|
||||
onChanged: onToggleBan,
|
||||
title: Text(user.username),
|
||||
subtitle: Text(user.userId),
|
||||
secondary: CircleAvatar(
|
||||
backgroundImage:
|
||||
user.avatarUrl.isNotEmpty ? NetworkImage(user.avatarUrl) : null,
|
||||
child: user.avatarUrl.isEmpty ? const Icon(Icons.person) : null,
|
||||
),
|
||||
activeThumbColor: Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostModerationCard extends StatelessWidget {
|
||||
const _PostModerationCard({
|
||||
required this.post,
|
||||
required this.onDelete,
|
||||
required this.onToggleFeatured,
|
||||
});
|
||||
|
||||
final AdminPostModel post;
|
||||
final VoidCallback onDelete;
|
||||
final ValueChanged<bool> onToggleFeatured;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
post.username,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (post.caption.isNotEmpty) ...[
|
||||
Text(post.caption),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (post.imageUrl.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.network(
|
||||
post.imageUrl,
|
||||
width: double.infinity,
|
||||
height: 160,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text('${post.likesCount} likes'),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Feature'),
|
||||
Switch(
|
||||
value: post.featured,
|
||||
onChanged: onToggleFeatured,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/config/admin_whitelist.dart';
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../../auth/presentation/providers/auth_provider.dart';
|
||||
import '../../data/services/admin_service.dart';
|
||||
import '../../domain/models/admin_panel_data_model.dart';
|
||||
|
||||
final adminServiceProvider = Provider<AdminService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return AdminService(client);
|
||||
});
|
||||
|
||||
final isAdminProvider = Provider<bool>((ref) {
|
||||
final user = ref.watch(authStateProvider).value?.session?.user;
|
||||
return AdminWhitelist.isAdmin(user?.email);
|
||||
});
|
||||
|
||||
final adminPanelDataProvider = FutureProvider<AdminPanelDataModel>((ref) async {
|
||||
final isAdmin = ref.watch(isAdminProvider);
|
||||
if (!isAdmin) {
|
||||
throw StateError('UNAUTHORIZED ACCESS DETECTED.');
|
||||
}
|
||||
return ref.watch(adminServiceProvider).fetchPanelData();
|
||||
});
|
||||
|
||||
final adminControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AdminController, void>(
|
||||
AdminController.new,
|
||||
);
|
||||
|
||||
class AdminController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> deletePost(String postId) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).deletePost(postId);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setUserBanned({
|
||||
required String userId,
|
||||
required bool banned,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).setUserBanned(
|
||||
userId: userId,
|
||||
banned: banned,
|
||||
);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setUserFeatured({
|
||||
required String userId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).setUserFeatured(
|
||||
userId: userId,
|
||||
featured: featured,
|
||||
);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setPostFeatured({
|
||||
required String postId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).setPostFeatured(
|
||||
postId: postId,
|
||||
featured: featured,
|
||||
);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setTrackFeatured({
|
||||
required String trackId,
|
||||
required bool featured,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(adminServiceProvider).setTrackFeatured(
|
||||
trackId: trackId,
|
||||
featured: featured,
|
||||
);
|
||||
ref.invalidate(adminPanelDataProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
244
lib/features/admin/presentation/screens/admin_screen.dart
Normal file
244
lib/features/admin/presentation/screens/admin_screen.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import '../providers/admin_providers.dart';
|
||||
import '../../domain/models/admin_user_model.dart';
|
||||
import '../../domain/models/admin_post_model.dart';
|
||||
|
||||
class AdminScreen extends ConsumerWidget {
|
||||
const AdminScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final dataAsync = ref.watch(adminPanelDataProvider);
|
||||
|
||||
ref.listen(adminControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('SYSTEM OVERRIDE SUCCESSFUL.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text('ERROR: ${error.toString().toUpperCase()}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // TERMINAL'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: dataAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('TERMINAL OFFLINE: $error')),
|
||||
data: (data) => RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(adminPanelDataProvider),
|
||||
color: AppColors.neonRed,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
_SectionHeader(title: 'USER MODERATION', count: data.users.length),
|
||||
const SizedBox(height: 16),
|
||||
...data.users.map((user) => _AdminUserCard(user: user)),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
_SectionHeader(title: 'TRACK MODERATION', count: data.tracks.length),
|
||||
const SizedBox(height: 16),
|
||||
...data.tracks.map((track) => _AdminTrackCard(track: track)),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
_SectionHeader(title: 'POST MODERATION', count: data.posts.length),
|
||||
const SizedBox(height: 16),
|
||||
...data.posts.map((post) => _AdminPostCard(post: post)),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.count});
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 2, color: AppColors.neonRed)),
|
||||
const Divider(color: AppColors.neonRed, thickness: 2),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminUserCard extends ConsumerWidget {
|
||||
const _AdminUserCard({required this.user});
|
||||
final AdminUserModel user;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: user.banned ? AppColors.bloodRed : AppColors.border),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.surfaceLight,
|
||||
backgroundImage: user.avatarUrl.isNotEmpty ? NetworkImage(user.avatarUrl) : null,
|
||||
child: user.avatarUrl.isEmpty ? const Icon(Icons.person, color: AppColors.grey) : null,
|
||||
),
|
||||
title: Text(user.username.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
subtitle: Text(user.banned ? 'STATUS: BANNED' : (user.featured ? 'STATUS: FEATURED ARTIST' : 'STATUS: ACTIVE'),
|
||||
style: TextStyle(color: user.banned ? AppColors.neonRed : (user.featured ? AppColors.success : AppColors.grey), fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
trailing: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: AppColors.white),
|
||||
color: AppColors.surfaceLight,
|
||||
onSelected: (value) {
|
||||
if (value == 'ban') {
|
||||
ref.read(adminControllerProvider.notifier).setUserBanned(userId: user.userId, banned: !user.banned);
|
||||
} else if (value == 'feature') {
|
||||
ref.read(adminControllerProvider.notifier).setUserFeatured(userId: user.userId, featured: !user.featured);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'ban',
|
||||
child: Text(user.banned ? 'UNBAN USER' : 'BAN USER', style: const TextStyle(color: AppColors.neonRed)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'feature',
|
||||
child: Text(user.featured ? 'UNFEATURE ARTIST' : 'FEATURE ARTIST'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminTrackCard extends ConsumerWidget {
|
||||
const _AdminTrackCard({required this.track});
|
||||
final TrackModel track;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: track.featured ? AppColors.success : AppColors.border),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.music_note, color: track.featured ? AppColors.success : AppColors.white),
|
||||
title: Text(track.title.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
subtitle: Text('BY: ${track.username.toUpperCase()}', style: const TextStyle(fontSize: 10, color: AppColors.grey)),
|
||||
trailing: Switch(
|
||||
value: track.featured,
|
||||
activeColor: AppColors.success,
|
||||
onChanged: (val) {
|
||||
ref.read(adminControllerProvider.notifier).setTrackFeatured(trackId: track.id, featured: val);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminPostCard extends ConsumerWidget {
|
||||
const _AdminPostCard({required this.post});
|
||||
final AdminPostModel post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: post.featured ? AppColors.neonRed : AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(post.username.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
subtitle: Text('ID: ${post.id.substring(0, 8)}...', style: const TextStyle(fontSize: 10)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_forever, color: AppColors.neonRed),
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
),
|
||||
),
|
||||
if (post.imageUrl.isNotEmpty)
|
||||
Image.network(post.imageUrl, height: 120, width: double.infinity, fit: BoxFit.cover),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('FEATURE CONTENT', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: post.featured,
|
||||
activeColor: AppColors.neonRed,
|
||||
onChanged: (val) {
|
||||
ref.read(adminControllerProvider.notifier).setPostFeatured(postId: post.id, featured: val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
title: const Text('ERASE DATA?'),
|
||||
content: const Text('THIS WILL REMOVE THE CONTENT FROM THE NETWORK PERMANENTLY.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCEL')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(adminControllerProvider.notifier).deletePost(post.id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('DELETE', style: TextStyle(color: AppColors.neonRed)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/features/auth/data/services/auth_service.dart
Normal file
38
lib/features/auth/data/services/auth_service.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class AuthService {
|
||||
const AuthService(this._client);
|
||||
final SupabaseClient _client;
|
||||
|
||||
User? get currentUser => _client.auth.currentUser;
|
||||
Session? get currentSession => _client.auth.currentSession;
|
||||
Stream<AuthState> get onAuthStateChange => _client.auth.onAuthStateChange;
|
||||
|
||||
Future<AuthResponse> signUp({
|
||||
required String email,
|
||||
required String password,
|
||||
required String username,
|
||||
}) async {
|
||||
return await _client.auth.signUp(
|
||||
email: email,
|
||||
password: password,
|
||||
data: {'username': username},
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuthResponse> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
return await _client.auth.signInWithPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async => await _client.auth.signOut();
|
||||
|
||||
Future<void> forgotPassword(String email) async {
|
||||
await _client.auth.resetPasswordForEmail(email);
|
||||
}
|
||||
}
|
||||
126
lib/features/auth/presentation/pages/forgot_password_page.dart
Normal file
126
lib/features/auth/presentation/pages/forgot_password_page.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/auth_providers.dart';
|
||||
|
||||
class ForgotPasswordPage extends ConsumerStatefulWidget {
|
||||
const ForgotPasswordPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text(
|
||||
'RECOVERY SIGNAL SENT. CHECK YOUR INBOX.',
|
||||
style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RECOVER IDENTITY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LOST SIGNAL',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ENTER YOUR EMAIL TO RESTORE ACCESS.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'SEND RECOVERY LINK',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.forgotPassword(_emailController.text);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.login),
|
||||
child: Text(
|
||||
'REMEMBERED? BACK TO LOGIN',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/features/auth/presentation/pages/login_page.dart
Normal file
142
lib/features/auth/presentation/pages/login_page.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../providers/auth_providers.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
if (mounted) context.go(AppRoutes.home);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('IDENTIFY YOURSELF'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.splash),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LOGIN',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ACCESS THE UNDERGROUND.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'PASSWORD',
|
||||
prefixIcon: Icon(Icons.lock_outline, color: AppColors.grey),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.length < 6) ? 'PASSWORD TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'ACCESS GRANTED',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref.read(authControllerProvider.notifier).login(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading
|
||||
? null
|
||||
: () => context.push(AppRoutes.forgotPassword),
|
||||
child: Text(
|
||||
'FORGOT CREDENTIALS?',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.signup),
|
||||
child: Text(
|
||||
'NO IDENTITY? SIGN UP',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/features/auth/presentation/pages/signup_page.dart
Normal file
153
lib/features/auth/presentation/pages/signup_page.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../providers/auth_providers.dart';
|
||||
|
||||
class SignupPage extends ConsumerStatefulWidget {
|
||||
const SignupPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SignupPage> createState() => _SignupPageState();
|
||||
}
|
||||
|
||||
class _SignupPageState extends ConsumerState<SignupPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text(
|
||||
'IDENTITY CREATED. CHECK EMAIL FOR CONFIRMATION.',
|
||||
style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('CREATE IDENTITY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'SIGN UP',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'JOIN THE UNDERGROUND MOVEMENT.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'USERNAME',
|
||||
prefixIcon: Icon(Icons.person_outline, color: AppColors.grey),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().length < 3) ? 'USERNAME TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'PASSWORD',
|
||||
prefixIcon: Icon(Icons.lock_outline, color: AppColors.grey),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.length < 6) ? 'PASSWORD TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'INITIALIZE RIOT',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref.read(authControllerProvider.notifier).signup(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
username: _usernameController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.login),
|
||||
child: Text(
|
||||
'ALREADY HAVE AN IDENTITY? LOGIN',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
65
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/auth_service.dart';
|
||||
|
||||
/// Provider for the AuthService.
|
||||
final authServiceProvider = Provider<AuthService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return AuthService(client);
|
||||
});
|
||||
|
||||
/// Provider for the current user's authentication state.
|
||||
final authStateProvider = StreamProvider<AuthState>((ref) {
|
||||
return ref.watch(authServiceProvider).onAuthStateChange;
|
||||
});
|
||||
|
||||
/// Controller for authentication actions.
|
||||
final authControllerProvider = AutoDisposeAsyncNotifierProvider<AuthController, void>(
|
||||
AuthController.new,
|
||||
);
|
||||
|
||||
class AuthController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> login({required String email, required String password}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).login(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> signup({
|
||||
required String email,
|
||||
required String password,
|
||||
required String username,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).signUp(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
username: username.trim(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).logout();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> forgotPassword(String email) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).forgotPassword(email.trim());
|
||||
});
|
||||
}
|
||||
}
|
||||
80
lib/features/auth/presentation/providers/auth_providers.dart
Normal file
80
lib/features/auth/presentation/providers/auth_providers.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/auth_service.dart';
|
||||
|
||||
final authServiceProvider = Provider<AuthService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return AuthService(client);
|
||||
});
|
||||
|
||||
final authStateChangesProvider = StreamProvider<AuthState>((ref) {
|
||||
final service = ref.watch(authServiceProvider);
|
||||
return service.authStateChanges();
|
||||
});
|
||||
|
||||
final currentSessionProvider = StreamProvider<Session?>((ref) async* {
|
||||
final service = ref.watch(authServiceProvider);
|
||||
yield service.currentSession;
|
||||
await for (final event in service.authStateChanges()) {
|
||||
yield event.session;
|
||||
}
|
||||
});
|
||||
|
||||
final currentUserProvider = Provider<User?>((ref) {
|
||||
final service = ref.watch(authServiceProvider);
|
||||
return service.currentUser;
|
||||
});
|
||||
|
||||
final authControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AuthController, void>(
|
||||
AuthController.new,
|
||||
);
|
||||
|
||||
class AuthController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> signup({
|
||||
required String email,
|
||||
required String password,
|
||||
required String username,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).signUp(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
username: username.trim(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).login(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).logout();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> forgotPassword(String email) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(authServiceProvider).forgotPassword(email.trim());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class ForgotPasswordScreen extends ConsumerStatefulWidget {
|
||||
const ForgotPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text(
|
||||
'RECOVERY SIGNAL SENT. CHECK YOUR INBOX.',
|
||||
style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RECOVER IDENTITY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LOST SIGNAL',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ENTER YOUR EMAIL TO RESTORE ACCESS.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'SEND RECOVERY LINK',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.forgotPassword(_emailController.text);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.login),
|
||||
child: Text(
|
||||
'REMEMBERED? BACK TO LOGIN',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
149
lib/features/auth/presentation/screens/login_screen.dart
Normal file
149
lib/features/auth/presentation/screens/login_screen.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: AppColors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('IDENTIFY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.splash),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'LOGIN',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ENTER THE VOID.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppColors.neonRed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'PASSWORD',
|
||||
prefixIcon: Icon(Icons.lock_outline, color: AppColors.grey),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) => (v == null || v.length < 6)
|
||||
? 'PASSWORD TOO SHORT'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'ACCESS GRANTED',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref.read(authControllerProvider.notifier).login(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading
|
||||
? null
|
||||
: () => context.push(AppRoutes.forgotPassword),
|
||||
child: Text(
|
||||
'FORGOT CREDENTIALS?',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppColors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.signup),
|
||||
child: Text(
|
||||
'NO IDENTITY? SIGN UP',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/features/auth/presentation/screens/signup_screen.dart
Normal file
153
lib/features/auth/presentation/screens/signup_screen.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class SignupScreen extends ConsumerStatefulWidget {
|
||||
const SignupScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends ConsumerState<SignupScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(authControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text(
|
||||
'IDENTITY INITIALIZED. CHECK EMAIL FOR CONFIRMATION.',
|
||||
style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(
|
||||
error.toString().toUpperCase(),
|
||||
style: const TextStyle(color: AppColors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final loading = authState.isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('CREATE IDENTITY'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'SIGN UP',
|
||||
style: theme.textTheme.displayMedium?.copyWith(height: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'JOIN THE RIOT.',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'USERNAME',
|
||||
prefixIcon: Icon(Icons.person_outline, color: AppColors.grey),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().length < 3) ? 'USERNAME TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EMAIL ADDRESS',
|
||||
prefixIcon: Icon(Icons.email_outlined, color: AppColors.grey),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (v) => (v == null || !v.contains('@'))
|
||||
? 'INVALID EMAIL'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'PASSWORD',
|
||||
prefixIcon: Icon(Icons.lock_outline, color: AppColors.grey),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.length < 6) ? 'PASSWORD TOO SHORT' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'INITIALIZE',
|
||||
isLoading: loading,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await ref.read(authControllerProvider.notifier).signup(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
username: _usernameController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: loading ? null : () => context.go(AppRoutes.login),
|
||||
child: Text(
|
||||
'ALREADY HAVE AN IDENTITY? LOGIN',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
101
lib/features/discover/data/services/discover_service.dart
Normal file
101
lib/features/discover/data/services/discover_service.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../../feed/domain/models/feed_post_model.dart';
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import '../../domain/models/discover_data_model.dart';
|
||||
import '../../domain/models/trending_user_model.dart';
|
||||
|
||||
class DiscoverService {
|
||||
const DiscoverService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<DiscoverDataModel> fetchDiscoverData() async {
|
||||
final users = await _fetchTrendingUsers();
|
||||
final posts = await _fetchTrendingPosts();
|
||||
final tracks = await _fetchTrendingTracks();
|
||||
|
||||
return DiscoverDataModel(
|
||||
trendingUsers: users,
|
||||
trendingPosts: posts,
|
||||
trendingTracks: tracks,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TrendingUserModel>> _fetchTrendingUsers() async {
|
||||
// Trending users based on total engagement (mocking for now by fetching profiles)
|
||||
final profiles = List<Map<String, dynamic>>.from(
|
||||
await _client
|
||||
.from('profiles')
|
||||
.select('user_id, username, avatar_url')
|
||||
.limit(20),
|
||||
);
|
||||
|
||||
final users = <TrendingUserModel>[];
|
||||
for (final profile in profiles) {
|
||||
final userId = profile['user_id'] as String;
|
||||
|
||||
final postsResponse = await _client
|
||||
.from('posts')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
final tracksResponse = await _client
|
||||
.from('tracks')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
users.add(
|
||||
TrendingUserModel(
|
||||
userId: userId,
|
||||
username: (profile['username'] as String?) ?? 'RIOT_USER',
|
||||
avatarUrl: (profile['avatar_url'] as String?) ?? '',
|
||||
postsCount: (postsResponse as List).length,
|
||||
tracksCount: (tracksResponse as List).length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by total content count
|
||||
users.sort((a, b) => (b.postsCount + b.tracksCount).compareTo(a.postsCount + a.tracksCount));
|
||||
return users.take(10).toList();
|
||||
}
|
||||
|
||||
Future<List<FeedPostModel>> _fetchTrendingPosts() async {
|
||||
final rows = List<Map<String, dynamic>>.from(
|
||||
await _client
|
||||
.from('posts')
|
||||
.select('id, user_id, caption, image_url, likes_count, created_at, profiles(username, avatar_url)')
|
||||
.order('likes_count', ascending: false)
|
||||
.limit(10),
|
||||
);
|
||||
|
||||
return rows.map((row) {
|
||||
final profile = (row['profiles'] as Map<String, dynamic>?) ?? {};
|
||||
return FeedPostModel(
|
||||
id: row['id'] as String,
|
||||
userId: row['user_id'] as String,
|
||||
caption: (row['caption'] as String?) ?? '',
|
||||
imageUrl: (row['image_url'] as String?) ?? '',
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
likesCount: (row['likes_count'] as int?) ?? 0,
|
||||
isLiked: false, // Default to false for discover
|
||||
username: (profile['username'] as String?) ?? 'RIOTER',
|
||||
avatarUrl: (profile['avatar_url'] as String?) ?? '',
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<List<TrackModel>> _fetchTrendingTracks() async {
|
||||
final rows = await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.order('plays', ascending: false)
|
||||
.limit(10);
|
||||
|
||||
return List<Map<String, dynamic>>.from(rows).map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
15
lib/features/discover/domain/models/discover_data_model.dart
Normal file
15
lib/features/discover/domain/models/discover_data_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import '../../../feed/domain/models/feed_post_model.dart';
|
||||
import '../../../music/domain/models/track_model.dart';
|
||||
import 'trending_user_model.dart';
|
||||
|
||||
class DiscoverDataModel {
|
||||
const DiscoverDataModel({
|
||||
required this.trendingUsers,
|
||||
required this.trendingPosts,
|
||||
required this.trendingTracks,
|
||||
});
|
||||
|
||||
final List<TrendingUserModel> trendingUsers;
|
||||
final List<FeedPostModel> trendingPosts;
|
||||
final List<TrackModel> trendingTracks;
|
||||
}
|
||||
15
lib/features/discover/domain/models/trending_user_model.dart
Normal file
15
lib/features/discover/domain/models/trending_user_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
class TrendingUserModel {
|
||||
const TrendingUserModel({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.avatarUrl,
|
||||
required this.postsCount,
|
||||
required this.tracksCount,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String avatarUrl;
|
||||
final int postsCount;
|
||||
final int tracksCount;
|
||||
}
|
||||
239
lib/features/discover/presentation/pages/discover_page.dart
Normal file
239
lib/features/discover/presentation/pages/discover_page.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../music/presentation/widgets/track_card.dart';
|
||||
import '../../domain/models/discover_data_model.dart';
|
||||
import '../../domain/models/trending_user_model.dart';
|
||||
import '../providers/discover_providers.dart';
|
||||
|
||||
class DiscoverPage extends ConsumerStatefulWidget {
|
||||
const DiscoverPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverPage> createState() => _DiscoverPageState();
|
||||
}
|
||||
|
||||
class _DiscoverPageState extends ConsumerState<DiscoverPage> {
|
||||
final _searchController = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final discoverAsync = ref.watch(discoverDataProvider);
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // DISCOVER'),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.neonRed,
|
||||
backgroundColor: AppColors.black,
|
||||
onRefresh: () async => ref.invalidate(discoverDataProvider),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
// Brutalist Search Bar
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search, color: AppColors.white),
|
||||
hintText: 'SEARCH THE VOID...',
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value.trim()),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
discoverAsync.when(
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
child: CircularProgressIndicator(color: AppColors.neonRed),
|
||||
),
|
||||
),
|
||||
error: (error, _) => Center(child: Text('DISCOVERY OFFLINE: $error')),
|
||||
data: (data) {
|
||||
final filteredUsers = _filterUsers(data);
|
||||
final filteredTracks = data.trendingTracks.where((track) {
|
||||
final q = _query.toLowerCase();
|
||||
return track.title.toLowerCase().contains(q) ||
|
||||
track.username.toLowerCase().contains(q) ||
|
||||
track.genreTag.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
final filteredPosts = data.trendingPosts.where((post) {
|
||||
final q = _query.toLowerCase();
|
||||
return post.caption.toLowerCase().contains(q) ||
|
||||
post.username.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Trending Artists
|
||||
if (filteredUsers.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'TRENDING AGENTS', count: filteredUsers.length),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: filteredUsers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = filteredUsers[index];
|
||||
return _TrendingUserCard(user: user);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
|
||||
// Trending Tracks
|
||||
if (filteredTracks.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'SONIC FREQUENCIES', count: filteredTracks.length),
|
||||
const SizedBox(height: 16),
|
||||
...filteredTracks.take(5).map((track) => TrackCard(track: track)),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
|
||||
// Popular Posts
|
||||
if (filteredPosts.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'VISUAL CHAOS', count: filteredPosts.length),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: filteredPosts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final post = filteredPosts[index];
|
||||
return _DiscoveryGridTile(post: post);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
if (filteredUsers.isEmpty && filteredTracks.isEmpty && filteredPosts.isEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
child: Text('THE VOID IS EMPTY.'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<TrendingUserModel> _filterUsers(DiscoverDataModel data) {
|
||||
final q = _query.toLowerCase();
|
||||
if (q.isEmpty) return data.trendingUsers;
|
||||
return data.trendingUsers
|
||||
.where((user) => user.username.toLowerCase().contains(q))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.count});
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 2)),
|
||||
const Spacer(),
|
||||
Text('[$count]', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrendingUserCard extends StatelessWidget {
|
||||
const _TrendingUserCard({required this.user});
|
||||
final TrendingUserModel user;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 90,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.white, width: 1.5),
|
||||
color: AppColors.surfaceLight,
|
||||
),
|
||||
child: user.avatarUrl.isNotEmpty
|
||||
? Image.network(user.avatarUrl, fit: BoxFit.cover)
|
||||
: const Icon(Icons.person, color: AppColors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
user.username.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscoveryGridTile extends StatelessWidget {
|
||||
const _DiscoveryGridTile({required this.post});
|
||||
final dynamic post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.border),
|
||||
color: AppColors.surface,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (post.imageUrl.isNotEmpty)
|
||||
Image.network(post.imageUrl, fit: BoxFit.cover),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: AppColors.black.withOpacity(0.7),
|
||||
child: Text(
|
||||
post.username.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/discover_service.dart';
|
||||
import '../../domain/models/discover_data_model.dart';
|
||||
|
||||
final discoverServiceProvider = Provider<DiscoverService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return DiscoverService(client);
|
||||
});
|
||||
|
||||
final discoverDataProvider = FutureProvider<DiscoverDataModel>((ref) {
|
||||
return ref.watch(discoverServiceProvider).fetchDiscoverData();
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../music/presentation/widgets/track_card.dart';
|
||||
import '../providers/discover_providers.dart';
|
||||
|
||||
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||
const DiscoverScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||
}
|
||||
|
||||
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final discoverAsync = ref.watch(discoverDataProvider);
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(title: const Text('RIOTZ // DISCOVER')),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.neonRed,
|
||||
backgroundColor: AppColors.black,
|
||||
onRefresh: () async => ref.invalidate(discoverDataProvider),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search, color: AppColors.white),
|
||||
hintText: 'SEARCH THE VOID...',
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value.trim()),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
discoverAsync.when(
|
||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(48), child: CircularProgressIndicator(color: AppColors.neonRed))),
|
||||
error: (error, _) => Center(child: Text('DISCOVERY OFFLINE: $error')),
|
||||
data: (data) => _buildDiscoveryContent(theme, data),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiscoveryContent(ThemeData theme, data) {
|
||||
// Logic previously implemented in discover_page.dart, now polished for discover_screen.dart
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionHeader(title: 'TRENDING AGENTS', count: data.trendingUsers.length),
|
||||
const SizedBox(height: 40),
|
||||
_SectionHeader(title: 'SONIC FREQUENCIES', count: data.trendingTracks.length),
|
||||
const SizedBox(height: 16),
|
||||
...data.trendingTracks.take(5).map((track) => TrackCard(track: track)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.count});
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 2)),
|
||||
const Spacer(),
|
||||
Text('[$count]', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
137
lib/features/feed/data/services/feed_service.dart
Normal file
137
lib/features/feed/data/services/feed_service.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../domain/models/feed_post_model.dart';
|
||||
|
||||
class FeedService {
|
||||
const FeedService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
User get _currentUser {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw StateError('No authenticated user');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<List<FeedPostModel>> fetchFeed() async {
|
||||
final user = _currentUser;
|
||||
final posts = await _client
|
||||
.from('posts')
|
||||
.select('id,user_id,caption,image_url,likes_count,created_at')
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
if (posts.isEmpty) return const [];
|
||||
|
||||
final postRows = List<Map<String, dynamic>>.from(posts);
|
||||
final userIds = postRows
|
||||
.map((e) => e['user_id'] as String?)
|
||||
.whereType<String>()
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
final profiles = userIds.isEmpty
|
||||
? <Map<String, dynamic>>[]
|
||||
: List<Map<String, dynamic>>.from(await _client
|
||||
.from('profiles')
|
||||
.select('user_id,username,avatar_url')
|
||||
.inFilter('user_id', userIds));
|
||||
|
||||
final profileByUserId = {
|
||||
for (final profile in profiles) (profile['user_id'] as String): profile,
|
||||
};
|
||||
|
||||
final postIds = postRows.map((e) => e['id'] as String).toList();
|
||||
final myLikes = postIds.isEmpty
|
||||
? <Map<String, dynamic>>[]
|
||||
: List<Map<String, dynamic>>.from(await _client
|
||||
.from('post_likes')
|
||||
.select('post_id')
|
||||
.eq('user_id', user.id)
|
||||
.inFilter('post_id', postIds));
|
||||
|
||||
final likedPostIds =
|
||||
myLikes.map((e) => e['post_id'] as String).toSet();
|
||||
|
||||
return postRows.map((row) {
|
||||
final profile = profileByUserId[row['user_id']] ?? const {};
|
||||
return FeedPostModel(
|
||||
id: row['id'] as String,
|
||||
userId: row['user_id'] as String,
|
||||
caption: (row['caption'] as String?) ?? '',
|
||||
imageUrl: (row['image_url'] as String?) ?? '',
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
likesCount: (row['likes_count'] as int?) ?? 0,
|
||||
isLiked: likedPostIds.contains(row['id']),
|
||||
username: (profile['username'] as String?) ?? 'riot_user',
|
||||
avatarUrl: (profile['avatar_url'] as String?) ?? '',
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> createPost({
|
||||
required String caption,
|
||||
required Uint8List imageBytes,
|
||||
required String extension,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final random = Random().nextInt(99999);
|
||||
final path = 'posts/${user.id}/$ts-$random.$extension';
|
||||
|
||||
await _client.storage.from('post-images').uploadBinary(
|
||||
path,
|
||||
imageBytes,
|
||||
fileOptions: const FileOptions(upsert: false),
|
||||
);
|
||||
|
||||
final imageUrl = _client.storage.from('post-images').getPublicUrl(path);
|
||||
|
||||
await _client.from('posts').insert({
|
||||
'user_id': user.id,
|
||||
'caption': caption.trim(),
|
||||
'image_url': imageUrl,
|
||||
'likes_count': 0,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> toggleLike({
|
||||
required String postId,
|
||||
required bool currentlyLiked,
|
||||
required int currentLikesCount,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
if (currentlyLiked) {
|
||||
await _client
|
||||
.from('post_likes')
|
||||
.delete()
|
||||
.eq('post_id', postId)
|
||||
.eq('user_id', user.id);
|
||||
await _client.from('posts').update({
|
||||
'likes_count': max(0, currentLikesCount - 1),
|
||||
}).eq('id', postId);
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.from('post_likes').upsert({
|
||||
'post_id': postId,
|
||||
'user_id': user.id,
|
||||
});
|
||||
await _client.from('posts').update({
|
||||
'likes_count': currentLikesCount + 1,
|
||||
}).eq('id', postId);
|
||||
}
|
||||
|
||||
Future<void> deleteOwnPost(String postId) async {
|
||||
final user = _currentUser;
|
||||
await _client
|
||||
.from('posts')
|
||||
.delete()
|
||||
.eq('id', postId)
|
||||
.eq('user_id', user.id);
|
||||
}
|
||||
}
|
||||
152
lib/features/feed/data/services/post_service.dart
Normal file
152
lib/features/feed/data/services/post_service.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../domain/models/feed_post_model.dart';
|
||||
|
||||
class PostService {
|
||||
const PostService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
User get _currentUser {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw StateError('No authenticated user');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/// Fetches the global feed in chronological order.
|
||||
Future<List<FeedPostModel>> fetchFeed() async {
|
||||
final user = _currentUser;
|
||||
|
||||
// Fetch posts with user profile data using joins if possible,
|
||||
// but sticking to the established pattern for consistency and safety.
|
||||
final posts = await _client
|
||||
.from('posts')
|
||||
.select('id, user_id, caption, image_url, likes_count, created_at')
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
if (posts.isEmpty) return const [];
|
||||
|
||||
final postRows = List<Map<String, dynamic>>.from(posts);
|
||||
final userIds = postRows
|
||||
.map((e) => e['user_id'] as String?)
|
||||
.whereType<String>()
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
// Fetch profiles for the users in the feed
|
||||
final profiles = userIds.isEmpty
|
||||
? <Map<String, dynamic>>[]
|
||||
: List<Map<String, dynamic>>.from(await _client
|
||||
.from('profiles')
|
||||
.select('user_id, username, avatar_url')
|
||||
.inFilter('user_id', userIds));
|
||||
|
||||
final profileByUserId = {
|
||||
for (final profile in profiles) (profile['user_id'] as String): profile,
|
||||
};
|
||||
|
||||
// Fetch the current user's likes for these posts to set isLiked
|
||||
final postIds = postRows.map((e) => e['id'] as String).toList();
|
||||
final myLikes = postIds.isEmpty
|
||||
? <Map<String, dynamic>>[]
|
||||
: List<Map<String, dynamic>>.from(await _client
|
||||
.from('post_likes')
|
||||
.select('post_id')
|
||||
.eq('user_id', user.id)
|
||||
.inFilter('post_id', postIds));
|
||||
|
||||
final likedPostIds = myLikes.map((e) => e['post_id'] as String).toSet();
|
||||
|
||||
return postRows.map((row) {
|
||||
final profile = profileByUserId[row['user_id']] ?? const {};
|
||||
return FeedPostModel(
|
||||
id: row['id'] as String,
|
||||
userId: row['user_id'] as String,
|
||||
caption: (row['caption'] as String?) ?? '',
|
||||
imageUrl: (row['image_url'] as String?) ?? '',
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
likesCount: (row['likes_count'] as int?) ?? 0,
|
||||
isLiked: likedPostIds.contains(row['id']),
|
||||
username: (profile['username'] as String?) ?? 'riot_user',
|
||||
avatarUrl: (profile['avatar_url'] as String?) ?? '',
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Uploads a new post with an image and optional caption.
|
||||
Future<void> uploadPost({
|
||||
required String caption,
|
||||
required Uint8List imageBytes,
|
||||
required String extension,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final random = Random().nextInt(99999);
|
||||
final fileName = '$ts-$random.$extension';
|
||||
final path = 'posts/${user.id}/$fileName';
|
||||
|
||||
// Upload image to Supabase Storage
|
||||
await _client.storage.from('post-images').uploadBinary(
|
||||
path,
|
||||
imageBytes,
|
||||
fileOptions: const FileOptions(upsert: false),
|
||||
);
|
||||
|
||||
final imageUrl = _client.storage.from('post-images').getPublicUrl(path);
|
||||
|
||||
// Insert post record into PostgreSQL
|
||||
await _client.from('posts').insert({
|
||||
'user_id': user.id,
|
||||
'caption': caption.trim(),
|
||||
'image_url': imageUrl,
|
||||
'likes_count': 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggles a like on a post.
|
||||
Future<void> toggleLike({
|
||||
required String postId,
|
||||
required bool currentlyLiked,
|
||||
required int currentLikesCount,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
|
||||
if (currentlyLiked) {
|
||||
// Unlike
|
||||
await _client
|
||||
.from('post_likes')
|
||||
.delete()
|
||||
.eq('post_id', postId)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
await _client.from('posts').update({
|
||||
'likes_count': max(0, currentLikesCount - 1),
|
||||
}).eq('id', postId);
|
||||
} else {
|
||||
// Like
|
||||
await _client.from('post_likes').upsert({
|
||||
'post_id': postId,
|
||||
'user_id': user.id,
|
||||
});
|
||||
|
||||
await _client.from('posts').update({
|
||||
'likes_count': currentLikesCount + 1,
|
||||
}).eq('id', postId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a post belonging to the current user.
|
||||
Future<void> deletePost(String postId) async {
|
||||
final user = _currentUser;
|
||||
await _client
|
||||
.from('posts')
|
||||
.delete()
|
||||
.eq('id', postId)
|
||||
.eq('user_id', user.id);
|
||||
}
|
||||
}
|
||||
40
lib/features/feed/domain/models/feed_post_model.dart
Normal file
40
lib/features/feed/domain/models/feed_post_model.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
class FeedPostModel {
|
||||
const FeedPostModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.caption,
|
||||
required this.imageUrl,
|
||||
required this.createdAt,
|
||||
required this.likesCount,
|
||||
required this.isLiked,
|
||||
required this.username,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
final String caption;
|
||||
final String imageUrl;
|
||||
final DateTime createdAt;
|
||||
final int likesCount;
|
||||
final bool isLiked;
|
||||
final String username;
|
||||
final String avatarUrl;
|
||||
|
||||
FeedPostModel copyWith({
|
||||
int? likesCount,
|
||||
bool? isLiked,
|
||||
}) {
|
||||
return FeedPostModel(
|
||||
id: id,
|
||||
userId: userId,
|
||||
caption: caption,
|
||||
imageUrl: imageUrl,
|
||||
createdAt: createdAt,
|
||||
likesCount: likesCount ?? this.likesCount,
|
||||
isLiked: isLiked ?? this.isLiked,
|
||||
username: username,
|
||||
avatarUrl: avatarUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/features/feed/presentation/providers/feed_providers.dart
Normal file
62
lib/features/feed/presentation/providers/feed_providers.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/feed_service.dart';
|
||||
import '../../domain/models/feed_post_model.dart';
|
||||
|
||||
final feedServiceProvider = Provider<FeedService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return FeedService(client);
|
||||
});
|
||||
|
||||
final feedPostsProvider = FutureProvider<List<FeedPostModel>>((ref) {
|
||||
return ref.watch(feedServiceProvider).fetchFeed();
|
||||
});
|
||||
|
||||
final feedControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<FeedController, void>(
|
||||
FeedController.new,
|
||||
);
|
||||
|
||||
class FeedController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> createPost({
|
||||
required String caption,
|
||||
required Uint8List imageBytes,
|
||||
required String extension,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(feedServiceProvider).createPost(
|
||||
caption: caption,
|
||||
imageBytes: imageBytes,
|
||||
extension: extension,
|
||||
);
|
||||
ref.invalidate(feedPostsProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> toggleLike(FeedPostModel post) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(feedServiceProvider).toggleLike(
|
||||
postId: post.id,
|
||||
currentlyLiked: post.isLiked,
|
||||
currentLikesCount: post.likesCount,
|
||||
);
|
||||
ref.invalidate(feedPostsProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteOwnPost(String postId) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(feedServiceProvider).deleteOwnPost(postId);
|
||||
ref.invalidate(feedPostsProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
59
lib/features/feed/presentation/providers/post_providers.dart
Normal file
59
lib/features/feed/presentation/providers/post_providers.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/post_service.dart';
|
||||
import '../../domain/models/feed_post_model.dart';
|
||||
|
||||
final postServiceProvider = Provider<PostService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return PostService(client);
|
||||
});
|
||||
|
||||
final feedPostsProvider = FutureProvider<List<FeedPostModel>>((ref) {
|
||||
return ref.watch(postServiceProvider).fetchFeed();
|
||||
});
|
||||
|
||||
final postControllerProvider = AutoDisposeAsyncNotifierProvider<PostController, void>(
|
||||
PostController.new,
|
||||
);
|
||||
|
||||
class PostController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> createPost({
|
||||
required String caption,
|
||||
required Uint8List imageBytes,
|
||||
required String extension,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(postServiceProvider).uploadPost(
|
||||
caption: caption,
|
||||
imageBytes: imageBytes,
|
||||
extension: extension,
|
||||
);
|
||||
ref.invalidate(feedPostsProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> toggleLike(FeedPostModel post) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(postServiceProvider).toggleLike(
|
||||
postId: post.id,
|
||||
currentlyLiked: post.isLiked,
|
||||
currentLikesCount: post.likesCount,
|
||||
);
|
||||
ref.invalidate(feedPostsProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deletePost(String postId) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(postServiceProvider).deletePost(postId);
|
||||
ref.invalidate(feedPostsProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
94
lib/features/feed/presentation/screens/feed_screen.dart
Normal file
94
lib/features/feed/presentation/screens/feed_screen.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../auth/presentation/providers/auth_provider.dart';
|
||||
import '../providers/post_providers.dart';
|
||||
import '../widgets/post_card.dart';
|
||||
|
||||
class FeedScreen extends ConsumerWidget {
|
||||
const FeedScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final postsAsync = ref.watch(feedPostsProvider);
|
||||
final currentUser = ref.watch(authServiceProvider).currentUser;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // FEED'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_box_outlined, color: AppColors.white),
|
||||
onPressed: () => context.push(AppRoutes.uploadPost),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.neonRed,
|
||||
backgroundColor: AppColors.black,
|
||||
onRefresh: () async => ref.refresh(feedPostsProvider),
|
||||
child: postsAsync.when(
|
||||
data: (posts) {
|
||||
if (posts.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('NO CHAOS YET. START A RIOT.'),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: posts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final post = posts[index];
|
||||
return PostCard(
|
||||
post: post,
|
||||
isOwnPost: post.userId == currentUser?.id,
|
||||
onLike: () => ref.read(postControllerProvider.notifier).toggleLike(post),
|
||||
onDelete: () => _confirmDelete(context, ref, post.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.neonRed),
|
||||
),
|
||||
error: (error, _) => Center(
|
||||
child: Text('SIGNAL LOST: $error'),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: AppColors.neonRed,
|
||||
child: const Icon(Icons.add, color: AppColors.white),
|
||||
onPressed: () => context.push(AppRoutes.uploadPost),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref, String postId) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
title: const Text('ERASE TRANSMISSION?'),
|
||||
content: const Text('THIS ACTION CANNOT BE UNDONE.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('CANCEL', style: TextStyle(color: AppColors.white)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(postControllerProvider.notifier).deletePost(postId);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('DELETE', style: TextStyle(color: AppColors.neonRed)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
lib/features/feed/presentation/screens/upload_post_screen.dart
Normal file
134
lib/features/feed/presentation/screens/upload_post_screen.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../providers/post_providers.dart';
|
||||
|
||||
class UploadPostScreen extends ConsumerStatefulWidget {
|
||||
const UploadPostScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<UploadPostScreen> createState() => _UploadPostScreenState();
|
||||
}
|
||||
|
||||
class _UploadPostScreenState extends ConsumerState<UploadPostScreen> {
|
||||
final _captionController = TextEditingController();
|
||||
final _picker = ImagePicker();
|
||||
Uint8List? _imageBytes;
|
||||
String? _extension;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_captionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final image = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 80,
|
||||
maxWidth: 1080,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
final bytes = await image.readAsBytes();
|
||||
setState(() {
|
||||
_imageBytes = bytes;
|
||||
_extension = image.name.split('.').last.toLowerCase();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _upload() async {
|
||||
if (_imageBytes == null) return;
|
||||
|
||||
await ref.read(postControllerProvider.notifier).createPost(
|
||||
caption: _captionController.text,
|
||||
imageBytes: _imageBytes!,
|
||||
extension: _extension ?? 'jpg',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final loading = ref.watch(postControllerProvider).isLoading;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('NEW TRANSMISSION'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Image Picker Area
|
||||
GestureDetector(
|
||||
onTap: loading ? null : _pickImage,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.border, width: 2),
|
||||
),
|
||||
child: _imageBytes != null
|
||||
? Image.memory(_imageBytes!, fit: BoxFit.cover)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.add_a_photo_outlined, size: 48, color: AppColors.grey),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'SELECT VISUALS',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Caption Field
|
||||
Text(
|
||||
'MANIFESTO',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _captionController,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'DESCRIBE THE CHAOS...',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Post Button
|
||||
RiotzButton(
|
||||
label: 'PROPAGATE',
|
||||
isLoading: loading,
|
||||
onPressed: _imageBytes == null ? null : _upload,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/features/feed/presentation/widgets/post_card.dart
Normal file
117
lib/features/feed/presentation/widgets/post_card.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_card.dart';
|
||||
import '../../domain/models/feed_post_model.dart';
|
||||
|
||||
class PostCard extends StatelessWidget {
|
||||
const PostCard({
|
||||
required this.post,
|
||||
required this.isOwnPost,
|
||||
required this.onLike,
|
||||
required this.onDelete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final FeedPostModel post;
|
||||
final bool isOwnPost;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final timeStr = DateFormat('HH:mm // dd.MM.yy').format(post.createdAt);
|
||||
|
||||
return RiotzCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: post.avatarUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: post.avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const Icon(Icons.person, color: AppColors.grey),
|
||||
)
|
||||
: const Icon(Icons.person, color: AppColors.grey),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(post.username.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
Text(timeStr, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isOwnPost)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppColors.grey, size: 20),
|
||||
onPressed: onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Visuals
|
||||
CachedNetworkImage(
|
||||
imageUrl: post.imageUrl,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
height: 300,
|
||||
color: AppColors.surfaceLight,
|
||||
child: const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onLike,
|
||||
icon: Icon(
|
||||
post.isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
color: post.isLiked ? AppColors.neonRed : AppColors.white,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${post.likesCount}', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(width: 24),
|
||||
const Icon(Icons.chat_bubble_outline, color: AppColors.white, size: 20),
|
||||
],
|
||||
),
|
||||
if (post.caption.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(post.caption, style: theme.textTheme.bodyLarge),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
347
lib/features/home/presentation/pages/home_page.dart
Normal file
347
lib/features/home/presentation/pages/home_page.dart
Normal file
@@ -0,0 +1,347 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../../features/auth/presentation/providers/auth_providers.dart';
|
||||
import '../../../../features/admin/presentation/providers/admin_providers.dart';
|
||||
import '../../../../features/feed/domain/models/feed_post_model.dart';
|
||||
import '../../../../features/feed/presentation/providers/feed_providers.dart';
|
||||
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
final _captionController = TextEditingController();
|
||||
final _picker = ImagePicker();
|
||||
Uint8List? _selectedImageBytes;
|
||||
String _selectedImageExt = 'jpg';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_captionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isAdmin = ref.watch(isAdminProvider);
|
||||
final feedAsync = ref.watch(feedPostsProvider);
|
||||
final feedActionState = ref.watch(feedControllerProvider);
|
||||
final isBusy = feedActionState.isLoading;
|
||||
|
||||
ref.listen(feedControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('CHAOS PROPAGATED.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(error.toString().toUpperCase()),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // FEED'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout, color: AppColors.white),
|
||||
onPressed: () async {
|
||||
await ref.read(authControllerProvider.notifier).logout();
|
||||
if (context.mounted) context.go(AppRoutes.login);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppColors.neonRed,
|
||||
backgroundColor: AppColors.black,
|
||||
onRefresh: () async => ref.invalidate(feedPostsProvider),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
children: [
|
||||
Text(
|
||||
'WELCOME, ${user?.email?.split('@').first.toUpperCase() ?? 'RIOTER'}',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Composer
|
||||
_PostComposer(
|
||||
captionController: _captionController,
|
||||
selectedImageBytes: _selectedImageBytes,
|
||||
onPickImage: isBusy ? null : _pickImage,
|
||||
onCreatePost: isBusy ? null : _createPost,
|
||||
isBusy: isBusy,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Navigation Shortcuts (Chaotic Grid)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_NavChip(label: 'PROFILE', onTap: () => context.push(AppRoutes.profile)),
|
||||
_NavChip(label: 'MUSIC', onTap: () => context.push(AppRoutes.music)),
|
||||
_NavChip(label: 'DISCOVER', onTap: () => context.push(AppRoutes.discover)),
|
||||
if (isAdmin) _NavChip(label: 'ADMIN', onTap: () => context.push(AppRoutes.admin), isAccent: true),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
feedAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('SYSTEM ERROR: $error')),
|
||||
data: (posts) {
|
||||
if (posts.isEmpty) {
|
||||
return const Center(child: Text('NO CHAOS YET. START A RIOT.'));
|
||||
}
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: posts.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 24),
|
||||
itemBuilder: (context, index) {
|
||||
final post = posts[index];
|
||||
return _FeedPostCard(
|
||||
post: post,
|
||||
isOwnPost: user?.id == post.userId,
|
||||
onLike: () => ref.read(feedControllerProvider.notifier).toggleLike(post),
|
||||
onDelete: () => ref.read(feedControllerProvider.notifier).deleteOwnPost(post.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final image = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 80);
|
||||
if (image == null) return;
|
||||
final bytes = await image.readAsBytes();
|
||||
setState(() {
|
||||
_selectedImageBytes = bytes;
|
||||
_selectedImageExt = image.name.split('.').last;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createPost() async {
|
||||
if (_selectedImageBytes == null) return;
|
||||
await ref.read(feedControllerProvider.notifier).createPost(
|
||||
caption: _captionController.text,
|
||||
imageBytes: _selectedImageBytes!,
|
||||
extension: _selectedImageExt,
|
||||
);
|
||||
_captionController.clear();
|
||||
setState(() => _selectedImageBytes = null);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavChip extends StatelessWidget {
|
||||
const _NavChip({required this.label, required this.onTap, this.isAccent = false});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool isAccent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: isAccent ? AppColors.neonRed : AppColors.white),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isAccent ? AppColors.neonRed : AppColors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostComposer extends StatelessWidget {
|
||||
const _PostComposer({
|
||||
required this.captionController,
|
||||
required this.selectedImageBytes,
|
||||
required this.onPickImage,
|
||||
required this.onCreatePost,
|
||||
required this.isBusy,
|
||||
});
|
||||
|
||||
final TextEditingController captionController;
|
||||
final Uint8List? selectedImageBytes;
|
||||
final VoidCallback? onPickImage;
|
||||
final VoidCallback? onCreatePost;
|
||||
final bool isBusy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.border),
|
||||
color: AppColors.surface,
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: captionController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'WHAT\'S THE WORD ON THE STREET?',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
),
|
||||
if (selectedImageBytes != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Image.memory(selectedImageBytes!, height: 200, width: double.infinity, fit: BoxFit.cover),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_a_photo_outlined, color: AppColors.white),
|
||||
onPressed: onPickImage,
|
||||
),
|
||||
const Spacer(),
|
||||
RiotzButton(
|
||||
label: 'POST',
|
||||
onPressed: onCreatePost,
|
||||
isLoading: isBusy,
|
||||
fullWidth: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedPostCard extends StatelessWidget {
|
||||
const _FeedPostCard({
|
||||
required this.post,
|
||||
required this.isOwnPost,
|
||||
required this.onLike,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final FeedPostModel post;
|
||||
final bool isOwnPost;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceLight,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: post.avatarUrl.isNotEmpty
|
||||
? Image.network(post.avatarUrl, fit: BoxFit.cover)
|
||||
: const Icon(Icons.person, color: AppColors.grey),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(post.username.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
Text('RIOTER', style: theme.textTheme.bodySmall?.copyWith(color: AppColors.neonRed, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (isOwnPost)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert, color: AppColors.grey),
|
||||
onPressed: onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (post.imageUrl.isNotEmpty)
|
||||
Image.network(post.imageUrl, width: double.infinity, fit: BoxFit.cover),
|
||||
const SizedBox(height: 16),
|
||||
if (post.caption.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(post.caption, style: theme.textTheme.bodyLarge),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(
|
||||
post.isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
color: post.isLiked ? AppColors.neonRed : AppColors.white,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onLike,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${post.likesCount}', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(width: 24),
|
||||
const Icon(Icons.chat_bubble_outline, color: AppColors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('0', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/features/music/data/services/music_service.dart
Normal file
84
lib/features/music/data/services/music_service.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../domain/models/track_model.dart';
|
||||
|
||||
class MusicService {
|
||||
const MusicService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
User get _currentUser {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw StateError('No authenticated user');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/// Uploads a new track with an MP3 file and metadata.
|
||||
Future<void> uploadTrack({
|
||||
required Uint8List bytes,
|
||||
required String title,
|
||||
required String genreTag,
|
||||
required String extension,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final random = Random().nextInt(99999);
|
||||
final fileName = '$ts-$random.$extension';
|
||||
final path = 'tracks/${user.id}/$fileName';
|
||||
|
||||
// Upload audio to Supabase Storage
|
||||
await _client.storage.from('tracks').uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: false),
|
||||
);
|
||||
|
||||
final audioUrl = _client.storage.from('tracks').getPublicUrl(path);
|
||||
|
||||
// Insert track record into PostgreSQL
|
||||
await _client.from('tracks').insert({
|
||||
'user_id': user.id,
|
||||
'title': title.trim().isEmpty ? 'UNTITLED' : title.trim().toUpperCase(),
|
||||
'audio_url': audioUrl,
|
||||
'genre_tag': genreTag.trim().isEmpty ? 'UNKNOWN' : genreTag.trim().toUpperCase(),
|
||||
'plays': 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Lists all tracks in chronological order.
|
||||
Future<List<TrackModel>> fetchTracks() async {
|
||||
final response = await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
return List<Map<String, dynamic>>.from(response).map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Increments the play count of a track.
|
||||
Future<void> incrementPlays(String trackId) async {
|
||||
await _client.rpc('increment_track_plays', params: {'track_id': trackId});
|
||||
}
|
||||
|
||||
/// Fetches tracks for a specific user.
|
||||
Future<List<TrackModel>> fetchUserTracks(String userId) async {
|
||||
final response = await _client
|
||||
.from('tracks')
|
||||
.select('*, profiles(username)')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
return List<Map<String, dynamic>>.from(response).map((row) {
|
||||
final username = (row['profiles'] as Map<String, dynamic>?)?['username'] as String?;
|
||||
return TrackModel.fromJson(row, username: username);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
54
lib/features/music/domain/models/track_model.dart
Normal file
54
lib/features/music/domain/models/track_model.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
class TrackModel {
|
||||
const TrackModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.title,
|
||||
required this.audioUrl,
|
||||
required this.genreTag,
|
||||
required this.plays,
|
||||
required this.createdAt,
|
||||
required this.username,
|
||||
this.featured = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
final String title;
|
||||
final String audioUrl;
|
||||
final String genreTag;
|
||||
final int plays;
|
||||
final DateTime createdAt;
|
||||
final String username;
|
||||
final bool featured;
|
||||
|
||||
factory TrackModel.fromJson(Map<String, dynamic> json, {String? username}) {
|
||||
return TrackModel(
|
||||
id: json['id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
title: (json['title'] as String?) ?? 'UNTITLED',
|
||||
audioUrl: (json['audio_url'] as String?) ?? '',
|
||||
genreTag: (json['genre_tag'] as String?) ?? 'UNKNOWN',
|
||||
plays: (json['plays'] as int?) ?? 0,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
username: username ?? 'RIOTER',
|
||||
featured: (json['featured'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
TrackModel copyWith({
|
||||
int? plays,
|
||||
bool? featured,
|
||||
}) {
|
||||
return TrackModel(
|
||||
id: id,
|
||||
userId: userId,
|
||||
title: title,
|
||||
audioUrl: audioUrl,
|
||||
genreTag: genreTag,
|
||||
plays: plays ?? this.plays,
|
||||
createdAt: createdAt,
|
||||
username: username,
|
||||
featured: featured ?? this.featured,
|
||||
);
|
||||
}
|
||||
}
|
||||
236
lib/features/music/presentation/pages/music_page.dart
Normal file
236
lib/features/music/presentation/pages/music_page.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
import '../widgets/track_card.dart';
|
||||
|
||||
class MusicPage extends ConsumerStatefulWidget {
|
||||
const MusicPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MusicPage> createState() => _MusicPageState();
|
||||
}
|
||||
|
||||
class _MusicPageState extends ConsumerState<MusicPage> {
|
||||
final _titleController = TextEditingController();
|
||||
final _genreController = TextEditingController();
|
||||
Uint8List? _selectedMp3Bytes;
|
||||
String? _selectedFileName;
|
||||
String _selectedExtension = 'mp3';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_genreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tracksAsync = ref.watch(trackListProvider);
|
||||
final uploadState = ref.watch(musicControllerProvider);
|
||||
|
||||
ref.listen(musicControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('SONIC CHAOS UPLOADED.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(error.toString().toUpperCase()),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // AUDIO'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Text(
|
||||
'UPLOAD TRANSMISSION',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_UploadComposer(
|
||||
titleController: _titleController,
|
||||
genreController: _genreController,
|
||||
selectedFileName: _selectedFileName,
|
||||
isBusy: uploadState.isLoading,
|
||||
onPickMp3: uploadState.isLoading ? null : _pickMp3,
|
||||
onUpload: uploadState.isLoading ? null : _upload,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
'LATEST SOUNDS',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
tracksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('SIGNAL LOST: $error')),
|
||||
data: (tracks) {
|
||||
if (tracks.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text('NO TRACKS FOUND. BROADCAST YOUR SOUND.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: tracks.map((track) {
|
||||
return TrackCard(track: track);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickMp3() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['mp3'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
|
||||
final file = result.files.first;
|
||||
if (file.bytes == null) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('UNREADABLE FILE.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedMp3Bytes = file.bytes;
|
||||
_selectedFileName = file.name.toUpperCase();
|
||||
_selectedExtension = file.name.split('.').last.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _upload() async {
|
||||
final bytes = _selectedMp3Bytes;
|
||||
if (bytes == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('SELECT AN MP3 FIRST.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(musicControllerProvider.notifier).uploadTrack(
|
||||
bytes: bytes,
|
||||
title: _titleController.text,
|
||||
genreTag: _genreController.text,
|
||||
extension: _selectedExtension,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedMp3Bytes = null;
|
||||
_selectedFileName = null;
|
||||
_titleController.clear();
|
||||
_genreController.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadComposer extends StatelessWidget {
|
||||
const _UploadComposer({
|
||||
required this.titleController,
|
||||
required this.genreController,
|
||||
required this.selectedFileName,
|
||||
required this.isBusy,
|
||||
required this.onPickMp3,
|
||||
required this.onUpload,
|
||||
});
|
||||
|
||||
final TextEditingController titleController;
|
||||
final TextEditingController genreController;
|
||||
final String? selectedFileName;
|
||||
final bool isBusy;
|
||||
final VoidCallback? onPickMp3;
|
||||
final VoidCallback? onUpload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'TRACK TITLE',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: genreController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'GENRE (RAGE, PUNK, PHONK)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (selectedFileName != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'READY: $selectedFileName',
|
||||
style: const TextStyle(color: AppColors.neonRed, fontWeight: FontWeight.bold, fontSize: 10),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RiotzButton(
|
||||
label: 'SELECT FILE',
|
||||
style: RiotzButtonStyle.outline,
|
||||
onPressed: onPickMp3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: RiotzButton(
|
||||
label: 'UPLOAD',
|
||||
isLoading: isBusy,
|
||||
onPressed: onUpload,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/music_service.dart';
|
||||
import '../../domain/models/track_model.dart';
|
||||
|
||||
final musicServiceProvider = Provider<MusicService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return MusicService(client);
|
||||
});
|
||||
|
||||
final trackListProvider = FutureProvider<List<TrackModel>>((ref) {
|
||||
return ref.watch(musicServiceProvider).fetchTracks();
|
||||
});
|
||||
|
||||
final userTracksProvider = FutureProvider.family<List<TrackModel>, String>((ref, userId) {
|
||||
return ref.watch(musicServiceProvider).fetchUserTracks(userId);
|
||||
});
|
||||
|
||||
final musicControllerProvider = AutoDisposeAsyncNotifierProvider<MusicController, void>(
|
||||
MusicController.new,
|
||||
);
|
||||
|
||||
class MusicController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> uploadTrack({
|
||||
required Uint8List bytes,
|
||||
required String title,
|
||||
required String genreTag,
|
||||
required String extension,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(musicServiceProvider).uploadTrack(
|
||||
bytes: bytes,
|
||||
title: title,
|
||||
genreTag: genreTag,
|
||||
extension: extension,
|
||||
);
|
||||
ref.invalidate(trackListProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> incrementPlays(String trackId) async {
|
||||
await AsyncValue.guard(() => ref.read(musicServiceProvider).incrementPlays(trackId));
|
||||
}
|
||||
}
|
||||
|
||||
final audioPlayerProvider = Provider.autoDispose<AudioPlayer>((ref) {
|
||||
final player = AudioPlayer();
|
||||
ref.onDispose(player.dispose);
|
||||
return player;
|
||||
});
|
||||
|
||||
final currentTrackProvider = StateProvider<TrackModel?>((ref) => null);
|
||||
final isPlayingProvider = StreamProvider<bool>((ref) {
|
||||
final player = ref.watch(audioPlayerProvider);
|
||||
return player.playingStream;
|
||||
});
|
||||
140
lib/features/music/presentation/screens/music_screen.dart
Normal file
140
lib/features/music/presentation/screens/music_screen.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
import '../widgets/track_card.dart';
|
||||
|
||||
class MusicScreen extends ConsumerStatefulWidget {
|
||||
const MusicScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MusicScreen> createState() => _MusicScreenState();
|
||||
}
|
||||
|
||||
class _MusicScreenState extends ConsumerState<MusicScreen> {
|
||||
final _titleController = TextEditingController();
|
||||
final _genreController = TextEditingController();
|
||||
Uint8List? _selectedMp3Bytes;
|
||||
String? _selectedFileName;
|
||||
String _selectedExtension = 'mp3';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_genreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tracksAsync = ref.watch(trackListProvider);
|
||||
final uploadState = ref.watch(musicControllerProvider);
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(title: const Text('RIOTZ // AUDIO')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Text('UPLOAD TRANSMISSION', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
const SizedBox(height: 16),
|
||||
_UploadComposer(
|
||||
titleController: _titleController,
|
||||
genreController: _genreController,
|
||||
selectedFileName: _selectedFileName,
|
||||
isBusy: uploadState.isLoading,
|
||||
onPickMp3: uploadState.isLoading ? null : _pickMp3,
|
||||
onUpload: uploadState.isLoading ? null : _upload,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Text('LATEST SOUNDS', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.neonRed)),
|
||||
const SizedBox(height: 16),
|
||||
tracksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('SIGNAL LOST: $error')),
|
||||
data: (tracks) => Column(
|
||||
children: tracks.map((track) => TrackCard(track: track)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickMp3() async {
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['mp3'], withData: true);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final file = result.files.first;
|
||||
setState(() {
|
||||
_selectedMp3Bytes = file.bytes;
|
||||
_selectedFileName = file.name.toUpperCase();
|
||||
_selectedExtension = file.name.split('.').last.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _upload() async {
|
||||
if (_selectedMp3Bytes == null) return;
|
||||
await ref.read(musicControllerProvider.notifier).uploadTrack(
|
||||
bytes: _selectedMp3Bytes!,
|
||||
title: _titleController.text,
|
||||
genreTag: _genreController.text,
|
||||
extension: _selectedExtension,
|
||||
);
|
||||
setState(() {
|
||||
_selectedMp3Bytes = null;
|
||||
_selectedFileName = null;
|
||||
_titleController.clear();
|
||||
_genreController.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadComposer extends StatelessWidget {
|
||||
const _UploadComposer({
|
||||
required this.titleController,
|
||||
required this.genreController,
|
||||
required this.selectedFileName,
|
||||
required this.isBusy,
|
||||
required this.onPickMp3,
|
||||
required this.onUpload,
|
||||
});
|
||||
|
||||
final TextEditingController titleController;
|
||||
final TextEditingController genreController;
|
||||
final String? selectedFileName;
|
||||
final bool isBusy;
|
||||
final VoidCallback? onPickMp3;
|
||||
final VoidCallback? onUpload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: AppColors.surface, border: Border.all(color: AppColors.border)),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(controller: titleController, decoration: const InputDecoration(hintText: 'TRACK TITLE')),
|
||||
const SizedBox(height: 16),
|
||||
TextField(controller: genreController, decoration: const InputDecoration(hintText: 'GENRE')),
|
||||
const SizedBox(height: 24),
|
||||
if (selectedFileName != null) Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text('READY: $selectedFileName', style: const TextStyle(color: AppColors.neonRed, fontWeight: FontWeight.bold, fontSize: 10)),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: RiotzButton(label: 'SELECT', style: RiotzButtonStyle.outline, onPressed: onPickMp3)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: RiotzButton(label: 'UPLOAD', isLoading: isBusy, onPressed: onUpload)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/features/music/presentation/widgets/track_card.dart
Normal file
109
lib/features/music/presentation/widgets/track_card.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../domain/models/track_model.dart';
|
||||
import '../providers/music_providers.dart';
|
||||
|
||||
class TrackCard extends ConsumerWidget {
|
||||
const TrackCard({
|
||||
required this.track,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TrackModel track;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final player = ref.watch(audioPlayerProvider);
|
||||
final currentTrack = ref.watch(currentTrackProvider);
|
||||
final isPlaying = ref.watch(isPlayingProvider).value ?? false;
|
||||
final isCurrent = currentTrack?.id == track.id;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent ? AppColors.surfaceLight : Colors.transparent,
|
||||
border: Border.all(color: isCurrent ? AppColors.neonRed : AppColors.border),
|
||||
),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _handlePlay(ref, player),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover / Icon
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: AppColors.black,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isCurrent && isPlaying ? Icons.graphic_eq : Icons.music_note,
|
||||
color: isCurrent ? AppColors.neonRed : AppColors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.title.toUpperCase(),
|
||||
style: theme.textTheme.titleLarge?.copyWith(fontSize: 16, letterSpacing: 1),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${track.username.toUpperCase()} // ${track.genreTag.toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: AppColors.grey, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Plays & Action
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${track.plays} PLAYS',
|
||||
style: theme.textTheme.bodySmall?.copyWith(fontSize: 9, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Icon(
|
||||
isCurrent && isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: AppColors.white,
|
||||
size: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePlay(WidgetRef ref, AudioPlayer player) async {
|
||||
final currentTrack = ref.read(currentTrackProvider);
|
||||
|
||||
if (currentTrack?.id == track.id) {
|
||||
if (player.playing) {
|
||||
await player.pause();
|
||||
} else {
|
||||
await player.play();
|
||||
}
|
||||
} else {
|
||||
ref.read(currentTrackProvider.notifier).state = track;
|
||||
await player.setUrl(track.audioUrl);
|
||||
await player.play();
|
||||
// Increment play count
|
||||
ref.read(musicControllerProvider.notifier).incrementPlays(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
lib/features/profile/data/services/profile_service.dart
Normal file
136
lib/features/profile/data/services/profile_service.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../domain/models/profile_model.dart';
|
||||
import '../../domain/models/profile_stats_model.dart';
|
||||
|
||||
class ProfileService {
|
||||
const ProfileService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
User get _currentUser {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw StateError('No authenticated user');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<ProfileModel> fetchMyProfile() async {
|
||||
final user = _currentUser;
|
||||
final response = await _client
|
||||
.from('profiles')
|
||||
.select()
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (response == null) {
|
||||
final created = await _client
|
||||
.from('profiles')
|
||||
.insert({
|
||||
'user_id': user.id,
|
||||
'username': (user.userMetadata?['username'] as String?) ?? '',
|
||||
'bio': '',
|
||||
'avatar_url': '',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return ProfileModel.fromJson(created);
|
||||
}
|
||||
|
||||
return ProfileModel.fromJson(response);
|
||||
}
|
||||
|
||||
Future<ProfileModel> updateUsername(String username) async {
|
||||
final user = _currentUser;
|
||||
await _client.auth.updateUser(UserAttributes(data: {'username': username}));
|
||||
final updated = await _client
|
||||
.from('profiles')
|
||||
.update({'username': username})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
return ProfileModel.fromJson(updated);
|
||||
}
|
||||
|
||||
Future<ProfileModel> updateBio(String bio) async {
|
||||
final user = _currentUser;
|
||||
final updated = await _client
|
||||
.from('profiles')
|
||||
.update({'bio': bio})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
return ProfileModel.fromJson(updated);
|
||||
}
|
||||
|
||||
Future<ProfileModel> updateProfile({
|
||||
required String username,
|
||||
required String bio,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
await _client.auth.updateUser(UserAttributes(data: {'username': username}));
|
||||
final updated = await _client
|
||||
.from('profiles')
|
||||
.update({
|
||||
'username': username,
|
||||
'bio': bio,
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
return ProfileModel.fromJson(updated);
|
||||
}
|
||||
|
||||
Future<ProfileModel> uploadAvatar({
|
||||
required Uint8List bytes,
|
||||
required String extension,
|
||||
}) async {
|
||||
final user = _currentUser;
|
||||
final path =
|
||||
'avatars/${user.id}/${DateTime.now().millisecondsSinceEpoch}.$extension';
|
||||
|
||||
await _client.storage.from('avatars').uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: true),
|
||||
);
|
||||
|
||||
final avatarUrl = _client.storage.from('avatars').getPublicUrl(path);
|
||||
final updated = await _client
|
||||
.from('profiles')
|
||||
.update({'avatar_url': avatarUrl})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single();
|
||||
return ProfileModel.fromJson(updated);
|
||||
}
|
||||
|
||||
Future<ProfileStatsModel> fetchMyStats() async {
|
||||
final user = _currentUser;
|
||||
|
||||
final postResponse = await _client
|
||||
.from('posts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
final commentResponse = await _client
|
||||
.from('comments')
|
||||
.select('*')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
final trackResponse = await _client
|
||||
.from('tracks')
|
||||
.select('*')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
return ProfileStatsModel(
|
||||
postsCount: (postResponse as List).length,
|
||||
commentsCount: (commentResponse as List).length,
|
||||
tracksCount: (trackResponse as List).length,
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/features/profile/domain/models/profile_model.dart
Normal file
35
lib/features/profile/domain/models/profile_model.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
class ProfileModel {
|
||||
const ProfileModel({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.bio,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String bio;
|
||||
final String avatarUrl;
|
||||
|
||||
factory ProfileModel.fromJson(Map<String, dynamic> json) {
|
||||
return ProfileModel(
|
||||
userId: (json['user_id'] as String?) ?? '',
|
||||
username: (json['username'] as String?) ?? '',
|
||||
bio: (json['bio'] as String?) ?? '',
|
||||
avatarUrl: (json['avatar_url'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
ProfileModel copyWith({
|
||||
String? username,
|
||||
String? bio,
|
||||
String? avatarUrl,
|
||||
}) {
|
||||
return ProfileModel(
|
||||
userId: userId,
|
||||
username: username ?? this.username,
|
||||
bio: bio ?? this.bio,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
11
lib/features/profile/domain/models/profile_stats_model.dart
Normal file
11
lib/features/profile/domain/models/profile_stats_model.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
class ProfileStatsModel {
|
||||
const ProfileStatsModel({
|
||||
required this.postsCount,
|
||||
required this.commentsCount,
|
||||
required this.tracksCount,
|
||||
});
|
||||
|
||||
final int postsCount;
|
||||
final int commentsCount;
|
||||
final int tracksCount;
|
||||
}
|
||||
283
lib/features/profile/presentation/pages/profile_page.dart
Normal file
283
lib/features/profile/presentation/pages/profile_page.dart
Normal file
@@ -0,0 +1,283 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
import '../../../auth/presentation/providers/auth_provider.dart';
|
||||
import '../../../music/presentation/providers/music_providers.dart';
|
||||
import '../../../music/presentation/widgets/track_card.dart';
|
||||
import '../../domain/models/profile_model.dart';
|
||||
import '../../domain/models/profile_stats_model.dart';
|
||||
import '../providers/profile_providers.dart';
|
||||
|
||||
class ProfilePage extends ConsumerStatefulWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends ConsumerState<ProfilePage> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _usernameController = TextEditingController();
|
||||
final _bioController = TextEditingController();
|
||||
final _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_usernameController.dispose();
|
||||
_bioController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
ref.listen(profileControllerProvider, (_, next) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
backgroundColor: AppColors.success,
|
||||
content: Text('IDENTITY UPDATED.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
ref.invalidate(myProfileStatsProvider);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppColors.bloodRed,
|
||||
content: Text(error.toString().toUpperCase()),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final profileAsync = ref.watch(myProfileProvider);
|
||||
final statsAsync = ref.watch(myProfileStatsProvider);
|
||||
final updating = ref.watch(profileControllerProvider).isLoading;
|
||||
final currentUser = ref.watch(authServiceProvider).currentUser;
|
||||
|
||||
return RiotzScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RIOTZ // IDENTITY'),
|
||||
),
|
||||
body: profileAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Center(child: Text('ERROR: $error')),
|
||||
data: (profile) {
|
||||
_syncControllers(profile);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
children: [
|
||||
// Profile Header
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.neonRed, width: 2),
|
||||
color: AppColors.surfaceLight,
|
||||
),
|
||||
child: profile.avatarUrl.isNotEmpty
|
||||
? Image.network(profile.avatarUrl, fit: BoxFit.cover)
|
||||
: const Icon(Icons.person, size: 60, color: AppColors.grey),
|
||||
),
|
||||
Positioned(
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
child: IconButton(
|
||||
onPressed: updating ? null : _pickAndUploadAvatar,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
color: AppColors.neonRed,
|
||||
child: const Icon(Icons.edit, color: AppColors.white, size: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_StatsSection(statsAsync: statsAsync),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Tabs
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: AppColors.neonRed,
|
||||
labelColor: AppColors.white,
|
||||
unselectedLabelColor: AppColors.grey,
|
||||
tabs: const [
|
||||
Tab(text: 'IDENTITY'),
|
||||
Tab(text: 'TRACKS'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
height: 400, // Fixed height for tab content in ListView
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Identity Tab
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('USERNAME', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(hintText: 'ENTER ALIAS'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('BIO // MANIFESTO', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _bioController,
|
||||
decoration: const InputDecoration(hintText: 'DESCRIBE THE CHAOS'),
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
RiotzButton(
|
||||
label: 'UPDATE IDENTITY',
|
||||
isLoading: updating,
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(profileControllerProvider.notifier)
|
||||
.saveProfile(
|
||||
username: _usernameController.text,
|
||||
bio: _bioController.text,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tracks Tab
|
||||
currentUser == null
|
||||
? const Center(child: Text('LOG IN TO SEE TRACKS'))
|
||||
: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final tracksAsync = ref.watch(userTracksProvider(currentUser.id));
|
||||
return tracksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (err, _) => Center(child: Text('ERROR: $err')),
|
||||
data: (tracks) {
|
||||
if (tracks.isEmpty) {
|
||||
return const Center(child: Text('NO TRACKS BROADCASTED.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) => TrackCard(track: tracks[index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncControllers(ProfileModel profile) {
|
||||
if (_usernameController.text != profile.username) {
|
||||
_usernameController.text = profile.username;
|
||||
}
|
||||
if (_bioController.text != profile.bio) {
|
||||
_bioController.text = profile.bio;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadAvatar() async {
|
||||
final picked = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 85,
|
||||
maxWidth: 1200,
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
final bytes = await picked.readAsBytes();
|
||||
final ext = picked.name.split('.').last.toLowerCase();
|
||||
|
||||
if (!mounted) return;
|
||||
await ref.read(profileControllerProvider.notifier).uploadAvatar(
|
||||
bytes: bytes,
|
||||
extension: ext.isEmpty ? 'jpg' : ext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsSection extends StatelessWidget {
|
||||
const _StatsSection({required this.statsAsync});
|
||||
|
||||
final AsyncValue<ProfileStatsModel> statsAsync;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return statsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
|
||||
error: (error, _) => Text('STATS OFFLINE: $error'),
|
||||
data: (stats) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_StatItem(label: 'POSTS', value: stats.postsCount.toString()),
|
||||
_StatItem(label: 'CHAOS', value: stats.commentsCount.toString()),
|
||||
_StatItem(label: 'TRACKS', value: stats.tracksCount.toString()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatItem extends StatelessWidget {
|
||||
const _StatItem({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
Text(value, style: theme.textTheme.headlineLarge),
|
||||
Text(label, style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey, fontSize: 10)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/supabase/supabase_providers.dart';
|
||||
import '../../data/services/profile_service.dart';
|
||||
import '../../domain/models/profile_model.dart';
|
||||
import '../../domain/models/profile_stats_model.dart';
|
||||
|
||||
final profileServiceProvider = Provider<ProfileService>((ref) {
|
||||
final client = ref.watch(supabaseProvider);
|
||||
return ProfileService(client);
|
||||
});
|
||||
|
||||
final myProfileProvider = FutureProvider<ProfileModel>((ref) {
|
||||
return ref.watch(profileServiceProvider).fetchMyProfile();
|
||||
});
|
||||
|
||||
final myProfileStatsProvider = FutureProvider<ProfileStatsModel>((ref) {
|
||||
return ref.watch(profileServiceProvider).fetchMyStats();
|
||||
});
|
||||
|
||||
final profileControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<ProfileController, void>(
|
||||
ProfileController.new,
|
||||
);
|
||||
|
||||
class ProfileController extends AutoDisposeAsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> updateUsername(String username) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileServiceProvider).updateUsername(username.trim());
|
||||
ref.invalidate(myProfileProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateBio(String bio) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileServiceProvider).updateBio(bio.trim());
|
||||
ref.invalidate(myProfileProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> uploadAvatar({
|
||||
required Uint8List bytes,
|
||||
required String extension,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileServiceProvider).uploadAvatar(
|
||||
bytes: bytes,
|
||||
extension: extension,
|
||||
);
|
||||
ref.invalidate(myProfileProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> saveProfile({
|
||||
required String username,
|
||||
required String bio,
|
||||
}) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileServiceProvider).updateProfile(
|
||||
username: username.trim(),
|
||||
bio: bio.trim(),
|
||||
);
|
||||
ref.invalidate(myProfileProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
70
lib/features/splash/presentation/pages/splash_page.dart
Normal file
70
lib/features/splash/presentation/pages/splash_page.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/widgets/riotz_scaffold.dart';
|
||||
import '../../../../core/widgets/riotz_button.dart';
|
||||
|
||||
class SplashPage extends StatelessWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return RiotzScaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
// Brand Title
|
||||
Text(
|
||||
'RIOTZ',
|
||||
style: theme.textTheme.displayLarge?.copyWith(
|
||||
color: AppColors.white,
|
||||
height: 0.9,
|
||||
),
|
||||
),
|
||||
// Aesthetic Subtitle
|
||||
Container(
|
||||
color: AppColors.neonRed,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(
|
||||
'UNDERGROUND // CHAOS // CULTURE',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppColors.white,
|
||||
letterSpacing: 1.5,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'THE PREMIER PLATFORM FOR PUNK RAP, RAGE CULTURE, AND ALT FASHION.',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Actions
|
||||
RiotzButton(
|
||||
label: 'Enter the Chaos',
|
||||
onPressed: () => context.go(AppRoutes.login),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
RiotzButton(
|
||||
label: 'Join the RIOT',
|
||||
style: RiotzButtonStyle.outline,
|
||||
onPressed: () => context.go(AppRoutes.signup),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_motion.dart';
|
||||
import '../../../../shared/widgets/riotz_shell.dart';
|
||||
|
||||
class RiotzThemePreviewPage extends StatefulWidget {
|
||||
const RiotzThemePreviewPage({super.key});
|
||||
|
||||
@override
|
||||
State<RiotzThemePreviewPage> createState() => _RiotzThemePreviewPageState();
|
||||
}
|
||||
|
||||
class _RiotzThemePreviewPageState extends State<RiotzThemePreviewPage> {
|
||||
bool _switchValue = true;
|
||||
bool _chipSelected = true;
|
||||
double _slider = 42;
|
||||
bool _animatedOn = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return RiotzShell(
|
||||
title: 'RIOTZ // Theme Preview',
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Typography', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text('Display Medium', style: theme.textTheme.displayMedium),
|
||||
Text('Headline Medium', style: theme.textTheme.headlineMedium),
|
||||
Text('Title Medium', style: theme.textTheme.titleMedium),
|
||||
Text('Body Medium example text', style: theme.textTheme.bodyMedium),
|
||||
Text('Label Large', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 18),
|
||||
Text('Color Tokens', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
_ColorSwatch(name: 'black', color: AppColors.black),
|
||||
_ColorSwatch(name: 'blackRaised', color: AppColors.blackRaised),
|
||||
_ColorSwatch(name: 'deepRed', color: AppColors.deepRed),
|
||||
_ColorSwatch(name: 'neonRed', color: AppColors.neonRed),
|
||||
_ColorSwatch(name: 'neonPurple', color: AppColors.neonPurple),
|
||||
_ColorSwatch(name: 'white', color: AppColors.white),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Buttons', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton(onPressed: () {}, child: const Text('Filled')),
|
||||
FilledButton.tonal(onPressed: () {}, child: const Text('Tonal')),
|
||||
OutlinedButton(onPressed: () {}, child: const Text('Outlined')),
|
||||
TextButton(onPressed: () {}, child: const Text('Text')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Inputs', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
const TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Username',
|
||||
hintText: 'riotz_name',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Bio',
|
||||
hintText: 'Underground frequencies only',
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Cards / Controls', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: _switchValue,
|
||||
onChanged: (v) => setState(() => _switchValue = v),
|
||||
title: const Text('Glitch mode'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
Slider(
|
||||
value: _slider,
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: _slider.round().toString(),
|
||||
onChanged: (v) => setState(() => _slider = v),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilterChip(
|
||||
label: const Text('punk-rap'),
|
||||
selected: _chipSelected,
|
||||
onSelected: (v) => setState(() => _chipSelected = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Motion', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: () => setState(() => _animatedOn = !_animatedOn),
|
||||
child: const Text('Trigger animation'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AnimatedContainer(
|
||||
duration: AppMotion.normal,
|
||||
curve: AppMotion.emphasizedCurve,
|
||||
height: 56,
|
||||
width: _animatedOn ? double.infinity : 140,
|
||||
decoration: BoxDecoration(
|
||||
color: _animatedOn ? AppColors.neonRed : AppColors.blackSoft,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_animatedOn ? 'Chaos On' : 'Chaos Idle',
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Snackbar', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('RIOTZ snack style preview')),
|
||||
);
|
||||
},
|
||||
child: const Text('Show Snackbar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorSwatch extends StatelessWidget {
|
||||
const _ColorSwatch({
|
||||
required this.name,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 110,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blackSoft,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(name, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/main.dart
Normal file
23
lib/main.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'core/config/supabase_config.dart';
|
||||
|
||||
/// RIOTZ - Underground Social & Music Platform
|
||||
///
|
||||
/// Main entry point for the application.
|
||||
/// Tech Stack: Flutter frontend, Supabase backend.
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize Supabase configuration
|
||||
// This expects SUPABASE_URL and SUPABASE_ANON_KEY to be provided via --dart-define
|
||||
await SupabaseConfig.initialize();
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: RiotzApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
35
lib/shared/widgets/riotz_shell.dart
Normal file
35
lib/shared/widgets/riotz_shell.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
class RiotzShell extends StatelessWidget {
|
||||
const RiotzShell({
|
||||
required this.child,
|
||||
this.title,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final String? title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: title == null
|
||||
? null
|
||||
: AppBar(
|
||||
title: Text(title!),
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.black, AppColors.blackRaised],
|
||||
),
|
||||
),
|
||||
child: SafeArea(child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user