first commit

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

View File

@@ -0,0 +1,136 @@
import 'dart:typed_data';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../domain/models/profile_model.dart';
import '../../domain/models/profile_stats_model.dart';
class ProfileService {
const ProfileService(this._client);
final SupabaseClient _client;
User get _currentUser {
final user = _client.auth.currentUser;
if (user == null) {
throw StateError('No authenticated user');
}
return user;
}
Future<ProfileModel> fetchMyProfile() async {
final user = _currentUser;
final response = await _client
.from('profiles')
.select()
.eq('user_id', user.id)
.maybeSingle();
if (response == null) {
final created = await _client
.from('profiles')
.insert({
'user_id': user.id,
'username': (user.userMetadata?['username'] as String?) ?? '',
'bio': '',
'avatar_url': '',
})
.select()
.single();
return ProfileModel.fromJson(created);
}
return ProfileModel.fromJson(response);
}
Future<ProfileModel> updateUsername(String username) async {
final user = _currentUser;
await _client.auth.updateUser(UserAttributes(data: {'username': username}));
final updated = await _client
.from('profiles')
.update({'username': username})
.eq('user_id', user.id)
.select()
.single();
return ProfileModel.fromJson(updated);
}
Future<ProfileModel> updateBio(String bio) async {
final user = _currentUser;
final updated = await _client
.from('profiles')
.update({'bio': bio})
.eq('user_id', user.id)
.select()
.single();
return ProfileModel.fromJson(updated);
}
Future<ProfileModel> updateProfile({
required String username,
required String bio,
}) async {
final user = _currentUser;
await _client.auth.updateUser(UserAttributes(data: {'username': username}));
final updated = await _client
.from('profiles')
.update({
'username': username,
'bio': bio,
})
.eq('user_id', user.id)
.select()
.single();
return ProfileModel.fromJson(updated);
}
Future<ProfileModel> uploadAvatar({
required Uint8List bytes,
required String extension,
}) async {
final user = _currentUser;
final path =
'avatars/${user.id}/${DateTime.now().millisecondsSinceEpoch}.$extension';
await _client.storage.from('avatars').uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final avatarUrl = _client.storage.from('avatars').getPublicUrl(path);
final updated = await _client
.from('profiles')
.update({'avatar_url': avatarUrl})
.eq('user_id', user.id)
.select()
.single();
return ProfileModel.fromJson(updated);
}
Future<ProfileStatsModel> fetchMyStats() async {
final user = _currentUser;
final postResponse = await _client
.from('posts')
.select('*')
.eq('user_id', user.id);
final commentResponse = await _client
.from('comments')
.select('*')
.eq('user_id', user.id);
final trackResponse = await _client
.from('tracks')
.select('*')
.eq('user_id', user.id);
return ProfileStatsModel(
postsCount: (postResponse as List).length,
commentsCount: (commentResponse as List).length,
tracksCount: (trackResponse as List).length,
);
}
}

View File

