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