|
@@ -1,13 +1,14 @@
|
|
|
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/kemono_client.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/creator_card.dart';
|
|
|
import 'package:veloe_kemono_party_flutter/pages/widgets/post_container.dart';
|
|
|
-import 'dart:convert';
|
|
|
+
|
|
|
+import 'package:veloe_kemono_party_flutter/posts_notifier.dart';
|
|
|
|
|
|
// Providers
|
|
|
final searchQueryProvider = StateProvider<String>((ref) => '');
|
|
@@ -37,109 +38,6 @@ final postsProvider = StateNotifierProvider.autoDispose
|
|
|
);
|
|
|
});
|
|
|
|
|
|
-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(
|
|
@@ -149,85 +47,94 @@ final imageCacheManager = CacheManager(
|
|
|
fileService: HttpFileService(),
|
|
|
),
|
|
|
);
|
|
|
+// Navigation Items Model
|
|
|
+class NavItem {
|
|
|
+ final String label;
|
|
|
+ final IconData icon;
|
|
|
+ final Widget page;
|
|
|
+
|
|
|
+ const NavItem({
|
|
|
+ required this.label,
|
|
|
+ required this.icon,
|
|
|
+ required this.page,
|
|
|
+ });
|
|
|
+}
|
|
|
|
|
|
-class ResizedImage extends StatelessWidget {
|
|
|
- final String imageUrl;
|
|
|
- final BoxFit fit;
|
|
|
-
|
|
|
- const ResizedImage({super.key, required this.imageUrl, this.fit = BoxFit.cover});
|
|
|
+// App Layout with Navigation
|
|
|
+class AppLayout extends ConsumerStatefulWidget {
|
|
|
+ final List<NavItem> navItems;
|
|
|
+
|
|
|
+ const AppLayout({super.key, required this.navItems});
|
|
|
|
|
|
@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),
|
|
|
- );
|
|
|
- }
|
|
|
+ ConsumerState<AppLayout> createState() => _AppLayoutState();
|
|
|
}
|
|
|
|
|
|
-// UI Components
|
|
|
-class CreatorCard extends ConsumerWidget {
|
|
|
- final Creator creator;
|
|
|
-
|
|
|
- const CreatorCard({super.key, required this.creator});
|
|
|
+class _AppLayoutState extends ConsumerState<AppLayout> {
|
|
|
+ final _navIndex = StateProvider<int>((ref) => 0);
|
|
|
+
|
|
|
+ Widget _buildDesktopNavRail(int selectedIndex) {
|
|
|
+ return NavigationRail(
|
|
|
+ selectedIndex: selectedIndex,
|
|
|
+ onDestinationSelected: (index) =>
|
|
|
+ ref.read(_navIndex.notifier).state = index,
|
|
|
+ labelType: NavigationRailLabelType.all,
|
|
|
+ destinations: widget.navItems.map((item) =>
|
|
|
+ NavigationRailDestination(
|
|
|
+ icon: Icon(item.icon),
|
|
|
+ label: Text(item.label),
|
|
|
+ )).toList(),
|
|
|
+ );
|
|
|
+ }
|
|
|
|
|
|
- @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,
|
|
|
- ),
|
|
|
+ Widget _buildMobileDrawer(int selectedIndex) {
|
|
|
+ return Drawer(
|
|
|
+ child: ListView(
|
|
|
+ children: [
|
|
|
+ const DrawerHeader(
|
|
|
+ decoration: BoxDecoration(color: Colors.blue),
|
|
|
+ child: Text('Navigation', style: TextStyle(color: Colors.white)),
|
|
|
),
|
|
|
- ),
|
|
|
- title: Text(creator.name),
|
|
|
- subtitle: Column(
|
|
|
- crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
- children: [
|
|
|
- Text(creator.service.toUpperCase()),
|
|
|
- Text('Last updated: ${_formatDate(creator.updated)}'),
|
|
|
- ],
|
|
|
- ),
|
|
|
- onTap: () => _showCreatorPosts(context, ref),
|
|
|
+ ...widget.navItems.asMap().entries.map((entry) => ListTile(
|
|
|
+ leading: Icon(entry.value.icon),
|
|
|
+ title: Text(entry.value.label),
|
|
|
+ selected: entry.key == selectedIndex,
|
|
|
+ onTap: () {
|
|
|
+ ref.read(_navIndex.notifier).state = entry.key;
|
|
|
+ Navigator.pop(context);
|
|
|
+ },
|
|
|
+ )),
|
|
|
+ ],
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- 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,
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ final selectedIndex = ref.watch(_navIndex);
|
|
|
+ final isDesktop = MediaQuery.of(context).size.width >= 600;
|
|
|
+
|
|
|
+ return Scaffold(
|
|
|
+ appBar: AppBar(
|
|
|
+ title: Text(widget.navItems[selectedIndex].label),
|
|
|
+ leading: isDesktop ? null : Builder(
|
|
|
+ builder: (context) => IconButton(
|
|
|
+ icon: const Icon(Icons.menu),
|
|
|
+ onPressed: () => Scaffold.of(context).openDrawer(),
|
|
|
+ ),
|
|
|
),
|
|
|
),
|
|
|
+ drawer: isDesktop ? null : _buildMobileDrawer(selectedIndex),
|
|
|
+ body: Row(
|
|
|
+ children: [
|
|
|
+ if (isDesktop) _buildDesktopNavRail(selectedIndex),
|
|
|
+ Expanded(child: widget.navItems[selectedIndex].page),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// Main Screens
|
|
|
class HomeScreen extends ConsumerStatefulWidget {
|
|
|
const HomeScreen({super.key});
|
|
|
|
|
@@ -262,21 +169,10 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|
|
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(
|
|
|
+ body: _buildCreatorsList(ref.watch(
|
|
|
filteredCreatorsProvider(ref.watch(_searchQuery)) // Added query param
|
|
|
- )),
|
|
|
- _buildGlobalPostsGrid(),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ )),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
@@ -340,10 +236,29 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// Main Application
|
|
|
void main() => runApp(
|
|
|
const ProviderScope(
|
|
|
child: MaterialApp(
|
|
|
- home: HomeScreen(),
|
|
|
+ home: AppLayout(
|
|
|
+ navItems: [
|
|
|
+ NavItem(
|
|
|
+ label: 'Home',
|
|
|
+ icon: Icons.home,
|
|
|
+ page: HomeScreen(),
|
|
|
+ ),
|
|
|
+ NavItem(
|
|
|
+ label: 'Global Feed (Rukis)',
|
|
|
+ icon: Icons.public,
|
|
|
+ page: PostsScreen( creatorId:'82522', service: 'patreon', withAppBar: false,),
|
|
|
+ ),
|
|
|
+ NavItem(
|
|
|
+ label: 'Settings',
|
|
|
+ icon: Icons.settings,
|
|
|
+ page: Placeholder(), // Replace with actual SettingsScreen
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
),
|
|
|
),
|
|
|
);
|