@@ -0,0 +1,35 @@
class ProfileModel {
const ProfileModel({
required this.userId,
required this.username,
required this.bio,
required this.avatarUrl,
});
final String userId;
final String username;
final String bio;
final String avatarUrl;
factory ProfileModel.fromJson(Map<String, dynamic> json) {
return ProfileModel(
userId: (json['user_id'] as String?) ?? '',
username: (json['username'] as String?) ?? '',
bio: (json['bio'] as String?) ?? '',
avatarUrl: (json['avatar_url'] as String?) ?? '',
);
}
ProfileModel copyWith({
String? username,
String? bio,
String? avatarUrl,
}) {
return ProfileModel(
userId: userId,
username: username ?? this.username,
bio: bio ?? this.bio,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
}

View File

@@ -0,0 +1,11 @@
class ProfileStatsModel {
const ProfileStatsModel({
required this.postsCount,
required this.commentsCount,
required this.tracksCount,
});
final int postsCount;
final int commentsCount;
final int tracksCount;
}

View File

@@ -0,0 +1,283 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/riotz_scaffold.dart';
import '../../../../core/widgets/riotz_button.dart';
import '../../../auth/presentation/providers/auth_provider.dart';
import '../../../music/presentation/providers/music_providers.dart';
import '../../../music/presentation/widgets/track_card.dart';
import '../../domain/models/profile_model.dart';
import '../../domain/models/profile_stats_model.dart';
import '../providers/profile_providers.dart';
class ProfilePage extends ConsumerStatefulWidget {
const ProfilePage({super.key});
@override
ConsumerState<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends ConsumerState<ProfilePage> with SingleTickerProviderStateMixin {
late TabController _tabController;
final _usernameController = TextEditingController();
final _bioController = TextEditingController();
final _picker = ImagePicker();
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
_usernameController.dispose();
_bioController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
ref.listen(profileControllerProvider, (_, next) {
next.whenOrNull(
data: (_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
backgroundColor: AppColors.success,
content: Text('IDENTITY UPDATED.', style: TextStyle(color: AppColors.black, fontWeight: FontWeight.bold)),
),
);
ref.invalidate(myProfileStatsProvider);
},
error: (error, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: AppColors.bloodRed,
content: Text(error.toString().toUpperCase()),
),
);
},
);
});
final profileAsync = ref.watch(myProfileProvider);
final statsAsync = ref.watch(myProfileStatsProvider);
final updating = ref.watch(profileControllerProvider).isLoading;
final currentUser = ref.watch(authServiceProvider).currentUser;
return RiotzScaffold(
appBar: AppBar(
title: const Text('RIOTZ // IDENTITY'),
),
body: profileAsync.when(
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
error: (error, _) => Center(child: Text('ERROR: $error')),
data: (profile) {
_syncControllers(profile);
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
children: [
// Profile Header
Center(
child: Stack(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
border: Border.all(color: AppColors.neonRed, width: 2),
color: AppColors.surfaceLight,
),
child: profile.avatarUrl.isNotEmpty
? Image.network(profile.avatarUrl, fit: BoxFit.cover)
: const Icon(Icons.person, size: 60, color: AppColors.grey),
),
Positioned(
right: -4,
bottom: -4,
child: IconButton(
onPressed: updating ? null : _pickAndUploadAvatar,
icon: Container(
padding: const EdgeInsets.all(4),
color: AppColors.neonRed,
child: const Icon(Icons.edit, color: AppColors.white, size: 18),
),
),
),
],
),
),
const SizedBox(height: 32),
_StatsSection(statsAsync: statsAsync),
const SizedBox(height: 32),
// Tabs
TabBar(
controller: _tabController,
indicatorColor: AppColors.neonRed,
labelColor: AppColors.white,
unselectedLabelColor: AppColors.grey,
tabs: const [
Tab(text: 'IDENTITY'),
Tab(text: 'TRACKS'),
],
),
const SizedBox(height: 24),
SizedBox(
height: 400, // Fixed height for tab content in ListView
child: TabBarView(
controller: _tabController,
children: [
// Identity Tab
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('USERNAME', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey)),
const SizedBox(height: 8),
TextField(
controller: _usernameController,
decoration: const InputDecoration(hintText: 'ENTER ALIAS'),
),
const SizedBox(height: 24),
Text('BIO // MANIFESTO', style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey)),
const SizedBox(height: 8),
TextField(
controller: _bioController,
decoration: const InputDecoration(hintText: 'DESCRIBE THE CHAOS'),
minLines: 3,
maxLines: 5,
),
const SizedBox(height: 32),
RiotzButton(
label: 'UPDATE IDENTITY',
isLoading: updating,
onPressed: () async {
await ref
.read(profileControllerProvider.notifier)
.saveProfile(
username: _usernameController.text,
bio: _bioController.text,
);
},
),
],
),
),
// Tracks Tab
currentUser == null
? const Center(child: Text('LOG IN TO SEE TRACKS'))
: Consumer(
builder: (context, ref, child) {
final tracksAsync = ref.watch(userTracksProvider(currentUser.id));
return tracksAsync.when(
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
error: (err, _) => Center(child: Text('ERROR: $err')),
data: (tracks) {
if (tracks.isEmpty) {
return const Center(child: Text('NO TRACKS BROADCASTED.'));
}
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: tracks.length,
itemBuilder: (context, index) => TrackCard(track: tracks[index]),
);
},
);
},
),
],
),
),
],
),
),
],
);
},
),
);
}
void _syncControllers(ProfileModel profile) {
if (_usernameController.text != profile.username) {
_usernameController.text = profile.username;
}
if (_bioController.text != profile.bio) {
_bioController.text = profile.bio;
}
}
Future<void> _pickAndUploadAvatar() async {
final picked = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 85,
maxWidth: 1200,
);
if (picked == null) return;
final bytes = await picked.readAsBytes();
final ext = picked.name.split('.').last.toLowerCase();
if (!mounted) return;
await ref.read(profileControllerProvider.notifier).uploadAvatar(
bytes: bytes,
extension: ext.isEmpty ? 'jpg' : ext,
);
}
}
class _StatsSection extends StatelessWidget {
const _StatsSection({required this.statsAsync});
final AsyncValue<ProfileStatsModel> statsAsync;
@override
Widget build(BuildContext context) {
return statsAsync.when(
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.neonRed)),
error: (error, _) => Text('STATS OFFLINE: $error'),
data: (stats) => Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(label: 'POSTS', value: stats.postsCount.toString()),
_StatItem(label: 'CHAOS', value: stats.commentsCount.toString()),
_StatItem(label: 'TRACKS', value: stats.tracksCount.toString()),
],
),
);
}
}
class _StatItem extends StatelessWidget {
const _StatItem({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
Text(value, style: theme.textTheme.headlineLarge),
Text(label, style: theme.textTheme.labelLarge?.copyWith(color: AppColors.grey, fontSize: 10)),
],
);
}
}

View File

@@ -0,0 +1,75 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/supabase/supabase_providers.dart';
import '../../data/services/profile_service.dart';
import '../../domain/models/profile_model.dart';
import '../../domain/models/profile_stats_model.dart';
final profileServiceProvider = Provider<ProfileService>((ref) {
final client = ref.watch(supabaseProvider);
return ProfileService(client);
});
final myProfileProvider = FutureProvider<ProfileModel>((ref) {
return ref.watch(profileServiceProvider).fetchMyProfile();
});
final myProfileStatsProvider = FutureProvider<ProfileStatsModel>((ref) {
return ref.watch(profileServiceProvider).fetchMyStats();
});
final profileControllerProvider =
AutoDisposeAsyncNotifierProvider<ProfileController, void>(
ProfileController.new,
);
class ProfileController extends AutoDisposeAsyncNotifier<void> {
@override
Future<void> build() async {}
Future<void> updateUsername(String username) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(profileServiceProvider).updateUsername(username.trim());
ref.invalidate(myProfileProvider);
});
}
Future<void> updateBio(String bio) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(profileServiceProvider).updateBio(bio.trim());
ref.invalidate(myProfileProvider);
});
}
Future<void> uploadAvatar({
required Uint8List bytes,
required String extension,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(profileServiceProvider).uploadAvatar(
bytes: bytes,
extension: extension,
);
ref.invalidate(myProfileProvider);
});
}
Future<void> saveProfile({
required String username,
required String bio,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(profileServiceProvider).updateProfile(
username: username.trim(),
bio: bio.trim(),
);
ref.invalidate(myProfileProvider);
});
}
}