feat: Enhance Stats With Genre Taxonomy and Charts (#1919)
* Add react-native-svg and refresh stats UI Normalize status counts, add donut chart with legend, and improve bars * Add genre stats screen and taxonomy link * Update StatsQueries.test.ts * change genre taxonomy style * Use LegendList instead of ScrollView * added more colors to chart * Add Highlight animation * Add ChartLegend animation * Maintainance * updated stats queries * Added animations to collapsibles and optimized performance * Extract tabs * update time tracking tab
This commit is contained in:
@@ -94,6 +94,7 @@
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-screens": "^4.25.2",
|
||||
"react-native-shimmer-placeholder": "^2.0.9",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-tab-view": "^4.3.2",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-webview": "^13.16.1",
|
||||
|
||||
Generated
+31
@@ -185,6 +185,9 @@ importers:
|
||||
react-native-shimmer-placeholder:
|
||||
specifier: ^2.0.9
|
||||
version: 2.0.9(prop-types@15.8.1)(react-native-linear-gradient@2.8.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3))
|
||||
react-native-svg:
|
||||
specifier: 15.15.4
|
||||
version: 15.15.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
|
||||
react-native-tab-view:
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2(react-native-pager-view@8.0.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
|
||||
@@ -2751,6 +2754,10 @@ packages:
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
|
||||
css-tree@1.1.3:
|
||||
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
css-what@6.2.2:
|
||||
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -4578,6 +4585,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdn-data@2.0.14:
|
||||
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -5258,6 +5268,12 @@ packages:
|
||||
prop-types: '>=15.6.0'
|
||||
react-native-linear-gradient: '>=2.4.0'
|
||||
|
||||
react-native-svg@15.15.4:
|
||||
resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
react-native-tab-view@4.3.2:
|
||||
resolution: {integrity: sha512-QCHX9bTGMeME5pt4jJ1/H7oshX7Z2nA/Ts3m7FpKUbWfPPIL8rlNLZwkedgV273Eqau8jj8deRtfSZl9LCctCw==}
|
||||
peerDependencies:
|
||||
@@ -9236,6 +9252,11 @@ snapshots:
|
||||
domutils: 3.2.2
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-tree@1.1.3:
|
||||
dependencies:
|
||||
mdn-data: 2.0.14
|
||||
source-map: 0.6.1
|
||||
|
||||
css-what@6.2.2: {}
|
||||
|
||||
cssom@0.3.8: {}
|
||||
@@ -11412,6 +11433,8 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdn-data@2.0.14: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.1: {}
|
||||
@@ -12195,6 +12218,14 @@ snapshots:
|
||||
prop-types: 15.8.1
|
||||
react-native-linear-gradient: 2.8.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
|
||||
|
||||
react-native-svg@15.15.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3):
|
||||
dependencies:
|
||||
css-select: 5.2.2
|
||||
css-tree: 1.1.3
|
||||
react: 19.2.3
|
||||
react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0)
|
||||
warn-once: 0.1.1
|
||||
|
||||
react-native-tab-view@4.3.2(react-native-pager-view@8.0.4(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
@@ -1,182 +1,118 @@
|
||||
import { countBy } from 'lodash-es';
|
||||
import { eq, count, and, sql } from 'drizzle-orm';
|
||||
import { eq, count, sql } from 'drizzle-orm';
|
||||
import { LibraryStats } from '../types';
|
||||
import { dbManager } from '@database/db';
|
||||
import { novelSchema, chapterSchema, categorySchema, novelCategorySchema } from '@database/schema';
|
||||
|
||||
/**
|
||||
* Get library statistics (novel count and distinct sources) using Drizzle ORM
|
||||
*/
|
||||
export const getLibraryStatsFromDb = async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({
|
||||
novelsCount: count(),
|
||||
sourcesCount: sql<number>`COUNT(DISTINCT ${novelSchema.pluginId})`,
|
||||
})
|
||||
.from(novelSchema)
|
||||
.where(eq(novelSchema.inLibrary, true))
|
||||
.get();
|
||||
|
||||
return result ?? { novelsCount: 0, sourcesCount: 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get total chapters count for all novels in library
|
||||
*/
|
||||
export const getChaptersTotalCountFromDb = async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({ chaptersCount: count() })
|
||||
.from(chapterSchema)
|
||||
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
|
||||
.where(eq(novelSchema.inLibrary, true))
|
||||
.get();
|
||||
|
||||
return result ?? { chaptersCount: 0 };
|
||||
};
|
||||
import {
|
||||
novelSchema,
|
||||
chapterSchema,
|
||||
categorySchema,
|
||||
novelCategorySchema,
|
||||
} from '@database/schema';
|
||||
|
||||
/**
|
||||
* Get top novels based on time spent
|
||||
*/
|
||||
export const getTopNovelsByTimeSpentFromDb = async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({
|
||||
id: novelSchema.id,
|
||||
pluginId: novelSchema.pluginId,
|
||||
cover: novelSchema.cover,
|
||||
name: novelSchema.name,
|
||||
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
|
||||
})
|
||||
.from(novelSchema)
|
||||
.innerJoin(chapterSchema, eq(chapterSchema.novelId, novelSchema.id))
|
||||
.where(eq(novelSchema.inLibrary, true))
|
||||
.groupBy(novelSchema.id)
|
||||
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
|
||||
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
|
||||
.limit(10)
|
||||
.all();
|
||||
|
||||
return { topNovelsByTimeSpent: result };
|
||||
};
|
||||
|
||||
/** Get top categories based on time spent */
|
||||
export const getTopCategoriesByTimeSpentFromDb = async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({
|
||||
id: categorySchema.id,
|
||||
name: categorySchema.name,
|
||||
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`
|
||||
})
|
||||
.from(categorySchema)
|
||||
.innerJoin(novelCategorySchema, eq(categorySchema.id, novelCategorySchema.categoryId))
|
||||
.innerJoin(novelSchema, eq(novelSchema.id, novelCategorySchema.novelId))
|
||||
.innerJoin(chapterSchema, eq(novelSchema.id, chapterSchema.novelId))
|
||||
.groupBy(categorySchema.id)
|
||||
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
|
||||
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
|
||||
.limit(10)
|
||||
.all()
|
||||
return { topCategoriesByTimeSpent: result };
|
||||
}
|
||||
|
||||
/** Sum up the timeSpent for all chapters */
|
||||
export const getTotalTimeSpentFromDb = async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({ totalTimeSpent: sql<number>`COALESCE(SUM(${chapterSchema.timeSpent}), 0)` })
|
||||
.from(chapterSchema)
|
||||
.get();
|
||||
return { totalTimeSpent: result?.totalTimeSpent ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total read chapters count for all novels in library
|
||||
*/
|
||||
export const getChaptersReadCountFromDb = async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({ chaptersRead: count() })
|
||||
.from(chapterSchema)
|
||||
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
|
||||
.where(
|
||||
and(eq(novelSchema.inLibrary, true), eq(chapterSchema.unread, false)),
|
||||
)
|
||||
.get();
|
||||
|
||||
return result ?? { chaptersRead: 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get total unread chapters count for all novels in library
|
||||
*/
|
||||
export const getChaptersUnreadCountFromDb = async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({ chaptersUnread: count() })
|
||||
.from(chapterSchema)
|
||||
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
|
||||
.where(and(eq(novelSchema.inLibrary, true), eq(chapterSchema.unread, true)))
|
||||
.get();
|
||||
|
||||
return result ?? { chaptersUnread: 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get total downloaded chapters count for all novels in library
|
||||
*/
|
||||
export const getChaptersDownloadedCountFromDb =
|
||||
export const getTopNovelsByTimeSpentFromDb =
|
||||
async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({ chaptersDownloaded: count() })
|
||||
.from(chapterSchema)
|
||||
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
|
||||
.where(
|
||||
and(
|
||||
eq(novelSchema.inLibrary, true),
|
||||
eq(chapterSchema.isDownloaded, true),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
.select({
|
||||
id: novelSchema.id,
|
||||
pluginId: novelSchema.pluginId,
|
||||
cover: novelSchema.cover,
|
||||
name: novelSchema.name,
|
||||
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
|
||||
})
|
||||
.from(novelSchema)
|
||||
.innerJoin(chapterSchema, eq(chapterSchema.novelId, novelSchema.id))
|
||||
.where(eq(novelSchema.inLibrary, true))
|
||||
.groupBy(novelSchema.id)
|
||||
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
|
||||
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
|
||||
.limit(10)
|
||||
.all();
|
||||
|
||||
return result ?? { chaptersDownloaded: 0 };
|
||||
return { topNovelsByTimeSpent: result };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get genre distribution for all novels in library
|
||||
*/
|
||||
export const getNovelGenresFromDb = async (): Promise<LibraryStats> => {
|
||||
/** Get top categories based on time spent */
|
||||
export const getTopCategoriesByTimeSpentFromDb =
|
||||
async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({
|
||||
id: categorySchema.id,
|
||||
name: categorySchema.name,
|
||||
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
|
||||
})
|
||||
.from(categorySchema)
|
||||
.innerJoin(
|
||||
novelCategorySchema,
|
||||
eq(categorySchema.id, novelCategorySchema.categoryId),
|
||||
)
|
||||
.innerJoin(novelSchema, eq(novelSchema.id, novelCategorySchema.novelId))
|
||||
.innerJoin(chapterSchema, eq(novelSchema.id, chapterSchema.novelId))
|
||||
.groupBy(categorySchema.id)
|
||||
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
|
||||
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
|
||||
.limit(10)
|
||||
.all();
|
||||
return { topCategoriesByTimeSpent: result };
|
||||
};
|
||||
|
||||
export interface NovelWithGenres {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string | null;
|
||||
pluginId: string;
|
||||
genres: string | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
export const getNovelsWithGenresFromDb = async (): Promise<
|
||||
NovelWithGenres[]
|
||||
> => {
|
||||
const res = await dbManager
|
||||
.select({ genres: novelSchema.genres })
|
||||
.select({
|
||||
id: novelSchema.id,
|
||||
path: novelSchema.path,
|
||||
name: novelSchema.name,
|
||||
cover: novelSchema.cover,
|
||||
pluginId: novelSchema.pluginId,
|
||||
genres: novelSchema.genres,
|
||||
status: novelSchema.status,
|
||||
})
|
||||
.from(novelSchema)
|
||||
.where(eq(novelSchema.inLibrary, true))
|
||||
.all();
|
||||
|
||||
const genres: string[] = [];
|
||||
res.forEach(item => {
|
||||
const novelGenres = item.genres?.split(/\s*,\s*/);
|
||||
|
||||
if (novelGenres?.length) {
|
||||
genres.push(...novelGenres);
|
||||
}
|
||||
});
|
||||
|
||||
return { genres: countBy(genres) };
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status distribution for all novels in library
|
||||
* Get aggregate library stats from Novel table pre-computed columns.
|
||||
* Single query replaces: getLibraryStatsFromDb, getChaptersTotalCountFromDb,
|
||||
* getChaptersReadCountFromDb, getChaptersUnreadCountFromDb,
|
||||
* getChaptersDownloadedCountFromDb, getTotalTimeSpentFromDb.
|
||||
*/
|
||||
export const getNovelStatusFromDb = async (): Promise<LibraryStats> => {
|
||||
const res = await dbManager
|
||||
.select({ status: novelSchema.status })
|
||||
export const getAggregateStatsFromDb = async (): Promise<LibraryStats> => {
|
||||
const result = await dbManager
|
||||
.select({
|
||||
novelsCount: count(),
|
||||
sourcesCount: sql<number>`COUNT(DISTINCT ${novelSchema.pluginId})`,
|
||||
chaptersCount: sql<number>`COALESCE(SUM(${novelSchema.totalChapters}), 0)`,
|
||||
chaptersDownloaded: sql<number>`COALESCE(SUM(${novelSchema.chaptersDownloaded}), 0)`,
|
||||
chaptersUnread: sql<number>`COALESCE(SUM(${novelSchema.chaptersUnread}), 0)`,
|
||||
totalTimeSpent: sql<number>`COALESCE((SELECT SUM(${chapterSchema.timeSpent}) FROM ${chapterSchema} ch INNER JOIN ${novelSchema} n ON ch.novelId = n.id WHERE n.inLibrary = true), 0)`,
|
||||
})
|
||||
.from(novelSchema)
|
||||
.where(eq(novelSchema.inLibrary, true))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
const statusList: string[] = [];
|
||||
res.forEach(item => {
|
||||
const novelStatus = item.status?.split(/\s*,\s*/);
|
||||
|
||||
if (novelStatus?.length) {
|
||||
statusList.push(...novelStatus);
|
||||
}
|
||||
});
|
||||
|
||||
return { status: countBy(statusList) };
|
||||
return {
|
||||
novelsCount: result?.novelsCount ?? 0,
|
||||
sourcesCount: result?.sourcesCount ?? 0,
|
||||
chaptersCount: result?.chaptersCount ?? 0,
|
||||
chaptersRead: (result?.chaptersCount ?? 0) - (result?.chaptersUnread ?? 0),
|
||||
chaptersUnread: result?.chaptersUnread ?? 0,
|
||||
chaptersDownloaded: result?.chaptersDownloaded ?? 0,
|
||||
totalTimeSpent: result?.totalTimeSpent ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,17 +6,16 @@
|
||||
|
||||
import './mockDb';
|
||||
import { setupTestDatabase, getTestDb, teardownTestDatabase } from './setup';
|
||||
import { insertTestNovel, insertTestChapter, clearAllTables, insertTestNovelCategory, insertTestCategory } from './testData';
|
||||
import {
|
||||
insertTestNovel,
|
||||
insertTestChapter,
|
||||
clearAllTables,
|
||||
insertTestNovelCategory,
|
||||
insertTestCategory,
|
||||
} from './testData';
|
||||
|
||||
import {
|
||||
getLibraryStatsFromDb,
|
||||
getChaptersTotalCountFromDb,
|
||||
getChaptersReadCountFromDb,
|
||||
getChaptersUnreadCountFromDb,
|
||||
getChaptersDownloadedCountFromDb,
|
||||
getNovelGenresFromDb,
|
||||
getNovelStatusFromDb,
|
||||
getTotalTimeSpentFromDb,
|
||||
getAggregateStatsFromDb,
|
||||
getTopCategoriesByTimeSpentFromDb,
|
||||
getTopNovelsByTimeSpentFromDb,
|
||||
} from '../StatsQueries';
|
||||
@@ -31,41 +30,68 @@ describe('StatsQueries', () => {
|
||||
teardownTestDatabase();
|
||||
});
|
||||
|
||||
describe('getLibraryStatsFromDb', () => {
|
||||
it('should return correct novel and source counts', async () => {
|
||||
describe('getAggregateStatsFromDb', () => {
|
||||
it('should return counts, totals, and chapter stats from Novel pre-computed columns', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
// Insert novels from different sources
|
||||
await insertTestNovel(testDb, {
|
||||
const novelId1 = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
pluginId: 'source1',
|
||||
totalChapters: 10,
|
||||
chaptersDownloaded: 3,
|
||||
chaptersUnread: 2,
|
||||
});
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
pluginId: 'source1',
|
||||
});
|
||||
await insertTestNovel(testDb, {
|
||||
|
||||
const novelId2 = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
pluginId: 'source2',
|
||||
});
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: false, // Not in library
|
||||
totalChapters: 5,
|
||||
chaptersDownloaded: 1,
|
||||
chaptersUnread: 5,
|
||||
});
|
||||
|
||||
const result = await getLibraryStatsFromDb();
|
||||
// Add chapters with timeSpent to test totalTimeSpent
|
||||
await insertTestChapter(testDb, novelId1, { timeSpent: 100 });
|
||||
await insertTestChapter(testDb, novelId1, { timeSpent: 200 });
|
||||
await insertTestChapter(testDb, novelId2, { timeSpent: 300 });
|
||||
|
||||
expect(result.novelsCount).toBe(3);
|
||||
// Novel not in library should be ignored
|
||||
const nonLibNovelId = await insertTestNovel(testDb, {
|
||||
inLibrary: false,
|
||||
pluginId: 'source3',
|
||||
totalChapters: 99,
|
||||
chaptersDownloaded: 99,
|
||||
chaptersUnread: 99,
|
||||
});
|
||||
await insertTestChapter(testDb, nonLibNovelId, { timeSpent: 999 });
|
||||
|
||||
const result = await getAggregateStatsFromDb();
|
||||
|
||||
// Triggers auto-increment totalChapters (1 per chapter insert) and chaptersUnread (1 per unread chapter insert)
|
||||
// novelId1: totalChapters 10 -> 12 (2 chapters inserted), chaptersUnread 2 -> 4
|
||||
// novelId2: totalChapters 5 -> 6 (1 chapter inserted), chaptersUnread 5 -> 6
|
||||
expect(result.novelsCount).toBe(2);
|
||||
expect(result.sourcesCount).toBe(2);
|
||||
expect(result.chaptersCount).toBe(18); // (10+2) + (5+1) = 18
|
||||
expect(result.chaptersDownloaded).toBe(4); // 3 + 1, trigger doesn't add (default isDownloaded=false)
|
||||
expect(result.chaptersUnread).toBe(10); // (2+2) + (5+1) = 10
|
||||
expect(result.chaptersRead).toBe(8); // 18 - 10 = 8
|
||||
expect(result.totalTimeSpent).toBe(600); // 100 + 200 + 300
|
||||
});
|
||||
|
||||
it('should return zero counts when library is empty', async () => {
|
||||
const result = await getLibraryStatsFromDb();
|
||||
it('should return zeros when library is empty', async () => {
|
||||
const result = await getAggregateStatsFromDb();
|
||||
|
||||
expect(result.novelsCount).toBe(0);
|
||||
expect(result.sourcesCount).toBe(0);
|
||||
expect(result.chaptersCount).toBe(0);
|
||||
expect(result.chaptersDownloaded).toBe(0);
|
||||
expect(result.chaptersUnread).toBe(0);
|
||||
expect(result.chaptersRead).toBe(0);
|
||||
expect(result.totalTimeSpent).toBe(0);
|
||||
});
|
||||
|
||||
it('should only count novels in library', async () => {
|
||||
it('should only count in-library novels', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
await insertTestNovel(testDb, { inLibrary: true });
|
||||
@@ -73,259 +99,11 @@ describe('StatsQueries', () => {
|
||||
await insertTestNovel(testDb, { inLibrary: false });
|
||||
await insertTestNovel(testDb, { inLibrary: false });
|
||||
|
||||
const result = await getLibraryStatsFromDb();
|
||||
const result = await getAggregateStatsFromDb();
|
||||
|
||||
expect(result.novelsCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChaptersTotalCountFromDb', () => {
|
||||
it('should return correct total chapter count', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const novelId1 = await insertTestNovel(testDb, { inLibrary: true });
|
||||
const novelId2 = await insertTestNovel(testDb, { inLibrary: true });
|
||||
await insertTestNovel(testDb, { inLibrary: false }); // Not in library
|
||||
|
||||
await insertTestChapter(testDb, novelId1);
|
||||
await insertTestChapter(testDb, novelId1);
|
||||
await insertTestChapter(testDb, novelId2);
|
||||
await insertTestChapter(testDb, novelId2);
|
||||
await insertTestChapter(testDb, novelId2);
|
||||
|
||||
const result = await getChaptersTotalCountFromDb();
|
||||
|
||||
expect(result.chaptersCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should return zero when no chapters in library novels', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
const result = await getChaptersTotalCountFromDb();
|
||||
|
||||
expect(result.chaptersCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should only count chapters from library novels', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
const nonLibraryNovelId = await insertTestNovel(testDb, {
|
||||
inLibrary: false,
|
||||
});
|
||||
|
||||
await insertTestChapter(testDb, libraryNovelId);
|
||||
await insertTestChapter(testDb, libraryNovelId);
|
||||
await insertTestChapter(testDb, nonLibraryNovelId);
|
||||
|
||||
const result = await getChaptersTotalCountFromDb();
|
||||
|
||||
expect(result.chaptersCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChaptersReadCountFromDb', () => {
|
||||
it('should return correct count of read chapters', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
await insertTestChapter(testDb, novelId, { unread: false }); // Read
|
||||
await insertTestChapter(testDb, novelId, { unread: false }); // Read
|
||||
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
|
||||
|
||||
const result = await getChaptersReadCountFromDb();
|
||||
|
||||
expect(result.chaptersRead).toBe(2);
|
||||
});
|
||||
|
||||
it('should return zero when no chapters are read', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
await insertTestChapter(testDb, novelId, { unread: true });
|
||||
|
||||
const result = await getChaptersReadCountFromDb();
|
||||
|
||||
expect(result.chaptersRead).toBe(0);
|
||||
});
|
||||
|
||||
it('should only count chapters from library novels', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
const nonLibraryNovelId = await insertTestNovel(testDb, {
|
||||
inLibrary: false,
|
||||
});
|
||||
|
||||
await insertTestChapter(testDb, libraryNovelId, { unread: false });
|
||||
await insertTestChapter(testDb, nonLibraryNovelId, { unread: false });
|
||||
|
||||
const result = await getChaptersReadCountFromDb();
|
||||
|
||||
expect(result.chaptersRead).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChaptersUnreadCountFromDb', () => {
|
||||
it('should return correct count of unread chapters', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
|
||||
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
|
||||
await insertTestChapter(testDb, novelId, { unread: false }); // Read
|
||||
|
||||
const result = await getChaptersUnreadCountFromDb();
|
||||
|
||||
expect(result.chaptersUnread).toBe(2);
|
||||
});
|
||||
|
||||
it('should return zero when all chapters are read', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
await insertTestChapter(testDb, novelId, { unread: false });
|
||||
|
||||
const result = await getChaptersUnreadCountFromDb();
|
||||
|
||||
expect(result.chaptersUnread).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChaptersDownloadedCountFromDb', () => {
|
||||
it('should return correct count of downloaded chapters', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
await insertTestChapter(testDb, novelId, { isDownloaded: true });
|
||||
await insertTestChapter(testDb, novelId, { isDownloaded: true });
|
||||
await insertTestChapter(testDb, novelId, { isDownloaded: false });
|
||||
|
||||
const result = await getChaptersDownloadedCountFromDb();
|
||||
|
||||
expect(result.chaptersDownloaded).toBe(2);
|
||||
});
|
||||
|
||||
it('should return zero when no chapters are downloaded', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
await insertTestChapter(testDb, novelId, { isDownloaded: false });
|
||||
|
||||
const result = await getChaptersDownloadedCountFromDb();
|
||||
|
||||
expect(result.chaptersDownloaded).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNovelGenresFromDb', () => {
|
||||
it('should return correct genre distribution', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
genres: 'Fantasy, Adventure',
|
||||
});
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
genres: 'Fantasy, Romance',
|
||||
});
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
genres: 'Sci-Fi',
|
||||
});
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: false,
|
||||
genres: 'Horror', // Not in library
|
||||
});
|
||||
|
||||
const result = await getNovelGenresFromDb();
|
||||
|
||||
expect(result.genres).toEqual({
|
||||
Fantasy: 2,
|
||||
Adventure: 1,
|
||||
Romance: 1,
|
||||
'Sci-Fi': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty genres when no novels in library', async () => {
|
||||
const result = await getNovelGenresFromDb();
|
||||
|
||||
expect(result.genres).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle novels with null genres', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
await insertTestNovel(testDb, { inLibrary: true, genres: null });
|
||||
await insertTestNovel(testDb, { inLibrary: true, genres: 'Fantasy' });
|
||||
|
||||
const result = await getNovelGenresFromDb();
|
||||
|
||||
expect(result.genres).toEqual({ Fantasy: 1 });
|
||||
});
|
||||
|
||||
it('should handle genres with extra whitespace', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
genres: 'Fantasy, Adventure, Action',
|
||||
});
|
||||
|
||||
const result = await getNovelGenresFromDb();
|
||||
|
||||
expect(result.genres).toHaveProperty('Fantasy');
|
||||
expect(result.genres).toHaveProperty('Adventure');
|
||||
expect(result.genres).toHaveProperty('Action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNovelStatusFromDb', () => {
|
||||
it('should return correct status distribution', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
|
||||
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
|
||||
await insertTestNovel(testDb, { inLibrary: true, status: 'Completed' });
|
||||
await insertTestNovel(testDb, { inLibrary: true, status: 'Hiatus' });
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: false,
|
||||
status: 'Dropped', // Not in library
|
||||
});
|
||||
|
||||
const result = await getNovelStatusFromDb();
|
||||
|
||||
expect(result.status).toEqual({
|
||||
Ongoing: 2,
|
||||
Completed: 1,
|
||||
Hiatus: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty status when no novels in library', async () => {
|
||||
const result = await getNovelStatusFromDb();
|
||||
|
||||
expect(result.status).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle novels with null status', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
await insertTestNovel(testDb, { inLibrary: true, status: null });
|
||||
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
|
||||
|
||||
const result = await getNovelStatusFromDb();
|
||||
|
||||
expect(result.status).toEqual({ Ongoing: 1 });
|
||||
});
|
||||
});
|
||||
describe('getTopNovelsByTimeSpentFromDb', () => {
|
||||
it('should return top novels ordered by total time spent descending', async () => {
|
||||
const testDb = getTestDb();
|
||||
@@ -362,7 +140,9 @@ describe('StatsQueries', () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
const nonLibraryNovelId = await insertTestNovel(testDb, { inLibrary: false });
|
||||
const nonLibraryNovelId = await insertTestNovel(testDb, {
|
||||
inLibrary: false,
|
||||
});
|
||||
|
||||
await insertTestChapter(testDb, libraryNovelId, { timeSpent: 150 });
|
||||
await insertTestChapter(testDb, nonLibraryNovelId, { timeSpent: 999 });
|
||||
@@ -464,26 +244,4 @@ describe('StatsQueries', () => {
|
||||
expect(result.topCategoriesByTimeSpent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalTimeSpentFromDb', () => {
|
||||
it('should sum timeSpent across all chapters', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
await insertTestChapter(testDb, novelId, { timeSpent: 120 });
|
||||
await insertTestChapter(testDb, novelId, { timeSpent: 180 });
|
||||
await insertTestChapter(testDb, novelId, { timeSpent: 50 });
|
||||
|
||||
const result = await getTotalTimeSpentFromDb();
|
||||
|
||||
expect(result.totalTimeSpent).toBe(350);
|
||||
});
|
||||
|
||||
it('should return 0 when there are no chapters or total is null/zero', async () => {
|
||||
const result = await getTotalTimeSpentFromDb();
|
||||
|
||||
expect(result.totalTimeSpent).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useMMKVObject } from 'react-native-mmkv';
|
||||
import { GENRE_TAXONOMY, TaxonomyNode } from '@screens/GenreStatsScreen/taxonomy';
|
||||
|
||||
const TAXONOMY_KEY = 'GENRE_TAXONOMY';
|
||||
|
||||
export const useGenreTaxonomy = () => {
|
||||
const [taxonomy = GENRE_TAXONOMY, setTaxonomyRaw] =
|
||||
useMMKVObject<TaxonomyNode[]>(TAXONOMY_KEY);
|
||||
|
||||
const setTaxonomy = (value: TaxonomyNode[]) => setTaxonomyRaw(value);
|
||||
|
||||
return { taxonomy, setTaxonomy } as const;
|
||||
};
|
||||
@@ -700,6 +700,7 @@
|
||||
"statsScreen": {
|
||||
"downloadedChapters": "Downloaded chapters",
|
||||
"genreDistribution": "Genre distribution",
|
||||
"novels": "novels",
|
||||
"readChapters": "Read chapters",
|
||||
"sources": "Sources",
|
||||
"statusDistribution": "Status distribution",
|
||||
@@ -711,7 +712,9 @@
|
||||
"topCategoriesByTimeSpent": "Top categories by time spent",
|
||||
"totalTimeSpent": "Total time spent",
|
||||
"showNovels": "Group by novel",
|
||||
"showCategories": "Group by category"
|
||||
"showCategories": "Group by category",
|
||||
"plugins": "Plugins",
|
||||
"pluginDistribution": "Plugin distribution"
|
||||
},
|
||||
"tracking": "Tracking",
|
||||
"trackingScreen": {
|
||||
@@ -774,5 +777,43 @@
|
||||
"one": "%{count} day",
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
"one": "%{count} novel",
|
||||
"other": "%{count} novels"
|
||||
},
|
||||
"topLevelCategories": {
|
||||
"one": "%{count} category",
|
||||
"other": "%{count} categories"
|
||||
},
|
||||
"mostCommon": "Most common",
|
||||
"empty": "No genres found",
|
||||
"seeAll": "View all genres",
|
||||
"subgenres": {
|
||||
"one": "%{count} subgenre",
|
||||
"other": "%{count} subgenres"
|
||||
},
|
||||
"novelsIn": "Novels in %{genre}",
|
||||
"noNovels": "No novels found for this category",
|
||||
"seeAllNovels": "See all",
|
||||
"editTaxonomy": "Edit genre taxonomy",
|
||||
"taxonomyTitle": "Genre taxonomy",
|
||||
"parentCategories": "Parent categories",
|
||||
"normalization": "Normalization rules",
|
||||
"addCategory": "Add category",
|
||||
"addChild": "Add subgenre",
|
||||
"addNormalization": "Add rule",
|
||||
"deleteConfirmTitle": "Delete entry",
|
||||
"deleteCategoryConfirm": "Delete this category and its subgenres?",
|
||||
"deleteChildConfirm": "Delete this subgenre?",
|
||||
"deleteNormalizationConfirm": "Delete this normalization rule?",
|
||||
"parentNamePlaceholder": "Category name",
|
||||
"childNamePlaceholder": "Add Subgenre",
|
||||
"normalizationKeyPlaceholder": "e.g. sci-fi",
|
||||
"normalizationValuePlaceholder": "e.g. Sci-Fi",
|
||||
"noCategories": "No parent categories configured",
|
||||
"noNormalization": "No normalization rules configured"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,6 +591,7 @@ export interface StringMap {
|
||||
'sourceScreen.noResultsFound': 'string';
|
||||
'statsScreen.downloadedChapters': 'string';
|
||||
'statsScreen.genreDistribution': 'string';
|
||||
'statsScreen.novels': 'string';
|
||||
'statsScreen.readChapters': 'string';
|
||||
'statsScreen.sources': 'string';
|
||||
'statsScreen.statusDistribution': 'string';
|
||||
@@ -603,6 +604,8 @@ export interface StringMap {
|
||||
'statsScreen.totalTimeSpent': 'string';
|
||||
'statsScreen.showNovels': 'string';
|
||||
'statsScreen.showCategories': 'string';
|
||||
'statsScreen.plugins': 'string';
|
||||
'statsScreen.pluginDistribution': 'string';
|
||||
'tracking': 'string';
|
||||
'trackingScreen.info': 'string';
|
||||
'trackingScreen.logOutMessage': 'string';
|
||||
@@ -647,4 +650,34 @@ export interface StringMap {
|
||||
'time.hours.other': 'string';
|
||||
'time.days.one': 'string';
|
||||
'time.days.other': 'string';
|
||||
'genreStats.title': 'string';
|
||||
'genreStats.totalNovels.one': 'string';
|
||||
'genreStats.totalNovels.other': 'string';
|
||||
'genreStats.topLevelCategories.one': 'string';
|
||||
'genreStats.topLevelCategories.other': 'string';
|
||||
'genreStats.mostCommon': 'string';
|
||||
'genreStats.empty': 'string';
|
||||
'genreStats.seeAll': 'string';
|
||||
'genreStats.subgenres.one': 'string';
|
||||
'genreStats.subgenres.other': 'string';
|
||||
'genreStats.novelsIn': 'string';
|
||||
'genreStats.noNovels': 'string';
|
||||
'genreStats.seeAllNovels': 'string';
|
||||
'genreStats.editTaxonomy': 'string';
|
||||
'genreStats.taxonomyTitle': 'string';
|
||||
'genreStats.parentCategories': 'string';
|
||||
'genreStats.normalization': 'string';
|
||||
'genreStats.addCategory': 'string';
|
||||
'genreStats.addChild': 'string';
|
||||
'genreStats.addNormalization': 'string';
|
||||
'genreStats.deleteConfirmTitle': 'string';
|
||||
'genreStats.deleteCategoryConfirm': 'string';
|
||||
'genreStats.deleteChildConfirm': 'string';
|
||||
'genreStats.deleteNormalizationConfirm': 'string';
|
||||
'genreStats.parentNamePlaceholder': 'string';
|
||||
'genreStats.childNamePlaceholder': 'string';
|
||||
'genreStats.normalizationKeyPlaceholder': 'string';
|
||||
'genreStats.normalizationValuePlaceholder': 'string';
|
||||
'genreStats.noCategories': 'string';
|
||||
'genreStats.noNormalization': 'string';
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import CategoriesScreen from '@screens/Categories/CategoriesScreen';
|
||||
import RespositorySettings from '@screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen';
|
||||
import LibrarySettings from '@screens/settings/SettingsLibraryScreen/SettingsLibraryScreen';
|
||||
import StatsScreen from '@screens/StatsScreen/StatsScreen';
|
||||
import GenreTaxonomyScreen from '@screens/settings/SettingsTaxonomyScreen/SettingsTaxonomyScreen';
|
||||
import { MoreStackParamList, SettingsStackParamList } from './types';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
|
||||
@@ -45,6 +46,7 @@ const SettingsStack = () => {
|
||||
component={RespositorySettings}
|
||||
/>
|
||||
<Stack.Screen name="LibrarySettings" component={LibrarySettings} />
|
||||
<Stack.Screen name="GenreTaxonomy" component={GenreTaxonomyScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -83,6 +83,7 @@ export type SettingsStackParamList = {
|
||||
AdvancedSettings: undefined;
|
||||
LibrarySettings: undefined;
|
||||
RespositorySettings: { url?: string } | undefined;
|
||||
GenreTaxonomy: undefined;
|
||||
};
|
||||
|
||||
export type NovelScreenProps = StackScreenProps<
|
||||
@@ -95,14 +96,14 @@ export type ChapterScreenProps = StackScreenProps<
|
||||
>;
|
||||
export type ReaderStackParamList = {
|
||||
Novel:
|
||||
| {
|
||||
name: string;
|
||||
path: string;
|
||||
pluginId: string;
|
||||
cover: string | null;
|
||||
isLocal?: boolean | null;
|
||||
}
|
||||
| Omit<NovelInfo, 'id'>;
|
||||
| {
|
||||
name: string;
|
||||
path: string;
|
||||
pluginId: string;
|
||||
cover: string | null;
|
||||
isLocal?: boolean | null;
|
||||
}
|
||||
| Omit<NovelInfo, 'id'>;
|
||||
Chapter: {
|
||||
novel: NovelInfo;
|
||||
chapter: ChapterInfo;
|
||||
@@ -182,6 +183,10 @@ export type LibrarySettingsScreenProps = CompositeScreenProps<
|
||||
StackScreenProps<SettingsStackParamList, 'LibrarySettings'>,
|
||||
StackScreenProps<MoreStackParamList, 'SettingsStack'>
|
||||
>;
|
||||
export type GenreTaxonomyScreenProps = CompositeScreenProps<
|
||||
StackScreenProps<SettingsStackParamList, 'GenreTaxonomy'>,
|
||||
StackScreenProps<MoreStackParamList, 'SettingsStack'>
|
||||
>;
|
||||
|
||||
export type RespositorySettingsScreenProps = CompositeScreenProps<
|
||||
StackScreenProps<SettingsStackParamList, 'RespositorySettings'>,
|
||||
@@ -191,6 +196,6 @@ export type RespositorySettingsScreenProps = CompositeScreenProps<
|
||||
declare global {
|
||||
namespace ReactNavigation {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface RootParamList extends RootStackParamList {}
|
||||
interface RootParamList extends RootStackParamList { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
|
||||
interface GenreRowProps {
|
||||
name: string;
|
||||
count: number;
|
||||
maxCount: number; // global max across all genres
|
||||
theme: ThemeColors;
|
||||
isChild?: boolean; // indented, smaller font if true
|
||||
showBar?: boolean; // default true; set false for parent header rows
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const GenreRow: React.FC<GenreRowProps> = ({
|
||||
name,
|
||||
count,
|
||||
maxCount,
|
||||
theme,
|
||||
isChild = false,
|
||||
showBar = true,
|
||||
onPress,
|
||||
}) => {
|
||||
const barWidth =
|
||||
count > 0 ? (maxCount > 0 ? (count / maxCount) * 100 : 0) : 0;
|
||||
|
||||
const barStyle: ViewStyle = {
|
||||
width: `${Math.max(barWidth, count > 0 ? 2 : 0)}%`,
|
||||
backgroundColor: theme.primary,
|
||||
height: '100%',
|
||||
borderRadius: 6,
|
||||
};
|
||||
|
||||
const row = (
|
||||
<View style={[styles.row, isChild && styles.childRow]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: theme.onSurface,
|
||||
fontWeight: isChild ? '400' : '600',
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
{showBar && (
|
||||
<View
|
||||
style={[styles.barTrack, { backgroundColor: theme.surfaceVariant }]}
|
||||
>
|
||||
<View style={barStyle} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.count, { color: theme.onSurfaceVariant }]}>
|
||||
{count}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${name}, ${count}`}
|
||||
>
|
||||
{row}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
childRow: {
|
||||
paddingLeft: 16,
|
||||
},
|
||||
label: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
},
|
||||
barTrack: {
|
||||
flex: 2,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
count: {
|
||||
width: 40,
|
||||
textAlign: 'right',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
export default GenreRow;
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
useAdaptiveRender,
|
||||
useRecyclingState,
|
||||
} from '@legendapp/list/react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import { getString } from '@i18n/translations';
|
||||
import GenreRow from './GenreRow';
|
||||
import NovelCarousel from './NovelCarousel';
|
||||
import AnimatedHeight from '@screens/StatsScreen/components/AnimatedHeight';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import type { GenreTreeNode } from '../utils';
|
||||
import type { NovelWithGenres } from '@database/queries/StatsQueries';
|
||||
|
||||
interface GenreSectionProps {
|
||||
node: GenreTreeNode; // the category node
|
||||
globalMax: number; // global bar scale max
|
||||
novels: NovelWithGenres[]; // ALL novels (filtered internally)
|
||||
theme: ThemeColors;
|
||||
onNovelPress: (novel: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string | null;
|
||||
pluginId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const GenreSection: React.FC<GenreSectionProps> = ({
|
||||
node,
|
||||
globalMax,
|
||||
novels,
|
||||
theme,
|
||||
onNovelPress,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useRecyclingState(false);
|
||||
const adaptiveRender = useAdaptiveRender();
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
|
||||
const categoryNovels = React.useMemo(
|
||||
() =>
|
||||
novels
|
||||
.filter(n => node.novelIds.includes(n.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[novels, node.novelIds],
|
||||
);
|
||||
|
||||
const toggleExpand = () => setExpanded(e => !e);
|
||||
|
||||
const accessibilityLabel = expanded
|
||||
? `${node.name}, ${node.categoryTotal} novels, expanded`
|
||||
: `${node.name}, ${node.categoryTotal} novels, collapsed`;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable
|
||||
onPress={toggleExpand}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={({ pressed }) => [
|
||||
styles.headerRow,
|
||||
{ borderBottomColor: theme.outlineVariant },
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text
|
||||
style={[styles.headerName, { color: theme.onSurface }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{node.name}
|
||||
</Text>
|
||||
{!expanded && hasChildren && (
|
||||
<Text style={[styles.subtitle, { color: theme.onSurfaceVariant }]}>
|
||||
{getString('genreStats.subgenres', {
|
||||
count: node.children!.length,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<Text style={[styles.headerCount, { color: theme.onSurfaceVariant }]}>
|
||||
{node.categoryTotal}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name={expanded ? 'chevron-down' : 'chevron-right'}
|
||||
color={theme.onSurfaceVariant}
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{adaptiveRender === 'light' ? null : (
|
||||
<AnimatedHeight key={node.name + '-colapse'} expanded={expanded}>
|
||||
{hasChildren &&
|
||||
node.children!.map(child => (
|
||||
<GenreRow
|
||||
key={child.name}
|
||||
name={child.name}
|
||||
count={child.count}
|
||||
maxCount={globalMax}
|
||||
theme={theme}
|
||||
isChild
|
||||
/>
|
||||
))}
|
||||
{hasChildren && <View style={styles.separator} />}
|
||||
<NovelCarousel
|
||||
novels={categoryNovels}
|
||||
genreName={node.name}
|
||||
theme={theme}
|
||||
onNovelPress={onNovelPress}
|
||||
/>
|
||||
<View style={{ height: 20 }} />
|
||||
</AnimatedHeight>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 8,
|
||||
},
|
||||
headerName: {
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
headerCount: {
|
||||
fontSize: 16,
|
||||
},
|
||||
expandedContent: {
|
||||
paddingTop: 8,
|
||||
},
|
||||
separator: {
|
||||
height: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default GenreSection;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text } from 'react-native';
|
||||
import NovelCoverImage from '@components/NovelCoverImage';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
|
||||
interface NovelCardProps {
|
||||
novel: { id: number; name: string; cover: string | null; pluginId: string };
|
||||
theme: ThemeColors;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const NovelCard: React.FC<NovelCardProps> = React.memo(
|
||||
({ novel, theme, onPress }) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityLabel={`${novel.name}, novel`}
|
||||
accessibilityRole="button"
|
||||
style={styles.card}
|
||||
>
|
||||
<NovelCoverImage
|
||||
uri={novel.cover}
|
||||
theme={theme}
|
||||
iconSize={20}
|
||||
style={styles.cover}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Text
|
||||
style={[styles.title, { color: theme.onSurface }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{novel.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
(prev, next) => prev.novel.id === next.novel.id,
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
width: 80,
|
||||
marginRight: 8,
|
||||
},
|
||||
cover: {
|
||||
width: 80,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default NovelCard;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import { getString } from '@i18n/translations';
|
||||
import NovelCard from './NovelCard';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
|
||||
interface NovelCarouselProps {
|
||||
novels: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string | null;
|
||||
pluginId: string;
|
||||
}[];
|
||||
genreName: string;
|
||||
theme: ThemeColors;
|
||||
onNovelPress: (novel: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string | null;
|
||||
pluginId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 10;
|
||||
|
||||
const NovelCarousel: React.FC<NovelCarouselProps> = ({
|
||||
novels,
|
||||
genreName,
|
||||
theme,
|
||||
onNovelPress,
|
||||
}) => {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
if (novels.length === 0) {
|
||||
return (
|
||||
<Text style={[styles.emptyText, { color: theme.onSurfaceVariant }]}>
|
||||
{getString('genreStats.noNovels')}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleNovels = showAll ? novels : novels.slice(0, MAX_VISIBLE);
|
||||
const hasMore = !showAll && novels.length > MAX_VISIBLE;
|
||||
|
||||
const data = hasMore
|
||||
? [...visibleNovels, { id: -1, name: '', cover: null, pluginId: '' } as any]
|
||||
: visibleNovels;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.heading, { color: theme.onSurfaceVariant }]}>
|
||||
{getString('genreStats.novelsIn', { genre: genreName })}
|
||||
</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={data}
|
||||
keyExtractor={item =>
|
||||
item.id != null && item.id !== -1 ? String(item.id) : 'see-all'
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
if (item.id === -1) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setShowAll(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getString('genreStats.seeAllNovels')}
|
||||
style={styles.seeAllCard}
|
||||
>
|
||||
<Text style={[styles.seeAllText, { color: theme.primary }]}>
|
||||
{getString('genreStats.seeAllNovels')}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
color={theme.primary}
|
||||
size={20}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NovelCard
|
||||
novel={item}
|
||||
theme={theme}
|
||||
onPress={() => onNovelPress(item)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
windowSize={5}
|
||||
maxToRenderPerBatch={10}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 8,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 13,
|
||||
padding: 8,
|
||||
},
|
||||
seeAllCard: {
|
||||
width: 80,
|
||||
marginRight: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
seeAllText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default NovelCarousel;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as GenreRow } from './GenreRow';
|
||||
export { default as NovelCard } from './NovelCard';
|
||||
export { default as NovelCarousel } from './NovelCarousel';
|
||||
export { default as GenreSection } from './GenreSection';
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Explicit parent → children relationships. A genre listed as a parent MUST also be listed
|
||||
* in knownRoots so it is recognized as a top-level category. */
|
||||
export interface TaxonomyNode {
|
||||
parent: string;
|
||||
children: string[];
|
||||
}
|
||||
|
||||
export const GENRE_TAXONOMY: TaxonomyNode[] = [
|
||||
{
|
||||
parent: 'Fantasy',
|
||||
children: ['High Fantasy', 'Urban Fantasy', 'Modern Fantasy', 'Portal Fantasy / Isekai'],
|
||||
},
|
||||
{
|
||||
parent: 'Lead',
|
||||
children: ['Female Lead', 'Strong Lead', 'Anti-Hero Lead', 'Non-Human Lead', 'Villainous Lead', 'Male Lead'],
|
||||
},
|
||||
{
|
||||
parent: 'Romance',
|
||||
children: ['Lesbian Romance', 'Romance Subplot'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { NovelWithGenres } from '@database/queries/StatsQueries';
|
||||
import { GENRE_TAXONOMY, TaxonomyNode } from './taxonomy';
|
||||
|
||||
export interface GenreTreeNode {
|
||||
name: string;
|
||||
count: number;
|
||||
categoryTotal: number;
|
||||
novelIds: number[];
|
||||
children?: GenreTreeNode[];
|
||||
isCategory: boolean;
|
||||
}
|
||||
|
||||
export function normalizeGenre(genre: string): string {
|
||||
const trimmed = genre.trim();
|
||||
if (!trimmed) return 'Unknown';
|
||||
|
||||
// Strip non-alphanumeric chars, lower-case, then capitalize first letter.
|
||||
// This makes "sci-fi", "sci fi", "scifi", "SCIFI" all produce "Scifi".
|
||||
const cleaned = trimmed.toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
export function buildGenreTree(
|
||||
novels: NovelWithGenres[],
|
||||
taxonomy?: TaxonomyNode[],
|
||||
): GenreTreeNode[] {
|
||||
// Step 1: For each novel, split genres by comma, normalize, collect into Map<genreName, Set<novelId>>
|
||||
const genreMap = new Map<string, Set<number>>();
|
||||
|
||||
for (const novel of novels) {
|
||||
if (!novel.genres || novel.genres.trim().length === 0) {
|
||||
const unknown = 'Unknown';
|
||||
if (!genreMap.has(unknown)) genreMap.set(unknown, new Set());
|
||||
genreMap.get(unknown)!.add(novel.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = novel.genres.split(/\s*,\s*/);
|
||||
for (const part of parts) {
|
||||
const normalized = normalizeGenre(part);
|
||||
if (!genreMap.has(normalized)) genreMap.set(normalized, new Set());
|
||||
genreMap.get(normalized)!.add(novel.id);
|
||||
}
|
||||
}
|
||||
|
||||
const processed = new Set<string>();
|
||||
const result: GenreTreeNode[] = [];
|
||||
|
||||
// Step 2: Build taxonomy-based categories (normalize names for matching)
|
||||
const activeTaxonomy = taxonomy ?? GENRE_TAXONOMY;
|
||||
for (const taxNode of activeTaxonomy) {
|
||||
const normalizedParent = normalizeGenre(taxNode.parent);
|
||||
const parentEntry = genreMap.get(normalizedParent);
|
||||
if (!parentEntry) continue;
|
||||
|
||||
// Collect novelIds: union of parent + all children that exist in genreMap
|
||||
const categoryNovelIds = new Set(parentEntry);
|
||||
|
||||
const children: GenreTreeNode[] = [];
|
||||
for (const childName of taxNode.children) {
|
||||
const normalizedChild = normalizeGenre(childName);
|
||||
const childNovelIds = genreMap.get(normalizedChild);
|
||||
if (!childNovelIds) continue;
|
||||
|
||||
// Add child's novels to category total
|
||||
for (const id of childNovelIds) {
|
||||
categoryNovelIds.add(id);
|
||||
}
|
||||
|
||||
processed.add(normalizedChild);
|
||||
children.push({
|
||||
name: childName,
|
||||
count: childNovelIds.size,
|
||||
categoryTotal: childNovelIds.size,
|
||||
novelIds: Array.from(childNovelIds),
|
||||
isCategory: false,
|
||||
});
|
||||
}
|
||||
|
||||
processed.add(normalizedParent);
|
||||
|
||||
// Sort children by count descending
|
||||
children.sort((a, b) => b.count - a.count);
|
||||
|
||||
result.push({
|
||||
name: taxNode.parent,
|
||||
count: parentEntry.size,
|
||||
categoryTotal: categoryNovelIds.size,
|
||||
novelIds: Array.from(categoryNovelIds),
|
||||
children: children.length > 0 ? children : undefined,
|
||||
isCategory: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Remaining unprocessed genres → standalone
|
||||
for (const [genreName, novelIds] of genreMap) {
|
||||
if (processed.has(genreName)) continue;
|
||||
result.push({
|
||||
name: genreName,
|
||||
count: novelIds.size,
|
||||
categoryTotal: novelIds.size,
|
||||
novelIds: Array.from(novelIds),
|
||||
isCategory: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Sort by categoryTotal descending
|
||||
result.sort((a, b) => b.categoryTotal - a.categoryTotal);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getNovelsForGenre(
|
||||
novels: NovelWithGenres[],
|
||||
genreIds: Set<number>,
|
||||
): NovelWithGenres[] {
|
||||
const novelMap = new Map<number, NovelWithGenres>();
|
||||
for (const novel of novels) {
|
||||
if (genreIds.has(novel.id)) {
|
||||
novelMap.set(novel.id, novel);
|
||||
}
|
||||
}
|
||||
return Array.from(novelMap.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { LegendList } from '@legendapp/list/react-native';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
|
||||
import { getString } from '@i18n/translations';
|
||||
|
||||
import { translateNovelStatus } from '@utils/translateEnum';
|
||||
|
||||
import {
|
||||
buildGenreTree,
|
||||
type GenreTreeNode,
|
||||
} from '@screens/GenreStatsScreen/utils';
|
||||
import { useGenreTaxonomy } from '@hooks/persisted/useGenreTaxonomy';
|
||||
import { GenreSection } from '@screens/GenreStatsScreen/components';
|
||||
import {
|
||||
getDonutPalette,
|
||||
ChapterBar,
|
||||
DonutChartWithLegend,
|
||||
} from './components';
|
||||
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import type { NovelWithGenres } from '@database/queries/StatsQueries';
|
||||
import type { LibraryStats } from '@database/types';
|
||||
import type { MoreStackParamList } from '@navigators/types';
|
||||
|
||||
interface OverviewTabProps {
|
||||
allNovels: NovelWithGenres[];
|
||||
stats: LibraryStats;
|
||||
theme: ThemeColors;
|
||||
onNovelPress: (novel: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string | null;
|
||||
pluginId: string;
|
||||
}) => void;
|
||||
navigation: NavigationProp<MoreStackParamList>;
|
||||
}
|
||||
|
||||
export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
allNovels,
|
||||
stats,
|
||||
theme,
|
||||
onNovelPress,
|
||||
navigation,
|
||||
}) => {
|
||||
const { taxonomy } = useGenreTaxonomy();
|
||||
const tree = useMemo(
|
||||
() => buildGenreTree(allNovels, taxonomy),
|
||||
[allNovels, taxonomy],
|
||||
);
|
||||
const globalMax = Math.max(
|
||||
...tree.flatMap(n => [n.count, ...(n.children?.map(c => c.count) ?? [])]),
|
||||
1,
|
||||
);
|
||||
|
||||
const statusColors = getDonutPalette(Object.keys(stats.status || {}), theme);
|
||||
|
||||
const renderOverviewItem = useCallback(
|
||||
({ item }: { item: GenreTreeNode }) => (
|
||||
<GenreSection
|
||||
node={item}
|
||||
globalMax={globalMax}
|
||||
novels={allNovels}
|
||||
theme={theme}
|
||||
onNovelPress={onNovelPress}
|
||||
/>
|
||||
),
|
||||
[globalMax, allNovels, theme, onNovelPress],
|
||||
);
|
||||
|
||||
const overviewListHeader = useCallback(
|
||||
() => (
|
||||
<View>
|
||||
<ChapterBar
|
||||
read={stats.chaptersRead ?? 0}
|
||||
total={stats.chaptersCount ?? 0}
|
||||
downloaded={stats.chaptersDownloaded ?? 0}
|
||||
/>
|
||||
<DonutChartWithLegend
|
||||
title={getString('statsScreen.statusDistribution')}
|
||||
entries={Object.entries(stats.status || {})
|
||||
.filter(([_, v]) => v > 0)
|
||||
.map(([k, v]) => ({ key: k, value: v }))}
|
||||
colors={statusColors}
|
||||
theme={theme}
|
||||
centerLabel={getString('statsScreen.novels')}
|
||||
getLabel={key => translateNovelStatus(key)}
|
||||
/>
|
||||
{tree.length > 0 && (
|
||||
<View style={styles.genreSectionHeader}>
|
||||
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
|
||||
{getString('statsScreen.genreDistribution')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
navigation.navigate('SettingsStack', {
|
||||
screen: 'GenreTaxonomy',
|
||||
})
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getString('genreStats.editTaxonomy')}
|
||||
hitSlop={12}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="cog-outline"
|
||||
color={theme.onSurfaceVariant}
|
||||
size={20}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
[stats, theme, statusColors, tree.length, navigation],
|
||||
);
|
||||
|
||||
return (
|
||||
<LegendList
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
data={tree}
|
||||
estimatedItemSize={64}
|
||||
keyExtractor={item => item.name}
|
||||
getItemType={() => 'genre'}
|
||||
ListHeaderComponent={overviewListHeader}
|
||||
renderItem={renderOverviewItem}
|
||||
recycleItems
|
||||
showsVerticalScrollIndicator={false}
|
||||
experimental_adaptiveRender={{}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
listContent: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
genreSectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
fontWeight: 'bold',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { LegendList } from '@legendapp/list/react-native';
|
||||
|
||||
import { getString } from '@i18n/translations';
|
||||
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
|
||||
import {
|
||||
getDonutPalette,
|
||||
DonutChartWithLegend,
|
||||
PluginSection,
|
||||
} from './components';
|
||||
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import type { NovelWithGenres } from '@database/queries/StatsQueries';
|
||||
|
||||
interface PluginsTabProps {
|
||||
allNovels: NovelWithGenres[];
|
||||
theme: ThemeColors;
|
||||
onNovelPress: (novel: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string | null;
|
||||
pluginId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const PluginsTab: React.FC<PluginsTabProps> = ({
|
||||
allNovels,
|
||||
theme,
|
||||
onNovelPress,
|
||||
}) => {
|
||||
const pluginData = useMemo(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ name: string; count: number; novelIds: number[] }
|
||||
>();
|
||||
for (const novel of allNovels) {
|
||||
const plugin = getPlugin(novel.pluginId);
|
||||
const name = plugin?.name ?? novel.pluginId;
|
||||
const group = groups.get(novel.pluginId);
|
||||
if (group) {
|
||||
group.novelIds.push(novel.id);
|
||||
group.count++;
|
||||
} else {
|
||||
groups.set(novel.pluginId, { name, count: 1, novelIds: [novel.id] });
|
||||
}
|
||||
}
|
||||
const nodes = Array.from(groups.entries())
|
||||
.map(([pluginId, data]) => ({
|
||||
pluginId,
|
||||
name: data.name,
|
||||
count: data.count,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const keys = nodes.map(n => n.name);
|
||||
const colors = getDonutPalette(keys, theme);
|
||||
const donutEntries = nodes.map(n => ({ key: n.name, value: n.count }));
|
||||
return { nodes, colors, donutEntries };
|
||||
}, [allNovels, theme]);
|
||||
|
||||
const renderPluginItem = useCallback(
|
||||
({ item }: { item: { pluginId: string; name: string; count: number } }) => (
|
||||
<PluginSection
|
||||
pluginId={item.pluginId}
|
||||
name={item.name}
|
||||
count={item.count}
|
||||
novels={allNovels}
|
||||
theme={theme}
|
||||
onNovelPress={onNovelPress}
|
||||
/>
|
||||
),
|
||||
[allNovels, theme, onNovelPress],
|
||||
);
|
||||
|
||||
const { donutEntries, colors: pluginColors } = pluginData;
|
||||
const pluginListHeader = useCallback(
|
||||
() => (
|
||||
<View>
|
||||
<DonutChartWithLegend
|
||||
title={getString('statsScreen.pluginDistribution')}
|
||||
entries={donutEntries}
|
||||
colors={pluginColors}
|
||||
theme={theme}
|
||||
centerLabel={getString('statsScreen.plugins')}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[donutEntries, pluginColors, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<LegendList
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
data={pluginData.nodes}
|
||||
estimatedItemSize={56}
|
||||
keyExtractor={item => item.pluginId}
|
||||
getItemType={() => 'plugin'}
|
||||
ListHeaderComponent={pluginListHeader}
|
||||
renderItem={renderPluginItem}
|
||||
recycleItems
|
||||
showsVerticalScrollIndicator={false}
|
||||
experimental_adaptiveRender={{ initialMode: 'light' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
listContent: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { TabView } from 'react-native-tab-view';
|
||||
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { getString } from '@i18n/translations';
|
||||
@@ -9,100 +10,73 @@ import {
|
||||
Appbar,
|
||||
ErrorScreenV2,
|
||||
LoadingScreenV2,
|
||||
NovelCoverImage,
|
||||
SafeAreaView,
|
||||
TopTabBar,
|
||||
} from '@components';
|
||||
|
||||
import { countBy } from 'lodash-es';
|
||||
import { LibraryStats } from '@database/types';
|
||||
import {
|
||||
getChaptersDownloadedCountFromDb,
|
||||
getChaptersReadCountFromDb,
|
||||
getChaptersTotalCountFromDb,
|
||||
getChaptersUnreadCountFromDb,
|
||||
getLibraryStatsFromDb,
|
||||
getNovelGenresFromDb,
|
||||
getNovelStatusFromDb,
|
||||
getAggregateStatsFromDb,
|
||||
getTopCategoriesByTimeSpentFromDb,
|
||||
getTopNovelsByTimeSpentFromDb,
|
||||
getTotalTimeSpentFromDb,
|
||||
getNovelsWithGenresFromDb,
|
||||
type NovelWithGenres,
|
||||
} from '@database/queries/StatsQueries';
|
||||
import { Row } from '@components/Common';
|
||||
import { IconButton, overlay } from 'react-native-paper';
|
||||
import { translateNovelStatus } from '@utils/translateEnum';
|
||||
import dayjs from 'dayjs';
|
||||
import { getUserAgent } from '@hooks/persisted/useUserAgent';
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
import { PluginsTab } from './PluginsTab';
|
||||
import { TimeTab } from './TimeTab';
|
||||
|
||||
function formatTimeSpent(totalMs: number | undefined) {
|
||||
if (totalMs === undefined || totalMs <= 0) {
|
||||
return getString('time.seconds', { count: 0 });
|
||||
}
|
||||
const d = dayjs.duration(totalMs, 'milliseconds');
|
||||
const asDays = Math.floor(d.asDays());
|
||||
const asHours = Math.floor(d.asHours());
|
||||
const asMinutes = Math.floor(d.asMinutes());
|
||||
const asSeconds = Math.floor(d.asSeconds());
|
||||
const hours = Math.floor(d.hours());
|
||||
const minutes = Math.floor(d.minutes());
|
||||
const seconds = Math.floor(d.seconds());
|
||||
|
||||
if (asDays >= 1) {
|
||||
return hours > 0
|
||||
? `${getString('time.days', { count: asDays })} ${getString('time.hours', { count: hours })}`
|
||||
: getString('time.days', { count: asDays });
|
||||
}
|
||||
if (asHours >= 1) {
|
||||
return minutes > 0
|
||||
? `${getString('time.hours', { count: asHours })} ${getString('time.minutes', { count: minutes })}`
|
||||
: getString('time.hours', { count: asHours });
|
||||
}
|
||||
if (asMinutes >= 1) {
|
||||
return seconds > 0
|
||||
? `${getString('time.minutes', { count: asMinutes })} ${getString('time.seconds', { count: seconds })}`
|
||||
: getString('time.minutes', { count: asMinutes });
|
||||
}
|
||||
return getString('time.seconds', { count: asSeconds });
|
||||
}
|
||||
type StatsRoute = {
|
||||
key: 'overview' | 'plugins' | 'time';
|
||||
title: string;
|
||||
};
|
||||
|
||||
const StatsScreen = () => {
|
||||
const theme = useTheme();
|
||||
const { goBack } = useNavigation();
|
||||
const navigation = useNavigation<any>();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [stats, setStats] = useState<LibraryStats>({});
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [allNovels, setAllNovels] = useState<NovelWithGenres[]>([]);
|
||||
|
||||
const [showingNovels, setShowingNovels] = useState(true);
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const res = await Promise.all([
|
||||
getLibraryStatsFromDb(),
|
||||
getChaptersTotalCountFromDb(),
|
||||
getChaptersReadCountFromDb(),
|
||||
getChaptersUnreadCountFromDb(),
|
||||
getChaptersDownloadedCountFromDb(),
|
||||
getNovelGenresFromDb(),
|
||||
getNovelStatusFromDb(),
|
||||
getTopNovelsByTimeSpentFromDb(),
|
||||
getTopCategoriesByTimeSpentFromDb(),
|
||||
getTotalTimeSpentFromDb(),
|
||||
]);
|
||||
const [aggregateStats, topNovels, topCategories, novelsWithGenres] =
|
||||
await Promise.all([
|
||||
getAggregateStatsFromDb(),
|
||||
getTopNovelsByTimeSpentFromDb(),
|
||||
getTopCategoriesByTimeSpentFromDb(),
|
||||
getNovelsWithGenresFromDb(),
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
setStats(
|
||||
res.reduce<LibraryStats>(
|
||||
(combinedStats, currentStats) => ({
|
||||
...combinedStats,
|
||||
...currentStats,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
);
|
||||
const allGenres: string[] = [];
|
||||
const statusMap: Record<string, number> = {};
|
||||
for (const n of novelsWithGenres) {
|
||||
if (n.genres) {
|
||||
allGenres.push(...n.genres.split(/\s*,\s*/));
|
||||
}
|
||||
const s = n.status?.trim() || 'Unknown';
|
||||
statusMap[s] = (statusMap[s] ?? 0) + 1;
|
||||
}
|
||||
const genres = countBy(allGenres);
|
||||
|
||||
setStats({
|
||||
...aggregateStats,
|
||||
...topNovels,
|
||||
...topCategories,
|
||||
genres,
|
||||
status: statusMap,
|
||||
});
|
||||
}
|
||||
if (!cancelled) setAllNovels(novelsWithGenres);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
@@ -121,10 +95,104 @@ const StatsScreen = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNovelPress = useCallback(
|
||||
(novel: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string | null;
|
||||
pluginId: string;
|
||||
}) => {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Novel',
|
||||
params: {
|
||||
name: novel.name,
|
||||
path: novel.path,
|
||||
pluginId: novel.pluginId,
|
||||
cover: novel.cover,
|
||||
},
|
||||
});
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const layout = useWindowDimensions();
|
||||
|
||||
const routes: StatsRoute[] = useMemo(
|
||||
() => [
|
||||
{ key: 'overview', title: getString('generalSettings') },
|
||||
{ key: 'plugins', title: getString('statsScreen.plugins') },
|
||||
{ key: 'time', title: getString('statsScreen.totalTimeSpent') },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const navigationState = useMemo(() => ({ index, routes }), [index, routes]);
|
||||
const initialLayout = useMemo(
|
||||
() => ({ width: layout.width }),
|
||||
[layout.width],
|
||||
);
|
||||
|
||||
const renderTabBar = useCallback(
|
||||
(props: any) => (
|
||||
<TopTabBar
|
||||
{...props}
|
||||
indicatorStyle={styles.tabBarIndicator}
|
||||
style={[
|
||||
styles.tabBar,
|
||||
{
|
||||
backgroundColor: theme.surface,
|
||||
borderBottomColor: theme.outlineVariant,
|
||||
},
|
||||
]}
|
||||
tabStyle={styles.tabStyle}
|
||||
gap={8}
|
||||
inactiveColor={theme.secondary}
|
||||
activeColor={theme.primary}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
/>
|
||||
),
|
||||
[
|
||||
theme.outlineVariant,
|
||||
theme.primary,
|
||||
theme.rippleColor,
|
||||
theme.secondary,
|
||||
theme.surface,
|
||||
],
|
||||
);
|
||||
|
||||
const renderScene = useCallback(
|
||||
({ route: tabRoute }: { route: StatsRoute }) => {
|
||||
switch (tabRoute.key) {
|
||||
case 'overview':
|
||||
return (
|
||||
<OverviewTab
|
||||
allNovels={allNovels}
|
||||
stats={stats}
|
||||
theme={theme}
|
||||
onNovelPress={handleNovelPress}
|
||||
navigation={navigation}
|
||||
/>
|
||||
);
|
||||
case 'plugins':
|
||||
return (
|
||||
<PluginsTab
|
||||
allNovels={allNovels}
|
||||
theme={theme}
|
||||
onNovelPress={handleNovelPress}
|
||||
/>
|
||||
);
|
||||
case 'time':
|
||||
return <TimeTab stats={stats} theme={theme} />;
|
||||
}
|
||||
},
|
||||
[allNovels, stats, theme, handleNovelPress, navigation],
|
||||
);
|
||||
|
||||
const Header = (
|
||||
<Appbar
|
||||
title={getString('statsScreen.title')}
|
||||
handleGoBack={goBack}
|
||||
handleGoBack={navigation.goBack}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
@@ -149,198 +217,30 @@ const StatsScreen = () => {
|
||||
return (
|
||||
<SafeAreaView excludeTop>
|
||||
{Header}
|
||||
<ScrollView
|
||||
style={styles.screenCtn}
|
||||
contentContainerStyle={styles.contentCtn}
|
||||
>
|
||||
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
|
||||
{getString('generalSettings')}
|
||||
</Text>
|
||||
<Row style={styles.statsRow}>
|
||||
<StatsCard
|
||||
label={getString('statsScreen.titlesInLibrary')}
|
||||
value={stats.novelsCount}
|
||||
/>
|
||||
<StatsCard
|
||||
label={getString('statsScreen.totalTimeSpent')}
|
||||
value={formatTimeSpent(stats.totalTimeSpent)}
|
||||
/>
|
||||
</Row>
|
||||
<Row style={styles.statsRow}>
|
||||
<StatsCard
|
||||
label={getString('statsScreen.readChapters')}
|
||||
value={stats.chaptersRead}
|
||||
/>
|
||||
<StatsCard
|
||||
label={getString('statsScreen.totalChapters')}
|
||||
value={stats.chaptersCount}
|
||||
/>
|
||||
</Row>
|
||||
<Row style={styles.statsRow}>
|
||||
<StatsCard
|
||||
label={getString('statsScreen.unreadChapters')}
|
||||
value={stats.chaptersUnread}
|
||||
/>
|
||||
<StatsCard
|
||||
label={getString('statsScreen.downloadedChapters')}
|
||||
value={stats.chaptersDownloaded}
|
||||
/>
|
||||
</Row>
|
||||
<Row style={styles.statsRow}>
|
||||
<StatsCard
|
||||
label={getString('statsScreen.sources')}
|
||||
value={stats.sourcesCount}
|
||||
/>
|
||||
</Row>
|
||||
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
|
||||
{getString('statsScreen.genreDistribution')}
|
||||
</Text>
|
||||
<Row style={[styles.statsRow, styles.genreRow]}>
|
||||
{Object.entries(stats.genres || {}).map(item => (
|
||||
<StatsCard key={item[0]} label={item[0]} value={item[1]} />
|
||||
))}
|
||||
</Row>
|
||||
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
|
||||
{getString('statsScreen.statusDistribution')}
|
||||
</Text>
|
||||
<Row style={[styles.statsRow, styles.genreRow]}>
|
||||
{Object.entries(stats.status || {}).map(item => (
|
||||
<StatsCard
|
||||
key={item[0]}
|
||||
label={translateNovelStatus(item[0])}
|
||||
value={item[1]}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
<View style={styles.timeSpentHeader}>
|
||||
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
|
||||
{showingNovels ? getString('statsScreen.topNovelsByTimeSpent') : getString('statsScreen.topCategoriesByTimeSpent')}
|
||||
</Text>
|
||||
<IconButton
|
||||
icon={showingNovels ? 'label-outline' : 'book'}
|
||||
iconColor={theme.onSurfaceVariant}
|
||||
onPress={() => setShowingNovels(!showingNovels)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={showingNovels ? getString('statsScreen.showCategories') : getString('statsScreen.showNovels')}
|
||||
/>
|
||||
</View>
|
||||
{showingNovels && stats.topNovelsByTimeSpent?.map((novel, _) => {
|
||||
const plugin = getPlugin(novel.pluginId);
|
||||
const headers = plugin?.imageRequestInit?.headers || { 'User-Agent': getUserAgent() };
|
||||
const requestInit = {...plugin?.imageRequestInit, headers };
|
||||
return <View key={novel.id} style={styles.timeSpentRow}>
|
||||
<NovelCoverImage
|
||||
uri={novel.cover}
|
||||
requestInit={requestInit}
|
||||
theme={theme}
|
||||
iconSize={22}
|
||||
style={styles.timeSpentNovelCover}
|
||||
contentFit='cover'
|
||||
/>
|
||||
<View>
|
||||
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
|
||||
{novel.name}
|
||||
</Text>
|
||||
<Text style={{ color: theme.onSurfaceVariant }}>
|
||||
{formatTimeSpent(novel.timeSpent)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
})}
|
||||
{!showingNovels && stats.topCategoriesByTimeSpent?.map((category, _) => {
|
||||
return <View key={category.id} style={styles.timeSpentRow}>
|
||||
<View>
|
||||
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
<Text style={{ color: theme.onSurfaceVariant }}>
|
||||
{formatTimeSpent(category.timeSpent)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
})}
|
||||
</ScrollView>
|
||||
<TabView
|
||||
navigationState={navigationState}
|
||||
renderTabBar={renderTabBar}
|
||||
renderScene={renderScene}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={initialLayout}
|
||||
lazy
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsScreen;
|
||||
|
||||
export const StatsCard: React.FC<{ label: string; value?: string | number }> = ({
|
||||
label,
|
||||
value = 0,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.statsCardCtn,
|
||||
{
|
||||
backgroundColor: theme.isDark
|
||||
? overlay(2, theme.surface)
|
||||
: theme.secondaryContainer,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.statsVal, { color: theme.primary }]}>{value}</Text>
|
||||
<Text style={{ color: theme.onSurface }}> {label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentCtn: {
|
||||
paddingBottom: 40,
|
||||
tabBar: {
|
||||
borderBottomWidth: 1,
|
||||
elevation: 0,
|
||||
},
|
||||
genreRow: {
|
||||
flexWrap: 'wrap',
|
||||
tabBarIndicator: {
|
||||
backgroundColor: '#000',
|
||||
height: 3,
|
||||
},
|
||||
header: {
|
||||
fontWeight: 'bold',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
screenCtn: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
statsCardCtn: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.25)',
|
||||
justifyContent: 'center',
|
||||
margin: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
statsRow: {
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statsVal: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
timeSpentHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
timeSpentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
timeSpentNovelCover: {
|
||||
width: 50,
|
||||
aspectRatio: 2 / 3,
|
||||
marginRight: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
timeSpentLabel: {
|
||||
fontWeight: 'bold',
|
||||
tabStyle: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { LegendList } from '@legendapp/list/react-native';
|
||||
import { IconButton } from 'react-native-paper';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
||||
import { getString } from '@i18n/translations';
|
||||
|
||||
import { NovelCoverImage } from '@components';
|
||||
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
import { getUserAgent } from '@hooks/persisted/useUserAgent';
|
||||
import { formatTimeSpent } from './utils';
|
||||
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import type { LibraryStats } from '@database/types';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
type TimeSpentItem =
|
||||
| {
|
||||
type: 'novel';
|
||||
id: number;
|
||||
pluginId: string;
|
||||
name: string;
|
||||
cover: string | null;
|
||||
timeSpent: number;
|
||||
}
|
||||
| { type: 'category'; id: number; name: string; timeSpent: number };
|
||||
|
||||
interface TimeTabProps {
|
||||
stats: LibraryStats;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
function formatTotalTimeParts(totalMs: number | undefined) {
|
||||
if (!totalMs || totalMs <= 0) return null;
|
||||
const d = dayjs.duration(totalMs, 'milliseconds');
|
||||
const days = Math.floor(d.asDays());
|
||||
const hours = d.hours();
|
||||
const minutes = d.minutes();
|
||||
|
||||
return { days, hours, minutes };
|
||||
}
|
||||
|
||||
export const TimeTab: React.FC<TimeTabProps> = ({ stats, theme }) => {
|
||||
const [showingNovels, setShowingNovels] = useState(true);
|
||||
|
||||
const timeSpentData = useMemo<TimeSpentItem[]>(() => {
|
||||
if (showingNovels) {
|
||||
return (stats.topNovelsByTimeSpent ?? []).map(n => ({
|
||||
type: 'novel' as const,
|
||||
id: n.id,
|
||||
pluginId: n.pluginId,
|
||||
name: n.name,
|
||||
cover: n.cover,
|
||||
timeSpent: n.timeSpent,
|
||||
}));
|
||||
}
|
||||
return (stats.topCategoriesByTimeSpent ?? []).map(c => ({
|
||||
type: 'category' as const,
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
timeSpent: c.timeSpent,
|
||||
}));
|
||||
}, [
|
||||
showingNovels,
|
||||
stats.topNovelsByTimeSpent,
|
||||
stats.topCategoriesByTimeSpent,
|
||||
]);
|
||||
|
||||
const renderTimeItem = useCallback(
|
||||
({ item }: { item: TimeSpentItem }) => {
|
||||
if (item.type === 'novel') {
|
||||
const plugin = getPlugin(item.pluginId);
|
||||
const headers = plugin?.imageRequestInit?.headers || {
|
||||
'User-Agent': getUserAgent(),
|
||||
};
|
||||
const requestInit = { ...plugin?.imageRequestInit, headers };
|
||||
return (
|
||||
<View style={styles.timeSpentRow}>
|
||||
<NovelCoverImage
|
||||
uri={item.cover}
|
||||
requestInit={requestInit}
|
||||
theme={theme}
|
||||
iconSize={22}
|
||||
style={styles.timeSpentNovelCover}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<View>
|
||||
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={{ color: theme.onSurfaceVariant }}>
|
||||
{formatTimeSpent(item.timeSpent)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.timeSpentRow}>
|
||||
<View>
|
||||
<Text style={[styles.timeSpentLabel, { color: theme.onSurface }]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={{ color: theme.onSurfaceVariant }}>
|
||||
{formatTimeSpent(item.timeSpent)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[theme],
|
||||
);
|
||||
|
||||
const totalTimeParts = formatTotalTimeParts(stats.totalTimeSpent);
|
||||
|
||||
const formatTwoNumbers = (timeSpent: number) =>
|
||||
timeSpent <= 9 ? `0${timeSpent}` : timeSpent;
|
||||
|
||||
const timeListHeader = useCallback(
|
||||
() => (
|
||||
<>
|
||||
<View style={styles.totalTimeContainer}>
|
||||
<Text
|
||||
style={[styles.totalTimeLabel, { color: theme.onSurfaceVariant }]}
|
||||
>
|
||||
{getString('statsScreen.totalTimeSpent')}
|
||||
</Text>
|
||||
{totalTimeParts ? (
|
||||
<View style={styles.totalTimeValueRow}>
|
||||
{totalTimeParts.days > 0 && (
|
||||
<View style={styles.totalTimeBlock}>
|
||||
<Text
|
||||
style={[styles.totalTimeNumber, { color: theme.primary }]}
|
||||
>
|
||||
{formatTwoNumbers(totalTimeParts.days)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.totalTimeUnit,
|
||||
{ color: theme.onSurfaceVariant },
|
||||
]}
|
||||
>
|
||||
{getString('time.days', { count: totalTimeParts.days })
|
||||
.replace(/\d+/, '')
|
||||
.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{totalTimeParts.hours > 0 && (
|
||||
<View style={styles.totalTimeBlock}>
|
||||
<Text
|
||||
style={[styles.totalTimeNumber, { color: theme.primary }]}
|
||||
>
|
||||
{formatTwoNumbers(totalTimeParts.hours)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.totalTimeUnit,
|
||||
{ color: theme.onSurfaceVariant },
|
||||
]}
|
||||
>
|
||||
{getString('time.hours', { count: totalTimeParts.hours })
|
||||
.replace(/\d+/, '')
|
||||
.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.totalTimeBlock}>
|
||||
<Text
|
||||
style={[styles.totalTimeNumber, { color: theme.primary }]}
|
||||
>
|
||||
{formatTwoNumbers(totalTimeParts.minutes)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.totalTimeUnit,
|
||||
{ color: theme.onSurfaceVariant },
|
||||
]}
|
||||
>
|
||||
{getString('time.minutes', {
|
||||
count: totalTimeParts.minutes,
|
||||
})
|
||||
.replace(/\d+/, '')
|
||||
.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[styles.totalTimeEmpty, { color: theme.onSurfaceVariant }]}
|
||||
>
|
||||
{formatTimeSpent(stats.totalTimeSpent)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.timeSpentHeader}>
|
||||
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
|
||||
{showingNovels
|
||||
? getString('statsScreen.topNovelsByTimeSpent')
|
||||
: getString('statsScreen.topCategoriesByTimeSpent')}
|
||||
</Text>
|
||||
<IconButton
|
||||
icon={showingNovels ? 'label-outline' : 'book'}
|
||||
iconColor={theme.onSurfaceVariant}
|
||||
onPress={() => setShowingNovels(!showingNovels)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
showingNovels
|
||||
? getString('statsScreen.showCategories')
|
||||
: getString('statsScreen.showNovels')
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
),
|
||||
[stats.totalTimeSpent, theme, showingNovels, totalTimeParts],
|
||||
);
|
||||
|
||||
return (
|
||||
<LegendList
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
data={timeSpentData}
|
||||
estimatedItemSize={56}
|
||||
getItemType={item => item.type}
|
||||
keyExtractor={item => `${item.type}-${item.id}`}
|
||||
ListHeaderComponent={timeListHeader}
|
||||
recycleItems
|
||||
renderItem={renderTimeItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
listContent: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
totalTimeContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
totalTimeLabel: {
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
totalTimeValueRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 20,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
totalTimeBlock: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
totalTimeNumber: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 38,
|
||||
},
|
||||
totalTimeUnit: {
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
totalTimeEmpty: {
|
||||
fontSize: 16,
|
||||
},
|
||||
timeSpentHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
timeSpentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
timeSpentNovelCover: {
|
||||
width: 50,
|
||||
aspectRatio: 2 / 3,
|
||||
marginRight: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
timeSpentLabel: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
header: {
|
||||
fontWeight: 'bold',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
LayoutChangeEvent,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface AnimatedHeightProps {
|
||||
expanded: boolean;
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const AnimatedHeight: React.FC<AnimatedHeightProps> = ({
|
||||
expanded,
|
||||
children,
|
||||
style,
|
||||
duration = 250,
|
||||
}) => {
|
||||
const [measuredHeight, setMeasuredHeight] = useState(0);
|
||||
|
||||
const height = useSharedValue(0);
|
||||
const opacity = useSharedValue(0);
|
||||
const overflowVisible = useSharedValue(false);
|
||||
|
||||
const onLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const nextHeight = event.nativeEvent.layout.height;
|
||||
|
||||
if (nextHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMeasuredHeight(currentHeight =>
|
||||
Math.abs(currentHeight - nextHeight) > 0.5 ? nextHeight : currentHeight,
|
||||
);
|
||||
},
|
||||
[setMeasuredHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimation(height);
|
||||
cancelAnimation(opacity);
|
||||
|
||||
overflowVisible.value = false;
|
||||
|
||||
if (!expanded) {
|
||||
height.value = withTiming(0, {
|
||||
duration: duration * 0.7,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
});
|
||||
|
||||
opacity.value = withTiming(0, {
|
||||
duration: duration * 0.7,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (measuredHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
height.value = withTiming(
|
||||
measuredHeight,
|
||||
{
|
||||
duration,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
finished => {
|
||||
if (finished) {
|
||||
overflowVisible.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
opacity.value = withTiming(1, {
|
||||
duration: duration * 0.6,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
}, [duration, expanded, height, measuredHeight, opacity, overflowVisible]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
height: height.value,
|
||||
opacity: opacity.value,
|
||||
overflow: overflowVisible.value ? 'visible' : 'hidden',
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, animatedStyle]}>
|
||||
<View onLayout={onLayout} style={styles.content}>
|
||||
{children}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
position: 'absolute',
|
||||
top: 14,
|
||||
right: 0,
|
||||
left: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(AnimatedHeight);
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { getString } from '@i18n/translations';
|
||||
|
||||
interface ChapterBarProps {
|
||||
read: number;
|
||||
total: number;
|
||||
downloaded: number;
|
||||
}
|
||||
|
||||
export const ChapterBar: React.FC<ChapterBarProps> = ({
|
||||
read,
|
||||
total,
|
||||
downloaded,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const readPercent = total > 0 ? Math.min(read / total, 1) : 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.totalLabel, { color: theme.onSurface }]}>
|
||||
{getString('statsScreen.totalChapters')}
|
||||
</Text>
|
||||
<Text style={[styles.totalCount, { color: theme.onSurface }]}>
|
||||
{total}
|
||||
</Text>
|
||||
<View style={[styles.track, { backgroundColor: theme.surfaceVariant }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.fill,
|
||||
{
|
||||
width: `${readPercent * 100}%` as any,
|
||||
backgroundColor: theme.primary,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{total > 0 && (
|
||||
<View style={styles.labelsRow}>
|
||||
<View style={styles.labelCol}>
|
||||
<Text style={{ color: theme.onSurfaceVariant, fontSize: 12 }}>
|
||||
{getString('statsScreen.readChapters')}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.onSurface,
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{read}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.labelCol}>
|
||||
<Text style={{ color: theme.onSurfaceVariant, fontSize: 12 }}>
|
||||
{getString('statsScreen.downloadedChapters')}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.onSurface,
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{downloaded}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
totalCount: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
track: {
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: '100%',
|
||||
borderRadius: 10,
|
||||
},
|
||||
labelsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginTop: 4,
|
||||
},
|
||||
labelCol: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export interface ChartLegendEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ChartLegendProps {
|
||||
entries: ChartLegendEntry[];
|
||||
highlightedKey?: string;
|
||||
onEntryPress: (key: string) => void;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const ChartLegend: React.FC<ChartLegendProps> = ({
|
||||
entries,
|
||||
highlightedKey,
|
||||
onEntryPress,
|
||||
theme,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.legendContainer}>
|
||||
{entries.map(entry => {
|
||||
const isHighlighted = highlightedKey === entry.key;
|
||||
return (
|
||||
<Pressable
|
||||
key={entry.key}
|
||||
onPress={() => onEntryPress(entry.key)}
|
||||
style={styles.legendRow}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.legendDot,
|
||||
{
|
||||
backgroundColor: entry.color,
|
||||
borderWidth: isHighlighted ? 2 : 0,
|
||||
width: isHighlighted ? 16 : 12,
|
||||
height: isHighlighted ? 16 : 12,
|
||||
marginLeft: isHighlighted ? 0 : 2,
|
||||
marginRight: isHighlighted ? 6 : 8,
|
||||
transitionDuration: '150ms',
|
||||
transitionProperty: 'all',
|
||||
borderColor: theme.onSurface,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.legendLabel, { color: theme.onSurface }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.legendValue, { color: theme.onSurfaceVariant }]}
|
||||
>
|
||||
{entry.value}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
legendContainer: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
legendRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
legendDot: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
legendLabel: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
},
|
||||
legendValue: {
|
||||
fontSize: 14,
|
||||
textAlign: 'right',
|
||||
width: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export default ChartLegend;
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Svg, { Path, G, PathProps } from 'react-native-svg';
|
||||
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import color from 'color';
|
||||
import Animated, {
|
||||
ReduceMotion,
|
||||
useAnimatedProps,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const PULL_OFFSET = 8;
|
||||
|
||||
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
||||
|
||||
function toRadians(angle: number) {
|
||||
return (angle * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export function getDonutPalette(
|
||||
keys: string[],
|
||||
theme: ThemeColors,
|
||||
): Record<string, string> {
|
||||
const bases = [
|
||||
theme.primary,
|
||||
theme.primaryContainer,
|
||||
theme.tertiary,
|
||||
theme.tertiaryContainer,
|
||||
theme.secondary,
|
||||
theme.secondaryContainer,
|
||||
theme.surface,
|
||||
theme.surfaceVariant,
|
||||
theme.onPrimary,
|
||||
];
|
||||
const result: Record<string, string> = {};
|
||||
keys.forEach((key, i) => {
|
||||
const base = bases[i % bases.length];
|
||||
const cycle = Math.floor(i / bases.length);
|
||||
if (cycle === 0) {
|
||||
result[key] = base;
|
||||
} else {
|
||||
const factor = cycle * 0.15;
|
||||
result[key] = theme.isDark
|
||||
? color(base).lighten(factor).hex()
|
||||
: color(base).darken(factor).hex();
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
interface DonutChartProps {
|
||||
entries: { key: string; value: number }[];
|
||||
size: number;
|
||||
thickness: number;
|
||||
colors: Record<string, string>;
|
||||
centerLabel?: string;
|
||||
highlightedKey?: string;
|
||||
onSegmentPress?: (key: string) => void;
|
||||
}
|
||||
|
||||
interface SegmentData {
|
||||
key: string;
|
||||
color: string;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
}
|
||||
|
||||
type DonutChartSegmentProps = SegmentData & {
|
||||
segKey: string;
|
||||
cx: number;
|
||||
cy: number;
|
||||
radius: number;
|
||||
innerRadius: number;
|
||||
isHighlighted: boolean;
|
||||
onPress?: (key: string) => void;
|
||||
};
|
||||
|
||||
const config = {
|
||||
duration: 150,
|
||||
dampingRatio: 0.75,
|
||||
mass: 4,
|
||||
overshootClamping: undefined,
|
||||
energyThreshold: 6e-9,
|
||||
velocity: 0,
|
||||
reduceMotion: ReduceMotion.System,
|
||||
};
|
||||
|
||||
const DonutChartSegment = ({
|
||||
segKey,
|
||||
color: segColor,
|
||||
startAngle,
|
||||
endAngle,
|
||||
onPress,
|
||||
isHighlighted,
|
||||
cx,
|
||||
cy,
|
||||
radius,
|
||||
innerRadius,
|
||||
}: DonutChartSegmentProps) => {
|
||||
const path = useMemo(() => {
|
||||
const polar = (angle: number, r: number) => ({
|
||||
x: cx + r * Math.cos(toRadians(angle)),
|
||||
y: cy + r * Math.sin(toRadians(angle)),
|
||||
});
|
||||
|
||||
const outerStart = polar(startAngle, radius);
|
||||
const outerEnd = polar(endAngle, radius);
|
||||
const innerStart = polar(startAngle, innerRadius);
|
||||
const innerEnd = polar(endAngle, innerRadius);
|
||||
|
||||
const sweepAngle = (((endAngle - startAngle) % 360) + 360) % 360;
|
||||
const largeArcFlag = sweepAngle > 180 ? 1 : 0;
|
||||
|
||||
return [
|
||||
`M ${innerStart.x} ${innerStart.y}`,
|
||||
`L ${outerStart.x} ${outerStart.y}`,
|
||||
`A ${radius} ${radius}`,
|
||||
`0 ${largeArcFlag} 1`,
|
||||
`${outerEnd.x} ${outerEnd.y}`,
|
||||
`L ${innerEnd.x} ${innerEnd.y}`,
|
||||
`A ${innerRadius} ${innerRadius}`,
|
||||
`0 ${largeArcFlag} 0`,
|
||||
`${innerStart.x} ${innerStart.y}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}, [cx, cy, startAngle, endAngle, radius, innerRadius]);
|
||||
|
||||
const animatedProps = useAnimatedProps<PathProps>(() => {
|
||||
const midAngle = (startAngle + endAngle) / 2;
|
||||
const midAngleRadians = (midAngle * Math.PI) / 180;
|
||||
const targetX = PULL_OFFSET * Math.cos(midAngleRadians);
|
||||
const targetY = PULL_OFFSET * Math.sin(midAngleRadians);
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateX: withSpring(isHighlighted ? targetX : 0, config),
|
||||
},
|
||||
{
|
||||
translateY: withSpring(isHighlighted ? targetY : 0, config),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [isHighlighted]);
|
||||
|
||||
return (
|
||||
<G onPress={() => onPress?.(segKey)}>
|
||||
<AnimatedPath animatedProps={animatedProps} d={path} fill={segColor} />
|
||||
</G>
|
||||
);
|
||||
};
|
||||
|
||||
export const DonutChart: React.FC<DonutChartProps> = ({
|
||||
entries,
|
||||
size,
|
||||
thickness,
|
||||
colors,
|
||||
centerLabel,
|
||||
highlightedKey,
|
||||
onSegmentPress,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const total = entries.reduce((s, e) => s + e.value, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
const totalSize = size + PULL_OFFSET * 2;
|
||||
const cx = totalSize / 2;
|
||||
const cy = totalSize / 2;
|
||||
const radius = size / 2 - 1; // slight inset to avoid clipping
|
||||
const innerRadius = radius - thickness;
|
||||
const active = entries.filter(e => e.value > 0);
|
||||
|
||||
// Build accumulated angles (math coords, CW from 3 o'clock)
|
||||
const segments: SegmentData[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const entry of active) {
|
||||
const angle = (entry.value / total) * 360;
|
||||
segments.push({
|
||||
key: entry.key,
|
||||
color: colors[entry.key] || theme.outline,
|
||||
startAngle: cursor,
|
||||
endAngle: cursor + angle,
|
||||
});
|
||||
cursor += angle;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: totalSize,
|
||||
height: totalSize,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Svg width={totalSize} height={totalSize}>
|
||||
<G>
|
||||
{segments.map(seg => {
|
||||
const { key, ...p } = seg;
|
||||
const isHighlighted = highlightedKey === key;
|
||||
const props = {
|
||||
...p,
|
||||
segKey: key,
|
||||
cx,
|
||||
cy,
|
||||
radius,
|
||||
innerRadius,
|
||||
isHighlighted,
|
||||
onPress: onSegmentPress,
|
||||
};
|
||||
return <DonutChartSegment key={key} {...props} />;
|
||||
})}
|
||||
</G>
|
||||
</Svg>
|
||||
|
||||
{/* Center count */}
|
||||
<View
|
||||
style={[styles.centerLabel, { width: totalSize, height: totalSize }]}
|
||||
>
|
||||
<Text style={[styles.centerCount, { color: theme.onSurface }]}>
|
||||
{total}
|
||||
</Text>
|
||||
<Text style={[styles.centerUnit, { color: theme.onSurfaceVariant }]}>
|
||||
{centerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centerLabel: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
centerCount: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
centerUnit: {
|
||||
fontSize: 11,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { DonutChart } from './DonutChart';
|
||||
import ChartLegend, { type ChartLegendEntry } from './ChartLegend';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
|
||||
interface DonutChartWithLegendProps {
|
||||
title: string;
|
||||
entries: { key: string; value: number }[];
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
colors: Record<string, string>;
|
||||
theme: ThemeColors;
|
||||
centerLabel?: string;
|
||||
getLabel?: (key: string) => string;
|
||||
}
|
||||
|
||||
const DonutChartWithLegend: React.FC<DonutChartWithLegendProps> = ({
|
||||
title,
|
||||
entries,
|
||||
size = 160,
|
||||
thickness = 28,
|
||||
colors,
|
||||
theme,
|
||||
centerLabel,
|
||||
getLabel,
|
||||
}) => {
|
||||
const [highlightedKey, setHighlightedKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const handleSegmentPress = useCallback(
|
||||
(key: string) =>
|
||||
setHighlightedKey(prev => (prev === key ? undefined : key)),
|
||||
[],
|
||||
);
|
||||
const legendEntries: ChartLegendEntry[] = entries
|
||||
.filter(e => e.value > 0)
|
||||
.map(e => ({
|
||||
key: e.key,
|
||||
label: getLabel ? getLabel(e.key) : e.key,
|
||||
value: e.value,
|
||||
color: colors[e.key] || theme.outline,
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={[styles.header, { color: theme.onSurfaceVariant }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<View style={styles.donutContainer}>
|
||||
<DonutChart
|
||||
entries={entries}
|
||||
size={size}
|
||||
thickness={thickness}
|
||||
colors={colors}
|
||||
centerLabel={centerLabel}
|
||||
highlightedKey={highlightedKey}
|
||||
onSegmentPress={handleSegmentPress}
|
||||
/>
|
||||
</View>
|
||||
<ChartLegend
|
||||
entries={legendEntries}
|
||||
highlightedKey={highlightedKey}
|
||||
onEntryPress={handleSegmentPress}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
fontWeight: 'bold',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
donutContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default DonutChartWithLegend;
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
useRecyclingState,
|
||||
useAdaptiveRender,
|
||||
} from '@legendapp/list/react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import { NovelCoverImage } from '@components';
|
||||
import AnimatedHeight from './AnimatedHeight';
|
||||
import type { ThemeColors } from '@theme/types';
|
||||
import type { NovelWithGenres } from '@database/queries/StatsQueries';
|
||||
|
||||
interface PluginSectionProps {
|
||||
pluginId: string;
|
||||
name: string;
|
||||
count: number;
|
||||
novels: NovelWithGenres[];
|
||||
theme: ThemeColors;
|
||||
onNovelPress: (novel: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
cover: string | null;
|
||||
pluginId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const PluginSection: React.FC<PluginSectionProps> = ({
|
||||
pluginId,
|
||||
name,
|
||||
count,
|
||||
novels,
|
||||
theme,
|
||||
onNovelPress,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useRecyclingState(false);
|
||||
const adaptiveRender = useAdaptiveRender();
|
||||
const pluginNovels = useMemo(
|
||||
() => novels.filter(n => n.pluginId === pluginId),
|
||||
[novels, pluginId],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={() => setExpanded(e => !e)}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [
|
||||
styles.header,
|
||||
{ borderBottomColor: theme.outlineVariant },
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.name, { color: theme.onSurface }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<View style={styles.headerRight}>
|
||||
<Text style={[styles.count, { color: theme.onSurfaceVariant }]}>
|
||||
{count}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name={expanded ? 'chevron-down' : 'chevron-right'}
|
||||
color={theme.onSurfaceVariant}
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
{adaptiveRender === 'light' ? null : (
|
||||
<AnimatedHeight expanded={expanded}>
|
||||
{pluginNovels.map(novel => (
|
||||
<Pressable
|
||||
key={novel.id}
|
||||
onPress={() => onNovelPress(novel)}
|
||||
style={styles.novelRow}
|
||||
>
|
||||
<NovelCoverImage
|
||||
uri={novel.cover}
|
||||
requestInit={undefined}
|
||||
theme={theme}
|
||||
iconSize={18}
|
||||
style={styles.novelCover}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Text
|
||||
style={[styles.novelName, { color: theme.onSurface }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{novel.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</AnimatedHeight>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
name: { fontWeight: '600', flex: 1 },
|
||||
headerRight: { flexDirection: 'row', alignItems: 'center' },
|
||||
count: { marginRight: 8 },
|
||||
novelRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||
novelCover: {
|
||||
width: 36,
|
||||
aspectRatio: 2 / 3,
|
||||
marginRight: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
novelName: { flex: 1 },
|
||||
});
|
||||
|
||||
export default PluginSection;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { overlay } from 'react-native-paper';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.25)',
|
||||
justifyContent: 'center',
|
||||
margin: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
value: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export const StatsCard: React.FC<{
|
||||
label: string;
|
||||
value?: string | number;
|
||||
}> = ({ label, value = 0 }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: theme.isDark
|
||||
? overlay(2, theme.surface)
|
||||
: theme.secondaryContainer,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.value, { color: theme.primary }]}>{value}</Text>
|
||||
<Text style={{ color: theme.onSurface }}> {label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as AnimatedHeight } from './AnimatedHeight';
|
||||
export { StatsCard } from './StatsCard';
|
||||
export { DonutChart, getDonutPalette } from './DonutChart';
|
||||
export { ChapterBar } from './ChapterBar';
|
||||
export { default as PluginSection } from './PluginSection';
|
||||
export { default as ChartLegend } from './ChartLegend';
|
||||
export { default as DonutChartWithLegend } from './DonutChartWithLegend';
|
||||
@@ -0,0 +1,154 @@
|
||||
import { getString } from '@i18n/translations';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export function normalizeGenre(genre: string): string {
|
||||
return genre.trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function normalizeGenreDistribution(
|
||||
genres: Record<string, number>,
|
||||
): { name: string; count: number }[] {
|
||||
const merged: Record<string, number> = {};
|
||||
for (const [key, count] of Object.entries(genres)) {
|
||||
const normalized = normalizeGenre(key);
|
||||
merged[normalized] = (merged[normalized] || 0) + count;
|
||||
}
|
||||
return Object.entries(merged)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export function formatTimeSpent(totalMs: number | undefined) {
|
||||
if (totalMs === undefined || totalMs <= 0) {
|
||||
return getString('time.seconds', { count: 0 });
|
||||
}
|
||||
const d = dayjs.duration(totalMs, 'milliseconds');
|
||||
const asDays = Math.floor(d.asDays());
|
||||
const asHours = Math.floor(d.asHours());
|
||||
const asMinutes = Math.floor(d.asMinutes());
|
||||
const asSeconds = Math.floor(d.asSeconds());
|
||||
const hours = Math.floor(d.hours());
|
||||
const minutes = Math.floor(d.minutes());
|
||||
const seconds = Math.floor(d.seconds());
|
||||
|
||||
if (asDays >= 1) {
|
||||
return hours > 0
|
||||
? `${getString('time.days', { count: asDays })} ${getString('time.hours', { count: hours })}`
|
||||
: getString('time.days', { count: asDays });
|
||||
}
|
||||
if (asHours >= 1) {
|
||||
return minutes > 0
|
||||
? `${getString('time.hours', { count: asHours })} ${getString('time.minutes', { count: minutes })}`
|
||||
: getString('time.hours', { count: asHours });
|
||||
}
|
||||
if (asMinutes >= 1) {
|
||||
return seconds > 0
|
||||
? `${getString('time.minutes', { count: asMinutes })} ${getString('time.seconds', { count: seconds })}`
|
||||
: getString('time.minutes', { count: asMinutes });
|
||||
}
|
||||
return getString('time.seconds', { count: asSeconds });
|
||||
}
|
||||
|
||||
export function groupGenreDistribution(
|
||||
entries: { name: string; count: number }[]
|
||||
): { category: string; total: number; items: { name: string; count: number; isCategory: boolean }[] }[] {
|
||||
const knownRoots = [
|
||||
'Fantasy', 'Romance', 'Adventure', 'Action', 'Horror', 'Mystery',
|
||||
'Comedy', 'Drama', 'Sci-Fi', 'Fiction', 'Lead', 'Hero',
|
||||
];
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
count: number;
|
||||
normalized: string;
|
||||
}
|
||||
|
||||
const normalizedEntries: Entry[] = entries.map(e => ({
|
||||
...e,
|
||||
normalized: e.name.trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase()),
|
||||
}));
|
||||
|
||||
const assigned = new Set<number>();
|
||||
const groups: Map<string, { total: number; items: { name: string; count: number; isCategory: boolean }[] }> = new Map();
|
||||
|
||||
// Phase 1: match entries against known roots
|
||||
for (const root of knownRoots) {
|
||||
const matches = normalizedEntries.filter((e, i) => {
|
||||
if (assigned.has(i)) return false;
|
||||
if (e.normalized === root) return false; // the root itself
|
||||
return e.normalized.includes(root) || root.includes(e.normalized);
|
||||
});
|
||||
if (matches.length > 0 || normalizedEntries.some((e, i) => !assigned.has(i) && e.normalized === root)) {
|
||||
const rootEntry = normalizedEntries.find((e, i) => !assigned.has(i) && e.normalized === root);
|
||||
const rootIdx = normalizedEntries.findIndex((e, i) => !assigned.has(i) && e.normalized === root);
|
||||
if (rootEntry && rootIdx >= 0) {
|
||||
assigned.add(rootIdx);
|
||||
const group = groups.get(root) || { total: 0, items: [] };
|
||||
group.total += rootEntry.count;
|
||||
group.items.push({ name: rootEntry.name, count: rootEntry.count, isCategory: true });
|
||||
groups.set(root, group);
|
||||
}
|
||||
for (const m of matches) {
|
||||
const idx = normalizedEntries.indexOf(m);
|
||||
assigned.add(idx);
|
||||
const group = groups.get(root) || { total: 0, items: [] };
|
||||
group.total += m.count;
|
||||
group.items.push({ name: m.name, count: m.count, isCategory: false });
|
||||
groups.set(root, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: synthetic categories from trailing-word frequency
|
||||
const ungrouped = normalizedEntries.filter((_, i) => !assigned.has(i));
|
||||
const wordCounts: Map<string, { count: number; indices: number[] }> = new Map();
|
||||
for (let i = 0; i < ungrouped.length; i++) {
|
||||
const words = ungrouped[i].normalized.split(' ');
|
||||
if (words.length > 1) {
|
||||
const lastWord = words[words.length - 1];
|
||||
const entry = wordCounts.get(lastWord) || { count: 0, indices: [] };
|
||||
entry.count++;
|
||||
entry.indices.push(normalizedEntries.indexOf(ungrouped[i]));
|
||||
wordCounts.set(lastWord, entry);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [word, info] of wordCounts) {
|
||||
if (info.count >= 2) {
|
||||
for (const idx of info.indices) {
|
||||
if (!assigned.has(idx)) {
|
||||
assigned.add(idx);
|
||||
const e = normalizedEntries[idx];
|
||||
const group = groups.get(word) || { total: 0, items: [] };
|
||||
group.total += e.count;
|
||||
group.items.push({ name: e.name, count: e.count, isCategory: false });
|
||||
groups.set(word, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: leftovers become their own categories
|
||||
for (let i = 0; i < normalizedEntries.length; i++) {
|
||||
if (!assigned.has(i)) {
|
||||
assigned.add(i);
|
||||
const e = normalizedEntries[i];
|
||||
const group = groups.get(e.name) || { total: 0, items: [] };
|
||||
group.total += e.count;
|
||||
group.items.push({ name: e.name, count: e.count, isCategory: true });
|
||||
groups.set(e.name, group);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: categories by total descending, items by count descending with isCategory first
|
||||
return Array.from(groups.entries())
|
||||
.map(([category, g]) => ({
|
||||
category,
|
||||
total: g.total,
|
||||
items: g.items.sort((a, b) => {
|
||||
if (a.isCategory !== b.isCategory) return a.isCategory ? -1 : 1;
|
||||
return b.count - a.count;
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
}
|
||||
@@ -97,6 +97,16 @@ const SettingsScreen = ({ navigation }: SettingsScreenProps) => {
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title={getString('genreStats.taxonomyTitle')}
|
||||
icon="tag-multiple-outline"
|
||||
onPress={() =>
|
||||
navigation.navigate('SettingsStack', {
|
||||
screen: 'GenreTaxonomy',
|
||||
})
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useState } from 'react';
|
||||
import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { IconButton, TextInput } from 'react-native-paper';
|
||||
|
||||
import { Appbar, Dialog, List, SafeAreaView } from '@components';
|
||||
import ConfirmationDialog from '@components/ConfirmationDialog/ConfirmationDialog';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { useGenreTaxonomy } from '@hooks/persisted/useGenreTaxonomy';
|
||||
import { getString } from '@i18n/translations';
|
||||
import type { GenreTaxonomyScreenProps } from '@navigators/types';
|
||||
|
||||
const SettingsTaxonomyScreen = ({ navigation }: GenreTaxonomyScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const { taxonomy, setTaxonomy } = useGenreTaxonomy();
|
||||
|
||||
const [dialog, setDialog] = useState<
|
||||
| { type: 'addParent' }
|
||||
| { type: 'editParent'; parentName: string }
|
||||
| { type: 'none' }
|
||||
>({ type: 'none' });
|
||||
|
||||
// Form fields
|
||||
const [parentName, setParentName] = useState('');
|
||||
const [childName, setChildName] = useState('');
|
||||
|
||||
// Delete confirmation
|
||||
const [deleteTarget, setDeleteTarget] = useState<
|
||||
| { type: 'parent'; name: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setParentName('');
|
||||
setChildName('');
|
||||
};
|
||||
|
||||
const openDialog = (
|
||||
d:
|
||||
| { type: 'addParent' }
|
||||
| { type: 'editParent'; parentName: string },
|
||||
) => {
|
||||
resetForm();
|
||||
if (d.type === 'editParent') setParentName(d.parentName);
|
||||
setDialog(d);
|
||||
};
|
||||
|
||||
const handleAddParent = () => {
|
||||
const name = parentName.trim();
|
||||
if (!name) return;
|
||||
setTaxonomy([...taxonomy, { parent: name, children: [] }]);
|
||||
setDialog({ type: 'none' });
|
||||
};
|
||||
|
||||
const handleEditParent = () => {
|
||||
const name = parentName.trim();
|
||||
if (!name || dialog.type !== 'editParent') return;
|
||||
const oldName = dialog.parentName;
|
||||
const updated = taxonomy.map(t =>
|
||||
t.parent === oldName ? { ...t, parent: name } : t,
|
||||
);
|
||||
setTaxonomy(updated);
|
||||
setDialog({ type: 'none' });
|
||||
};
|
||||
|
||||
const handleAddChild = () => {
|
||||
const name = childName.trim();
|
||||
if (!name || dialog.type !== 'editParent') return;
|
||||
const updated = taxonomy.map(t =>
|
||||
t.parent === dialog.parentName
|
||||
? { ...t, children: [...t.children, name] }
|
||||
: t,
|
||||
);
|
||||
setTaxonomy(updated);
|
||||
setChildName('');
|
||||
};
|
||||
|
||||
const handleDeleteParent = (name: string) => {
|
||||
setTaxonomy(taxonomy.filter(t => t.parent !== name));
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleDeleteChild = (pName: string, cName: string) => {
|
||||
const updated = taxonomy.map(t =>
|
||||
t.parent === pName
|
||||
? { ...t, children: t.children.filter(c => c !== cName) }
|
||||
: t,
|
||||
);
|
||||
setTaxonomy(updated);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const hasTaxonomy = taxonomy.length > 0;
|
||||
|
||||
return (
|
||||
<SafeAreaView excludeTop>
|
||||
<Appbar
|
||||
title={getString('genreStats.taxonomyTitle')}
|
||||
handleGoBack={navigation.goBack}
|
||||
theme={theme}
|
||||
/>
|
||||
<ScrollView
|
||||
style={[{ backgroundColor: theme.background }, styles.flex]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* Parent categories section */}
|
||||
<List.Section>
|
||||
<List.SubHeader theme={theme}>
|
||||
{getString('genreStats.parentCategories')}
|
||||
</List.SubHeader>
|
||||
{hasTaxonomy ? (
|
||||
taxonomy.map(node => (
|
||||
<Pressable
|
||||
key={node.parent}
|
||||
onPress={() =>
|
||||
openDialog({ type: 'editParent', parentName: node.parent })
|
||||
}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text
|
||||
style={[styles.rowText, { color: theme.onSurface }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{node.parent}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.rowSubText,
|
||||
{ color: theme.onSurfaceVariant },
|
||||
]}
|
||||
>
|
||||
{getString('genreStats.subgenres', {
|
||||
count: node.children.length,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
<IconButton
|
||||
icon="close"
|
||||
iconColor={theme.onSurfaceVariant}
|
||||
size={20}
|
||||
onPress={() =>
|
||||
setDeleteTarget({
|
||||
type: 'parent',
|
||||
name: node.parent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
))
|
||||
) : (
|
||||
<List.Item
|
||||
title={getString('genreStats.noCategories')}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
<List.Item
|
||||
title={getString('genreStats.addCategory')}
|
||||
icon="plus"
|
||||
onPress={() => openDialog({ type: 'addParent' })}
|
||||
theme={theme}
|
||||
/>
|
||||
</List.Section>
|
||||
</ScrollView>
|
||||
|
||||
{/* Add / Edit Parent Dialog */}
|
||||
{(dialog.type === 'addParent' || dialog.type === 'editParent') && (
|
||||
<Dialog.Root
|
||||
visible
|
||||
onDismiss={() => setDialog({ type: 'none' })}
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{dialog.type === 'addParent'
|
||||
? getString('genreStats.addCategory')
|
||||
: dialog.parentName}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<Dialog.Content>
|
||||
<TextInput
|
||||
label={getString('genreStats.parentNamePlaceholder')}
|
||||
value={parentName}
|
||||
onChangeText={setParentName}
|
||||
mode="outlined"
|
||||
/>
|
||||
|
||||
{dialog.type === 'editParent' &&
|
||||
(() => {
|
||||
const node = taxonomy.find(
|
||||
t => t.parent === dialog.parentName,
|
||||
);
|
||||
if (!node) return null;
|
||||
return (
|
||||
<>
|
||||
{node.children.length > 0 && (
|
||||
<FlatList
|
||||
data={node.children}
|
||||
keyExtractor={item => item}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.childRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.childText,
|
||||
{ color: theme.onSurface },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
handleDeleteChild(dialog.parentName, item)
|
||||
}
|
||||
hitSlop={8}
|
||||
>
|
||||
<IconButton
|
||||
icon="close"
|
||||
iconColor={theme.onSurfaceVariant}
|
||||
size={18}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
style={styles.childList}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.inlineAddChild}>
|
||||
<TextInput
|
||||
label={getString('genreStats.childNamePlaceholder')}
|
||||
value={childName}
|
||||
onChangeText={setChildName}
|
||||
mode="outlined"
|
||||
style={styles.inlineInput}
|
||||
/>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
iconColor={theme.primary}
|
||||
size={24}
|
||||
disabled={!childName.trim()}
|
||||
onPress={handleAddChild}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions>
|
||||
<Dialog.Action onPress={() => setDialog({ type: 'none' })}>
|
||||
{getString('common.cancel')}
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
onPress={
|
||||
dialog.type === 'addParent'
|
||||
? handleAddParent
|
||||
: handleEditParent
|
||||
}
|
||||
>
|
||||
{getString('common.ok')}
|
||||
</Dialog.Action>
|
||||
</Dialog.Actions>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
|
||||
|
||||
{/* Delete confirmations */}
|
||||
{deleteTarget?.type === 'parent' && (
|
||||
<ConfirmationDialog
|
||||
visible
|
||||
title={getString('genreStats.deleteConfirmTitle')}
|
||||
message={getString('genreStats.deleteCategoryConfirm')}
|
||||
confirmLabel={getString('common.delete')}
|
||||
onConfirm={() => handleDeleteParent(deleteTarget.name)}
|
||||
onDismiss={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsTaxonomyScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
minHeight: 48,
|
||||
},
|
||||
rowTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
rowText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
rowSubText: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
rowActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
childList: {
|
||||
marginTop: 16,
|
||||
},
|
||||
childRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
childText: {
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
},
|
||||
inlineAddChild: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
inlineInput: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user