first commit
This commit is contained in:
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user