main.dart 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:dio/dio.dart';
  4. import 'package:cached_network_image/cached_network_image.dart';
  5. import 'package:flutter_cache_manager/flutter_cache_manager.dart';
  6. import 'package:veloe_kemono_party_flutter/models/creator.dart';
  7. import 'package:veloe_kemono_party_flutter/models/post.dart';
  8. import 'package:veloe_kemono_party_flutter/pages/posts_screen.dart';
  9. import 'package:veloe_kemono_party_flutter/pages/widgets/post_container.dart';
  10. import 'dart:convert';
  11. // Providers
  12. final searchQueryProvider = StateProvider<String>((ref) => '');
  13. final selectedCreatorProvider = StateProvider<Creator?>((ref) => null);
  14. final postsPageProvider = StateProvider<int>((ref) => 0);
  15. final kemonoClientProvider = Provider<KemonoClient>((ref) => KemonoClient());
  16. final creatorsProvider = FutureProvider<List<Creator>>((ref) async {
  17. final query = ref.watch(searchQueryProvider);
  18. final client = ref.read(kemonoClientProvider);
  19. final creators = await client.getCreators();
  20. return creators.where((creator) =>
  21. creator.name.toLowerCase().contains(query.toLowerCase()) ||
  22. creator.service.toLowerCase().contains(query.toLowerCase())
  23. ).toList();
  24. });
  25. final postsProvider = StateNotifierProvider.autoDispose
  26. .family<PostsNotifier, AsyncValue<List<Post>>, (String, String)>((ref, args) {
  27. final (creatorId, service) = args;
  28. return PostsNotifier(
  29. creatorId: creatorId,
  30. service: service,
  31. client: ref.read(kemonoClientProvider),
  32. );
  33. });
  34. class PostsNotifier extends StateNotifier<AsyncValue<List<Post>>> {
  35. final String creatorId;
  36. final String service;
  37. final KemonoClient client;
  38. int _currentPage = 0;
  39. PostsNotifier({
  40. required this.creatorId,
  41. required this.service,
  42. required this.client,
  43. }) : super(const AsyncValue.loading()) {
  44. loadInitial();
  45. }
  46. Future<void> loadInitial() async {
  47. state = const AsyncValue.loading();
  48. try {
  49. final posts = await client.getPosts(creatorId, service, 0);
  50. state = AsyncValue.data(posts);
  51. _currentPage = 1;
  52. } catch (e) {
  53. state = AsyncValue.error(e, StackTrace.current);
  54. }
  55. }
  56. Future<void> loadNext() async {
  57. if (state.isLoading) return;
  58. final previousPosts = state.valueOrNull ?? [];
  59. // Add explicit type parameter
  60. state = AsyncValue<List<Post>>.loading().copyWithPrevious(state);
  61. try {
  62. final newPosts = await client.getPosts(
  63. creatorId,
  64. service,
  65. _currentPage * 50
  66. );
  67. print('Loading page $_currentPage with offset ${_currentPage * 20}');
  68. state = AsyncValue.data([...previousPosts, ...newPosts]);
  69. _currentPage++;
  70. } catch (e) {
  71. // Specify type for error state
  72. state = AsyncValue<List<Post>>.error(e, StackTrace.current)
  73. .copyWithPrevious(AsyncValue.data(previousPosts));
  74. }
  75. }
  76. }
  77. // API Client
  78. class KemonoClient {
  79. final Dio _dio = Dio(BaseOptions(baseUrl: 'https://kemono.su/'));
  80. Future<List<Creator>> getCreators() async {
  81. try {
  82. final response = await _dio.get('/api/v1/creators.txt');
  83. if (response.statusCode != 200) {
  84. throw DioException(
  85. requestOptions: response.requestOptions,
  86. response: response,
  87. error: 'Unexpected status code: ${response.statusCode}'
  88. );
  89. }
  90. final parsedData = switch(response.data) {
  91. String s => jsonDecode(s),
  92. List<dynamic> list => list,
  93. Map<String,dynamic> map => [map],
  94. _ => throw FormatException('Unprocessable data')
  95. };
  96. return (parsedData as List).map((x) => Creator.fromJson(x)).toList();
  97. } on DioException catch (e) {
  98. throw Exception('Network error: ${e.message}');
  99. } on FormatException catch (e) {
  100. throw Exception('Data format error: ${e.message}');
  101. }
  102. }
  103. Future<List<Post>> getPosts(String creatorId, String service, int start) async {
  104. try {
  105. final response = await _dio.get(
  106. '/api/v1/$service/user/$creatorId',
  107. queryParameters: {'o': start},
  108. );
  109. final parsedData = switch(response.data) {
  110. String s => jsonDecode(s),
  111. List<dynamic> list => list,
  112. Map<String,dynamic> map => [map],
  113. _ => throw FormatException('Unprocessable data')
  114. };
  115. return (parsedData as List).map((x) => Post.fromJson(x)).toList();
  116. } on DioException catch (e) {
  117. throw Exception('Posts load failed: ${e.message}');
  118. } on Exception catch (e) {
  119. throw Exception('Oops something go wrong}');
  120. }
  121. }
  122. }
  123. // Image Caching
  124. final imageCacheManager = CacheManager(
  125. Config(
  126. 'kemono_images',
  127. stalePeriod: const Duration(days: 30),
  128. maxNrOfCacheObjects: 1024,
  129. fileService: HttpFileService(),
  130. ),
  131. );
  132. class ResizedImage extends StatelessWidget {
  133. final String imageUrl;
  134. final BoxFit fit;
  135. const ResizedImage({super.key, required this.imageUrl, this.fit = BoxFit.cover});
  136. @override
  137. Widget build(BuildContext context) {
  138. final screenSize = MediaQuery.of(context).size;
  139. return CachedNetworkImage(
  140. cacheManager: imageCacheManager,
  141. imageUrl: imageUrl,
  142. imageBuilder: (context, imageProvider) => Image(
  143. image: ResizeImage(
  144. imageProvider,
  145. width: screenSize.width.toInt(),
  146. ),
  147. fit: fit,
  148. ),
  149. placeholder: (context, url) => const CircularProgressIndicator(),
  150. errorWidget: (context, url, error) => const Icon(Icons.error),
  151. );
  152. }
  153. }
  154. // UI Components
  155. class CreatorCard extends ConsumerWidget {
  156. final Creator creator;
  157. const CreatorCard({super.key, required this.creator});
  158. @override
  159. Widget build(BuildContext context, WidgetRef ref) {
  160. return Card(
  161. child: ListTile(
  162. leading: Hero(
  163. tag: 'creator-${creator.id}',
  164. child: CircleAvatar(
  165. backgroundColor: Colors.grey[200],
  166. child: ResizedImage(
  167. imageUrl: creator.iconUrl,
  168. fit: BoxFit.contain,
  169. ),
  170. ),
  171. ),
  172. title: Text(creator.name),
  173. subtitle: Column(
  174. crossAxisAlignment: CrossAxisAlignment.start,
  175. children: [
  176. Text(creator.service.toUpperCase()),
  177. Text('Last updated: ${_formatDate(creator.updated)}'),
  178. ],
  179. ),
  180. onTap: () => _showCreatorPosts(context, ref),
  181. ),
  182. );
  183. }
  184. String _formatDate(int timestamp) {
  185. return
  186. DateTime.fromMillisecondsSinceEpoch((timestamp * 1000).toInt()).toString();
  187. }
  188. void _showCreatorPosts(BuildContext context, WidgetRef ref) {
  189. ref.read(selectedCreatorProvider.notifier).state = creator;
  190. Navigator.push(
  191. context,
  192. MaterialPageRoute(
  193. builder: (context) => PostsScreen(
  194. creatorId: creator.id,
  195. service: creator.service,
  196. ),
  197. ),
  198. );
  199. }
  200. }
  201. // Main Screens
  202. class HomeScreen extends ConsumerStatefulWidget {
  203. const HomeScreen({super.key});
  204. @override
  205. ConsumerState<HomeScreen> createState() => _HomeScreenState();
  206. }
  207. class _HomeScreenState extends ConsumerState<HomeScreen> {
  208. final TextEditingController _searchController = TextEditingController();
  209. final _searchQuery = StateProvider<String>((ref) => ''); // Added search state
  210. // Modified provider with search filtering
  211. final filteredCreatorsProvider = FutureProvider.autoDispose.family<List<Creator>, String>(
  212. (ref, query) async {
  213. final originalList = await ref.watch(creatorsProvider.future);
  214. return originalList.where(
  215. (creator) => creator.name.toLowerCase().contains(query.toLowerCase())
  216. ).toList();
  217. },
  218. );
  219. @override
  220. Widget build(BuildContext context) {
  221. return DefaultTabController(
  222. length: 2,
  223. child: Scaffold(
  224. appBar: AppBar(
  225. title: _buildSearchField(), // Replaced title with search field
  226. actions: [
  227. IconButton(
  228. icon: const Icon(Icons.refresh),
  229. onPressed: () => ref.invalidate(filteredCreatorsProvider),
  230. ),
  231. ],
  232. bottom: const TabBar(
  233. tabs: [
  234. Tab(icon: Icon(Icons.people_outline), text: 'Creators'),
  235. Tab(icon: Icon(Icons.grid_view), text: 'Posts'),
  236. ],
  237. ),
  238. ),
  239. body: TabBarView(
  240. children: [
  241. _buildCreatorsList(ref.watch(
  242. filteredCreatorsProvider(ref.watch(_searchQuery)) // Added query param
  243. )),
  244. _buildGlobalPostsGrid(),
  245. ],
  246. ),
  247. ),
  248. );
  249. }
  250. // New search field widget
  251. Widget _buildSearchField() {
  252. return TextField(
  253. controller: _searchController,
  254. decoration: InputDecoration(
  255. hintText: 'Search creators...',
  256. border: InputBorder.none,
  257. suffixIcon: IconButton(
  258. icon: const Icon(Icons.clear),
  259. onPressed: () {
  260. _searchController.clear();
  261. ref.read(_searchQuery.notifier).state = '';
  262. },
  263. ),
  264. ),
  265. onChanged: (value) => ref.read(_searchQuery.notifier).state = value,
  266. );
  267. }
  268. // Updated creators list with search
  269. Widget _buildCreatorsList(AsyncValue<List<Creator>> creatorsAsync) {
  270. return RefreshIndicator(
  271. onRefresh: () => ref.refresh(filteredCreatorsProvider(ref.read(_searchQuery)).future),
  272. child: creatorsAsync.when(
  273. loading: () => const Center(child: CircularProgressIndicator()),
  274. error: (error, _) => Center(child: Text(error.toString())),
  275. data: (creators) => creators.isEmpty
  276. ? const Center(child: Text('No matching creators found'))
  277. : ListView.separated(
  278. padding: const EdgeInsets.all(16),
  279. itemCount: creators.length,
  280. separatorBuilder: (_, __) => const SizedBox(height: 8),
  281. itemBuilder: (context, index) => CreatorCard(creator: creators[index]),
  282. ),
  283. ),
  284. );
  285. }
  286. Widget _buildGlobalPostsGrid() {
  287. final postsAsync = ref.watch(postsProvider(('82522', 'patreon')));
  288. return postsAsync.when(
  289. loading: () => const Center(child: CircularProgressIndicator()),
  290. error: (error, _) => Center(child: Text(error.toString())),
  291. data: (posts) => CustomScrollView(
  292. slivers: [
  293. SliverAppBar(),
  294. SliverList(
  295. delegate: SliverChildBuilderDelegate(
  296. (context, index) => PostContainer(post: posts[index]),
  297. childCount: posts.length,
  298. ),
  299. ),
  300. ],
  301. ),
  302. );
  303. }
  304. }
  305. void main() => runApp(
  306. const ProviderScope(
  307. child: MaterialApp(
  308. home: HomeScreen(),
  309. ),
  310. ),
  311. );