diff --git a/lib/modules/more/statistics/statistics_provider.dart b/lib/modules/more/statistics/statistics_provider.dart new file mode 100644 index 00000000..f7cc062d --- /dev/null +++ b/lib/modules/more/statistics/statistics_provider.dart @@ -0,0 +1,47 @@ +import 'package:isar/isar.dart'; +import 'package:mangayomi/main.dart'; +import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/models/download.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'statistics_provider.g.dart'; + +@riverpod +class StatisticsState extends _$StatisticsState { + @override + void build(ItemType itemType) {} + final items = + isar.mangas.filter().idIsNotNull().favoriteEqualTo(true).findAllSync(); + final chapters = + isar.chapters + .filter() + .idIsNotNull() + .manga((q) => q.favoriteEqualTo(true)) + .findAllSync(); + int get totalItems => items.where((i) => i.itemType == itemType).length; + int get totalChapters => + chapters.where((i) => i.manga.value!.itemType == itemType).length; + int get readChapters => + chapters + .where( + (i) => i.manga.value!.itemType == itemType && (i.isRead ?? false), + ) + .length; + int get completedItems => + items + .where( + (i) => i.itemType == itemType && (i.status == Status.completed), + ) + .where((e) => e.chapters.every((element) => element.isRead ?? false)) + .length; + + int get downloadedItems => + isar.downloads + .filter() + .idIsNotNull() + .chapter((q) => q.manga((m) => m.itemTypeEqualTo(itemType))) + .chapter((q) => q.manga((m) => m.favoriteEqualTo(true))) + .isDownloadEqualTo(true) + .findAllSync() + .length; +} diff --git a/lib/modules/more/statistics/statistics_provider.g.dart b/lib/modules/more/statistics/statistics_provider.g.dart new file mode 100644 index 00000000..9f7b7e10 --- /dev/null +++ b/lib/modules/more/statistics/statistics_provider.g.dart @@ -0,0 +1,174 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'statistics_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$statisticsStateHash() => r'6c94816bb70881890bc883480677e38885fa6ab2'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$StatisticsState extends BuildlessAutoDisposeNotifier { + late final ItemType itemType; + + void build( + ItemType itemType, + ); +} + +/// See also [StatisticsState]. +@ProviderFor(StatisticsState) +const statisticsStateProvider = StatisticsStateFamily(); + +/// See also [StatisticsState]. +class StatisticsStateFamily extends Family { + /// See also [StatisticsState]. + const StatisticsStateFamily(); + + /// See also [StatisticsState]. + StatisticsStateProvider call( + ItemType itemType, + ) { + return StatisticsStateProvider( + itemType, + ); + } + + @override + StatisticsStateProvider getProviderOverride( + covariant StatisticsStateProvider provider, + ) { + return call( + provider.itemType, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'statisticsStateProvider'; +} + +/// See also [StatisticsState]. +class StatisticsStateProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [StatisticsState]. + StatisticsStateProvider( + ItemType itemType, + ) : this._internal( + () => StatisticsState()..itemType = itemType, + from: statisticsStateProvider, + name: r'statisticsStateProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$statisticsStateHash, + dependencies: StatisticsStateFamily._dependencies, + allTransitiveDependencies: + StatisticsStateFamily._allTransitiveDependencies, + itemType: itemType, + ); + + StatisticsStateProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.itemType, + }) : super.internal(); + + final ItemType itemType; + + @override + void runNotifierBuild( + covariant StatisticsState notifier, + ) { + return notifier.build( + itemType, + ); + } + + @override + Override overrideWith(StatisticsState Function() create) { + return ProviderOverride( + origin: this, + override: StatisticsStateProvider._internal( + () => create()..itemType = itemType, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + itemType: itemType, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement createElement() { + return _StatisticsStateProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is StatisticsStateProvider && other.itemType == itemType; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, itemType.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin StatisticsStateRef on AutoDisposeNotifierProviderRef { + /// The parameter `itemType` of this provider. + ItemType get itemType; +} + +class _StatisticsStateProviderElement + extends AutoDisposeNotifierProviderElement + with StatisticsStateRef { + _StatisticsStateProviderElement(super.provider); + + @override + ItemType get itemType => (origin as StatisticsStateProvider).itemType; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/modules/more/statistics/statistics_screen.dart b/lib/modules/more/statistics/statistics_screen.dart index 253ed2fa..4135aca1 100644 --- a/lib/modules/more/statistics/statistics_screen.dart +++ b/lib/modules/more/statistics/statistics_screen.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:isar/isar.dart'; -import 'package:mangayomi/main.dart'; -import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; +import 'package:mangayomi/modules/more/statistics/statistics_provider.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; class StatisticsScreen extends ConsumerStatefulWidget { const StatisticsScreen({super.key}); @@ -13,145 +13,298 @@ class StatisticsScreen extends ConsumerStatefulWidget { ConsumerState createState() => _StatisticsScreenState(); } -class _StatisticsScreenState extends ConsumerState { +class _StatisticsScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final hideItems = ref.watch(hideItemsStateProvider); + late TabController _tabController; + + late final _tabList = [ + if (!hideItems.contains("/MangaLibrary")) 'manga', + if (!hideItems.contains("/AnimeLibrary")) 'anime', + if (!hideItems.contains("/NovelLibrary")) 'novel', + ]; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _tabController = TabController(length: _tabList.length, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + if (_tabList.isEmpty) { + return SizedBox.shrink(); + } final l10n = context.l10n; - final items = isar.mangas.filter().idIsNotNull().findAllSync(); - final chapters = isar.chapters.filter().idIsNotNull().findAllSync(); return Scaffold( - appBar: AppBar(title: Text(l10n.statistics)), - body: SingleChildScrollView( - child: Column( - spacing: 3, + appBar: AppBar( + title: Text(l10n.statistics), + bottom: TabBar( + controller: _tabController, + tabs: [ + if (!hideItems.contains("/MangaLibrary")) Tab(text: "Manga"), + if (!hideItems.contains("/AnimeLibrary")) Tab(text: "Anime"), + if (!hideItems.contains("/NovelLibrary")) Tab(text: "Novel"), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + if (!hideItems.contains("/MangaLibrary")) + _buildStatisticsTab(itemType: ItemType.manga), + if (!hideItems.contains("/AnimeLibrary")) + _buildStatisticsTab(itemType: ItemType.anime), + if (!hideItems.contains("/NovelLibrary")) + _buildStatisticsTab(itemType: ItemType.novel), + ], + ), + ); + } + + Widget _buildStatisticsTab({required ItemType itemType}) { + final l10n = context.l10n; + final stats = ref.watch(statisticsStateProvider(itemType).notifier); + + final title = switch (itemType) { + ItemType.manga => l10n.manga, + ItemType.anime => l10n.anime, + _ => l10n.novel, + }; + + final chapterLabel = switch (itemType) { + ItemType.manga => l10n.chapters, + ItemType.anime => l10n.episodes, + _ => l10n.chapters, + }; + final unreadLabel = switch (itemType) { + ItemType.manga => l10n.unread, + ItemType.anime => l10n.unwatched, + _ => l10n.unread, + }; + + final totalItems = stats.totalItems; + final totalChapters = stats.totalChapters; + final readChapters = stats.readChapters; + final unreadChapters = totalChapters - readChapters; + final completedItems = stats.completedItems; + final downloadedItems = stats.downloadedItems; + + final averageChapters = totalItems > 0 ? totalChapters / totalItems : 0; + final readPercentage = + totalChapters > 0 ? (readChapters / totalChapters) * 100 : 0; + final completedPercentage = + totalItems > 0 ? (completedItems / totalItems) * 100 : 0; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionHeader('Entries'), + _buildEntriesCard( + totalItems: totalItems, + completedItems: completedItems, + completedPercentage: completedPercentage.toDouble(), + ), + const SizedBox(height: 10), + _buildSectionHeader(chapterLabel), + _buildChaptersCard( + totalChapters: totalChapters, + readChapters: readChapters, + unreadChapters: unreadChapters, + downloadedItems: downloadedItems, + averageChapters: averageChapters.toDouble(), + readPercentage: readPercentage.toDouble(), + title: title, + context: context, + unreadLabel: unreadLabel, + ), + ], + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + ); + } + + Widget _buildEntriesCard({ + required int totalItems, + required int completedItems, + required double completedPercentage, + }) { + final l10n = context.l10n; + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - SizedBox(height: 20,), - Row( - children: [ - Expanded( - child: Card( - child: ListTile( - title: Text("Total manga", textAlign: TextAlign.center), - subtitle: Text( - "${items.where((i) => i.itemType == ItemType.manga).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - Expanded( - child: Card( - child: ListTile( - title: Text( - "Total chapters", - textAlign: TextAlign.center, - ), - subtitle: Text( - "${chapters.where((i) => i.manga.value!.itemType == ItemType.manga).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - Expanded( - child: Card( - child: ListTile( - title: Text("Read chapters", textAlign: TextAlign.center), - subtitle: Text( - "${chapters.where((i) => i.manga.value!.itemType == ItemType.manga && (i.isRead ?? false)).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - ], + _buildStatisticColumn( + value: "$totalItems", + label: l10n.in_library, + icon: Icons.collections_bookmark_outlined, ), - Row( - children: [ - Expanded( - child: Card( - child: ListTile( - title: Text("Total anime", textAlign: TextAlign.center), - subtitle: Text( - "${items.where((i) => i.itemType == ItemType.anime).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - Expanded( - child: Card( - child: ListTile( - title: Text( - "Total episodes", - textAlign: TextAlign.center, - ), - subtitle: Text( - "${chapters.where((i) => i.manga.value!.itemType == ItemType.anime).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - Expanded( - child: Card( - child: ListTile( - title: Text( - "Watched episodes", - textAlign: TextAlign.center, - ), - subtitle: Text( - "${chapters.where((i) => i.manga.value!.itemType == ItemType.anime && (i.isRead ?? false)).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - ], + _buildStatisticColumn( + value: "$completedItems", + label: "Completed", + icon: Icons.local_library_outlined, ), - Row( - children: [ - Expanded( - child: Card( - child: ListTile( - title: Text("Total novels", textAlign: TextAlign.center), - subtitle: Text( - "${items.where((i) => i.itemType == ItemType.novel).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - Expanded( - child: Card( - child: ListTile( - title: Text( - "Total chapters", - textAlign: TextAlign.center, - ), - subtitle: Text( - "${chapters.where((i) => i.manga.value!.itemType == ItemType.novel).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - Expanded( - child: Card( - child: ListTile( - title: Text("Read chapters", textAlign: TextAlign.center), - subtitle: Text( - "${chapters.where((i) => i.manga.value!.itemType == ItemType.novel && (i.isRead ?? false)).length}", - textAlign: TextAlign.center, - ), - ), - ), - ), - ], + _buildStatisticColumn( + value: "${completedPercentage.toStringAsFixed(1)}%", + label: "Completion Rate", + icon: Icons.percent, ), ], ), ), ); } + + Widget _buildChaptersCard({ + required int totalChapters, + required int readChapters, + required int unreadChapters, + required int downloadedItems, + required double averageChapters, + required double readPercentage, + required String title, + required BuildContext context, + required String unreadLabel, + }) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatisticColumn( + value: "$totalChapters", + label: "Total", + icon: Icons.format_list_numbered, + ), + _buildStatisticColumn( + value: "$readChapters", + label: "Read", + icon: Icons.done_all, + ), + _buildStatisticColumn( + value: "$unreadChapters", + label: unreadLabel, + icon: Icons.remove, + ), + _buildStatisticColumn( + value: "$downloadedItems", + label: context.l10n.downloaded, + icon: Icons.download_done, + ), + ], + ), + ), + ListTile( + title: Text("Average Chapters per $title"), + subtitle: Text(averageChapters.toStringAsFixed(2)), + leading: Icon(Icons.bar_chart, color: context.primaryColor), + ), + _buildReadPercentageGraph(readPercentage, context), + ], + ), + ); + } + + Widget _buildStatisticColumn({ + required String value, + required String label, + required IconData icon, + }) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(4), + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + Text(label, style: const TextStyle(fontSize: 10)), + Padding( + padding: const EdgeInsets.only(top: 20, bottom: 8), + child: Icon(icon, color: context.primaryColor), + ), + ], + ); + } + + Widget _buildReadPercentageGraph( + double readPercentage, + BuildContext context, + ) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.pie_chart, color: context.primaryColor), + Padding( + padding: EdgeInsets.all(8.0), + child: Text("Read Percentage"), + ), + ], + ), + const SizedBox(height: 10), + Center( + child: SizedBox( + width: 120, + height: 120, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.5), + blurRadius: 10, + spreadRadius: 5, + offset: const Offset(0, 5), + ), + ], + ), + child: CircularProgressIndicator( + value: readPercentage / 100, + strokeWidth: 10, + backgroundColor: Colors.grey.shade300, + valueColor: AlwaysStoppedAnimation( + context.primaryColor, + ), + ), + ), + Text( + "${readPercentage.toStringAsFixed(1)}%", + style: TextStyle(fontSize: 18, color: context.secondaryColor), + ), + ], + ), + ), + ), + const SizedBox(height: 10), + ], + ); + } } diff --git a/lib/services/aniskip.g.dart b/lib/services/aniskip.g.dart index c05db920..b2e1defb 100644 --- a/lib/services/aniskip.g.dart +++ b/lib/services/aniskip.g.dart @@ -6,7 +6,7 @@ part of 'aniskip.dart'; // RiverpodGenerator // ************************************************************************** -String _$aniSkipHash() => r'2e5d19b025a2207ff64da7bf7908450ea9e5ff8c'; +String _$aniSkipHash() => r'887869b54e2e151633efd46da83bde845e14f421'; /// See also [AniSkip]. @ProviderFor(AniSkip) diff --git a/lib/services/trackers/anilist.g.dart b/lib/services/trackers/anilist.g.dart index 834afd64..558da2c4 100644 --- a/lib/services/trackers/anilist.g.dart +++ b/lib/services/trackers/anilist.g.dart @@ -6,7 +6,7 @@ part of 'anilist.dart'; // RiverpodGenerator // ************************************************************************** -String _$anilistHash() => r'ddd07acc8d28d2aa95c942566109e9393ca9e5ed'; +String _$anilistHash() => r'70e8cd537270a9054a1ef72de117fc7ad5545218'; /// Copied from Dart SDK class _SystemHash {