123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- import 'package:flutter/material.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:dio/dio.dart';
- import 'package:cached_network_image/cached_network_image.dart';
- import 'package:flutter_cache_manager/flutter_cache_manager.dart';
- import 'package:veloe_kemono_party_flutter/models/creator.dart';
- import 'package:veloe_kemono_party_flutter/models/post.dart';
- import 'package:veloe_kemono_party_flutter/pages/posts_screen.dart';
- import 'package:veloe_kemono_party_flutter/pages/widgets/post_container.dart';
- import 'dart:convert';
- // Providers
- final searchQueryProvider = StateProvider<String>((ref) => '');
- final selectedCreatorProvider = StateProvider<Creator?>((ref) => null);
- final postsPageProvider = StateProvider<int>((ref) => 0);
- final kemonoClientProvider = Provider<KemonoClient>((ref) => KemonoClient());
- final creatorsProvider = FutureProvider<List<Creator>>((ref) async {
- final query = ref.watch(searchQueryProvider);
- final client = ref.read(kemonoClientProvider);
- final creators = await client.getCreators();
-
- return creators.where((creator) =>
- creator.name.toLowerCase().contains(query.toLowerCase()) ||
- creator.service.toLowerCase().contains(query.toLowerCase())
- ).toList();
- });
- final postsProvider = StateNotifierProvider.autoDispose
- .family<PostsNotifier, AsyncValue<List<Post>>, (String, String)>((ref, args) {
- final (creatorId, service) = args;
- return PostsNotifier(
- creatorId: creatorId,
- service: service,
- client: ref.read(kemonoClientProvider),
- );
- });
- class PostsNotifier extends StateNotifier<AsyncValue<List<Post>>> {
- final String creatorId;
- final String service;
- final KemonoClient client;
- int _currentPage = 0;
- PostsNotifier({
- required this.creatorId,
- required this.service,
- required this.client,
- }) : super(const AsyncValue.loading()) {
- loadInitial();
- }
- Future<void> loadInitial() async {
- state = const AsyncValue.loading();
- try {
- final posts = await client.getPosts(creatorId, service, 0);
- state = AsyncValue.data(posts);
- _currentPage = 1;
- } catch (e) {
- state = AsyncValue.error(e, StackTrace.current);
- }
- }
- Future<void> loadNext() async {
- if (state.isLoading) return;
-
- final previousPosts = state.valueOrNull ?? [];
- // Add explicit type parameter
- state = AsyncValue<List<Post>>.loading().copyWithPrevious(state);
-
- try {
- final newPosts = await client.getPosts(
- creatorId,
- service,
- _currentPage * 50
- );
- print('Loading page $_currentPage with offset ${_currentPage * 20}');
- state = AsyncValue.data([...previousPosts, ...newPosts]);
- _currentPage++;
- } catch (e) {
- // Specify type for error state
- state = AsyncValue<List<Post>>.error(e, StackTrace.current)
- .copyWithPrevious(AsyncValue.data(previousPosts));
- }
- }
- }
- // API Client
- class KemonoClient {
- final Dio _dio = Dio(BaseOptions(baseUrl: 'https://kemono.su/'));
- Future<List<Creator>> getCreators() async {
- try {
- final response = await _dio.get('/api/v1/creators.txt');
-
- if (response.statusCode != 200) {
- throw DioException(
- requestOptions: response.requestOptions,
- response: response,
- error: 'Unexpected status code: ${response.statusCode}'
- );
- }
- final parsedData = switch(response.data) {
- String s => jsonDecode(s),
- List<dynamic> list => list,
- Map<String,dynamic> map => [map],
- _ => throw FormatException('Unprocessable data')
- };
- return (parsedData as List).map((x) => Creator.fromJson(x)).toList();
- } on DioException catch (e) {
- throw Exception('Network error: ${e.message}');
- } on FormatException catch (e) {
- throw Exception('Data format error: ${e.message}');
- }
- }
- Future<List<Post>> getPosts(String creatorId, String service, int start) async {
- try {
- final response = await _dio.get(
- '/api/v1/$service/user/$creatorId',
- queryParameters: {'o': start},
- );
- final parsedData = switch(response.data) {
- String s => jsonDecode(s),
- List<dynamic> list => list,
- Map<String,dynamic> map => [map],
- _ => throw FormatException('Unprocessable data')
- };
- return (parsedData as List).map((x) => Post.fromJson(x)).toList();
- } on DioException catch (e) {
- throw Exception('Posts load failed: ${e.message}');
- } on Exception catch (e) {
- throw Exception('Oops something go wrong}');
- }
- }
- }
- // Image Caching
- final imageCacheManager = CacheManager(
- Config(
- 'kemono_images',
- stalePeriod: const Duration(days: 30),
- maxNrOfCacheObjects: 1024,
- fileService: HttpFileService(),
- ),
- );
- class ResizedImage extends StatelessWidget {
- final String imageUrl;
- final BoxFit fit;
- const ResizedImage({super.key, required this.imageUrl, this.fit = BoxFit.cover});
- @override
- Widget build(BuildContext context) {
- final screenSize = MediaQuery.of(context).size;
- return CachedNetworkImage(
- cacheManager: imageCacheManager,
- imageUrl: imageUrl,
- imageBuilder: (context, imageProvider) => Image(
- image: ResizeImage(
- imageProvider,
- width: screenSize.width.toInt(),
- ),
- fit: fit,
- ),
- placeholder: (context, url) => const CircularProgressIndicator(),
- errorWidget: (context, url, error) => const Icon(Icons.error),
- );
- }
- }
- // UI Components
- class CreatorCard extends ConsumerWidget {
- final Creator creator;
-
- const CreatorCard({super.key, required this.creator});
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- return Card(
- child: ListTile(
- leading: Hero(
- tag: 'creator-${creator.id}',
- child: CircleAvatar(
- backgroundColor: Colors.grey[200],
- child: ResizedImage(
- imageUrl: creator.iconUrl,
- fit: BoxFit.contain,
- ),
- ),
- ),
- title: Text(creator.name),
- subtitle: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(creator.service.toUpperCase()),
- Text('Last updated: ${_formatDate(creator.updated)}'),
- ],
- ),
- onTap: () => _showCreatorPosts(context, ref),
- ),
- );
- }
- String _formatDate(int timestamp) {
- return
- DateTime.fromMillisecondsSinceEpoch((timestamp * 1000).toInt()).toString();
- }
- void _showCreatorPosts(BuildContext context, WidgetRef ref) {
- ref.read(selectedCreatorProvider.notifier).state = creator;
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => PostsScreen(
- creatorId: creator.id,
- service: creator.service,
- ),
- ),
- );
- }
- }
- // Main Screens
- class HomeScreen extends ConsumerStatefulWidget {
- const HomeScreen({super.key});
- @override
- ConsumerState<HomeScreen> createState() => _HomeScreenState();
- }
- class _HomeScreenState extends ConsumerState<HomeScreen> {
- final TextEditingController _searchController = TextEditingController();
- final _searchQuery = StateProvider<String>((ref) => ''); // Added search state
- // Modified provider with search filtering
- final filteredCreatorsProvider = FutureProvider.autoDispose.family<List<Creator>, String>(
- (ref, query) async {
- final originalList = await ref.watch(creatorsProvider.future);
- return originalList.where(
- (creator) => creator.name.toLowerCase().contains(query.toLowerCase())
- ).toList();
- },
- );
- @override
- Widget build(BuildContext context) {
- return DefaultTabController(
- length: 2,
- child: Scaffold(
- appBar: AppBar(
- title: _buildSearchField(), // Replaced title with search field
- actions: [
- IconButton(
- icon: const Icon(Icons.refresh),
- onPressed: () => ref.invalidate(filteredCreatorsProvider),
- ),
- ],
- bottom: const TabBar(
- tabs: [
- Tab(icon: Icon(Icons.people_outline), text: 'Creators'),
- Tab(icon: Icon(Icons.grid_view), text: 'Posts'),
- ],
- ),
- ),
- body: TabBarView(
- children: [
- _buildCreatorsList(ref.watch(
- filteredCreatorsProvider(ref.watch(_searchQuery)) // Added query param
- )),
- _buildGlobalPostsGrid(),
- ],
- ),
- ),
- );
- }
- // New search field widget
- Widget _buildSearchField() {
- return TextField(
- controller: _searchController,
- decoration: InputDecoration(
- hintText: 'Search creators...',
- border: InputBorder.none,
- suffixIcon: IconButton(
- icon: const Icon(Icons.clear),
- onPressed: () {
- _searchController.clear();
- ref.read(_searchQuery.notifier).state = '';
- },
- ),
- ),
- onChanged: (value) => ref.read(_searchQuery.notifier).state = value,
- );
- }
- // Updated creators list with search
- Widget _buildCreatorsList(AsyncValue<List<Creator>> creatorsAsync) {
- return RefreshIndicator(
- onRefresh: () => ref.refresh(filteredCreatorsProvider(ref.read(_searchQuery)).future),
- child: creatorsAsync.when(
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, _) => Center(child: Text(error.toString())),
- data: (creators) => creators.isEmpty
- ? const Center(child: Text('No matching creators found'))
- : ListView.separated(
- padding: const EdgeInsets.all(16),
- itemCount: creators.length,
- separatorBuilder: (_, __) => const SizedBox(height: 8),
- itemBuilder: (context, index) => CreatorCard(creator: creators[index]),
- ),
- ),
- );
- }
- Widget _buildGlobalPostsGrid() {
- final postsAsync = ref.watch(postsProvider(('82522', 'patreon')));
- return postsAsync.when(
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, _) => Center(child: Text(error.toString())),
- data: (posts) => CustomScrollView(
- slivers: [
- SliverAppBar(),
- SliverList(
- delegate: SliverChildBuilderDelegate(
- (context, index) => PostContainer(post: posts[index]),
- childCount: posts.length,
- ),
- ),
- ],
- ),
- );
- }
- }
- void main() => runApp(
- const ProviderScope(
- child: MaterialApp(
- home: HomeScreen(),
- ),
- ),
- );
|