feat: Save last page and add library view for comics
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ashhhleyyy 2022-09-26 19:35:13 +01:00
parent 0edd8778a3
commit 821f8331e5
Signed by: ash
GPG key ID: 83B789081A0878FB
11 changed files with 238 additions and 23 deletions

View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^13.0.0", "@expo/vector-icons": "^13.0.0",
"@expo/webpack-config": "^0.17.0", "@expo/webpack-config": "^0.17.0",
"@react-native-async-storage/async-storage": "~1.17.3",
"@react-native-picker/picker": "2.4.2", "@react-native-picker/picker": "2.4.2",
"@react-navigation/bottom-tabs": "^6.4.0", "@react-navigation/bottom-tabs": "^6.4.0",
"@react-navigation/native": "^6.0.12", "@react-navigation/native": "^6.0.12",

22
src/components/Chip.tsx Normal file
View file

@ -0,0 +1,22 @@
import { FC, ReactNode } from "react";
import { Text, View } from "react-native";
interface Props {
colour: string;
children: ReactNode,
}
const Chip: FC<Props> = (props) => {
return <Text style={{
backgroundColor: props.colour,
padding: 4,
borderRadius: 4,
fontSize: 12,
height: 24,
color: 'white',
}}>
{props.children}
</Text>
};
export default Chip;

View file

@ -1,16 +1,27 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { FC } from "react"; import { FC } from "react";
import { Image, StyleSheet, Text, View } from "react-native"; import { Image, StyleSheet, Text, View } from "react-native";
import { ComicMetadata } from "../provider"; import { ComicMetadata } from "../provider";
import Chip from "./Chip";
interface Props { interface Props {
comic: ComicMetadata comic: ComicMetadata & { isLocal?: boolean }
} }
const ComicCard: FC<Props> = (props) => { const ComicCard: FC<Props> = (props) => {
return <View style={styles.aboutRow}> return <View style={styles.aboutRow}>
<Image source={{ uri: props.comic.avatarUrl }} style={styles.avatar} /> <Image source={{ uri: props.comic.avatarUrl }} style={styles.avatar} />
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={styles.title}>{props.comic.title}</Text> <View style={styles.titleRow}>
<Text style={styles.title}>
{props.comic.title}
</Text>
{props.comic.isLocal && <View style={styles.localChip}>
<Chip colour="#d602ee">
<MaterialCommunityIcons name="download" size={16} />
</Chip>
</View>}
</View>
<Text style={styles.subtitle}>{props.comic.shortDescription}</Text> <Text style={styles.subtitle}>{props.comic.shortDescription}</Text>
<Text style={styles.author}>By {props.comic.author}</Text> <Text style={styles.author}>By {props.comic.author}</Text>
</View> </View>
@ -29,9 +40,16 @@ const styles = StyleSheet.create({
flexDirection: 'column', flexDirection: 'column',
paddingLeft: 8, paddingLeft: 8,
}, },
titleRow: {
flexDirection: 'row',
alignItems: 'center',
},
title: { title: {
fontSize: 24, fontSize: 24,
}, },
localChip: {
paddingLeft: 4,
},
subtitle: { subtitle: {
fontSize: 16, fontSize: 16,
}, },

View file

@ -0,0 +1,21 @@
import { FC } from "react"
import { StyleSheet, View } from "react-native"
import { ComicMetadata } from "../provider"
import ComicCard from "./ComicCard"
import Touchable from "./Touchable"
const ComicSelector: FC<ComicMetadata & { onClick: () => void; isLocal?: boolean }> = (props) => {
return <Touchable onPress={() => props.onClick()}>
<View style={styles.comic}>
<ComicCard comic={props} />
</View>
</Touchable>
}
export default ComicSelector;
const styles = StyleSheet.create({
comic: {
padding: 8,
},
});

View file

@ -1,8 +1,11 @@
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { useEffect } from "react";
import { ActivityIndicator, Image as NativeImage, ScrollView, StyleSheet, Text, View } from "react-native"; import { ActivityIndicator, Image as NativeImage, ScrollView, StyleSheet, Text, View } from "react-native";
import { createImageProgress } from "react-native-image-progress"; import { createImageProgress } from "react-native-image-progress";
import { Pie } from 'react-native-progress'; import { Pie } from 'react-native-progress';
import { useQueryClient } from "react-query";
import { RootStackParamList } from "../../App"; import { RootStackParamList } from "../../App";
import Touchable from "../components/Touchable"; import Touchable from "../components/Touchable";
import { useComicPage } from "../provider"; import { useComicPage } from "../provider";
@ -12,6 +15,15 @@ const Image = createImageProgress(NativeImage);
export default function Comic(props: Props) { export default function Comic(props: Props) {
const { data, error, isLoading } = useComicPage(props.route.params.provider, props.route.params.comicId, props.route.params.pageId, props.navigation); const { data, error, isLoading } = useComicPage(props.route.params.provider, props.route.params.comicId, props.route.params.pageId, props.navigation);
const queryClient = useQueryClient();
useEffect(() => {
if (data) {
AsyncStorage.setItem(`comic:${props.route.params.provider}:${props.route.params.comicId}:lastPageRead`, data.id);
queryClient.invalidateQueries(['currentPage', props.route.params.provider, props.route.params.comicId])
}
}, [data]);
if (isLoading) return <ActivityIndicator />; if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Failed to load comic: {error.toString()}</Text>; if (error) return <Text>Failed to load comic: {error.toString()}</Text>;
if (!data) return <Text>Whoops! We couldn't find that page :/</Text>; if (!data) return <Text>Whoops! We couldn't find that page :/</Text>;

View file

@ -1,12 +1,14 @@
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons, Ionicons } from "@expo/vector-icons";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { Text, View } from "react-native";
import Library from "./comiclist/Library";
import LocalComicList from "./comiclist/Local"; import LocalComicList from "./comiclist/Local";
const Tab = createBottomTabNavigator<TabParamList>(); const Tab = createBottomTabNavigator<TabParamList>();
export type TabParamList = { export type TabParamList = {
Local: undefined; Local: undefined;
Library: undefined;
Remote: undefined; Remote: undefined;
}; };
@ -24,14 +26,14 @@ export default function ComicList() {
screenOptions={({ route }) => { screenOptions={({ route }) => {
return { return {
tabBarIcon: (({ focused, size, color }) => { tabBarIcon: (({ focused, size, color }) => {
const iconName = route.name === 'Local' if (route.name === 'Local') return <MaterialCommunityIcons name={focused ? 'download' : 'download-outline'} size={size} color={color} />
? (focused ? 'download' : 'download-outline') if (route.name === 'Remote') return <MaterialCommunityIcons name={focused ? 'cloud' : 'cloud-outline'} size={size} color={color} />
: (focused ? 'cloud' : 'cloud-outline'); if (route.name === 'Library') return <Ionicons name={focused ? 'library' : 'library-outline'} size={size} color={color} />
return <MaterialCommunityIcons name={iconName} size={size} color={color} />
}) })
}; };
}} }}
> >
<Tab.Screen name="Library" component={Library} />
<Tab.Screen name="Local" component={LocalComicList} /> <Tab.Screen name="Local" component={LocalComicList} />
<Tab.Screen name="Remote" component={Remote} /> <Tab.Screen name="Remote" component={Remote} />
</Tab.Navigator> </Tab.Navigator>

View file

@ -0,0 +1,80 @@
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
import { CompositeNavigationProp, useNavigation } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useEffect } from "react";
import { ActivityIndicator, FlatList, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useQueryClient } from "react-query";
import { RootStackParamList } from "../../../App";
import ComicSelector from "../../components/ComicSelector";
import { ComicProviderKey, useLibraryComics } from "../../provider";
import { TabParamList } from "../comiclist";
export type LibraryNavigationProp = CompositeNavigationProp<
BottomTabNavigationProp<TabParamList, 'Local'>,
NativeStackNavigationProp<RootStackParamList, 'Home'>
>;
export default function Library() {
const client = useQueryClient();
const navigator = useNavigation<LibraryNavigationProp>();
const { data, error, isLoading } = useLibraryComics();
useEffect(() => {
navigator.setOptions({
headerRight: () => <View style={styles.iconRow}>
<View style={styles.padding}>
<TouchableOpacity
onPress={() => {
client.invalidateQueries('libraryComics');
}}
>
<MaterialCommunityIcons name="refresh" size={24} style={{ color: "black" }} />
</TouchableOpacity>
</View>
</View>
});
}, [client]);
if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Failed to load comics: {error.toString()}</Text>;
if (!data) return <Text>Whoops! We couldn't load those comics :/</Text>;
if (data.length === 0) {
return <View style={styles.center}>
<MaterialIcons name='sentiment-dissatisfied' size={32} color='#666' />
<Text style={styles.noComicsText}>You aren't reading any comics</Text>
</View>
}
function openComic(provider: ComicProviderKey, id: string) {
navigator.navigate('ComicProfile', {
provider,
comicId: id,
});
}
return <FlatList data={data} renderItem={({ item }) =>
<ComicSelector onClick={() => openComic(item.provider, item.comicId)} isLocal={item.provider === 'local'} {...item} />
} keyExtractor={item => item.provider + ':' + item.comicId} />
}
const styles = StyleSheet.create({
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
noComicsText: {
color: '#666',
},
comic: {
padding: 8,
},
iconRow: {
flexDirection: 'row',
},
padding: {
paddingEnd: 16,
},
});

View file

@ -11,20 +11,13 @@ import { RootStackParamList } from "../../../App";
import { LocalComicId } from "../../provider/local"; import { LocalComicId } from "../../provider/local";
import { useQueryClient } from "react-query"; import { useQueryClient } from "react-query";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import ComicSelector from "../../components/ComicSelector";
export type ComicListNavigationProp = CompositeNavigationProp< export type ComicListNavigationProp = CompositeNavigationProp<
BottomTabNavigationProp<TabParamList, 'Local'>, BottomTabNavigationProp<TabParamList, 'Local'>,
NativeStackNavigationProp<RootStackParamList, 'Home'> NativeStackNavigationProp<RootStackParamList, 'Home'>
>; >;
const Comic: FC<ComicMetadata & { onClick: () => void }> = (props) => {
return <Touchable onPress={() => props.onClick()}>
<View style={styles.comic}>
<ComicCard comic={props} />
</View>
</Touchable>
}
export default function LocalComicList() { export default function LocalComicList() {
const client = useQueryClient(); const client = useQueryClient();
const navigator = useNavigation<ComicListNavigationProp>(); const navigator = useNavigation<ComicListNavigationProp>();
@ -57,8 +50,8 @@ export default function LocalComicList() {
}, [client]); }, [client]);
if (isLoading) return <ActivityIndicator />; if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Failed to load comic: {error.toString()}</Text>; if (error) return <Text>Failed to load comics: {error.toString()}</Text>;
if (!data) return <Text>Whoops! We couldn't find that comic :/</Text>; if (!data) return <Text>Whoops! We couldn't those comics :/</Text>;
function openComic(comicId: LocalComicId) { function openComic(comicId: LocalComicId) {
navigation.navigate('ComicProfile', { navigation.navigate('ComicProfile', {
@ -74,7 +67,7 @@ export default function LocalComicList() {
</View> </View>
} }
return <FlatList data={data} renderItem={({ item }) => <Comic onClick={() => openComic(item.id)} {...item} />} keyExtractor={item => item.id} /> return <FlatList data={data} renderItem={({ item }) => <ComicSelector onClick={() => openComic(item.id)} {...item} />} keyExtractor={item => item.id} />
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@ -86,9 +79,6 @@ const styles = StyleSheet.create({
noComicsText: { noComicsText: {
color: '#666', color: '#666',
}, },
comic: {
padding: 8,
},
iconRow: { iconRow: {
flexDirection: 'row', flexDirection: 'row',
}, },

View file

@ -2,10 +2,11 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { RootStackParamList } from "../../App"; import { RootStackParamList } from "../../App";
import ComicCard from "../components/ComicCard"; import ComicCard from "../components/ComicCard";
import { useComicMetadata } from "../provider"; import { useComicMetadata, useCurrentPage } from "../provider";
import Divider from "../components/Divider"; import Divider from "../components/Divider";
import ChapterList from "../components/ChapterList"; import ChapterList from "../components/ChapterList";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import Touchable from "../components/Touchable";
export default function ComicProfile(props: Props) { export default function ComicProfile(props: Props) {
const comicQuery = useComicMetadata(props.route.params.provider, props.route.params.comicId, (data) => { const comicQuery = useComicMetadata(props.route.params.provider, props.route.params.comicId, (data) => {
@ -27,12 +28,22 @@ export default function ComicProfile(props: Props) {
}); });
}); });
const { data: currentPage } = useCurrentPage(props.route.params.provider, props.route.params.comicId);
if (comicQuery.isLoading) return <ActivityIndicator />; if (comicQuery.isLoading) return <ActivityIndicator />;
if (comicQuery.error) return <Text>Failed to load comic: {comicQuery.error.toString()}</Text>; if (comicQuery.error) return <Text>Failed to load comic: {comicQuery.error.toString()}</Text>;
if (!comicQuery.data) return <Text>Whoops! We couldn't find that comic :/</Text>; if (!comicQuery.data) return <Text>Whoops! We couldn't find that comic :/</Text>;
return <View style={styles.container}> return <View style={styles.container}>
<ComicCard comic={comicQuery.data} /> <ComicCard comic={comicQuery.data} />
{currentPage && <View>
<Touchable onPress={() => props.navigation.push('Page', {
...props.route.params,
pageId: currentPage,
})}>
<MaterialCommunityIcons name="bookmark" size={24} color="red" />
</Touchable>
</View>}
<Divider /> <Divider />
<ChapterList provider={props.route.params.provider} comicId={props.route.params.comicId} /> <ChapterList provider={props.route.params.provider} comicId={props.route.params.comicId} />
</View> </View>

View file

@ -1,3 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { RootStackParamList } from "../../App"; import { RootStackParamList } from "../../App";
@ -20,6 +21,8 @@ export interface ComicMetadata {
avatarUrl: string; avatarUrl: string;
} }
export type FullComicMetadata = ComicMetadata & { comicId: string; provider: ComicProviderKey; };
export interface SimpleChapter { export interface SimpleChapter {
id: string; id: string;
title: string; title: string;
@ -119,3 +122,39 @@ export function useLocalComics() {
() => COMIC_PROVIDERS['local'].listComics(), () => COMIC_PROVIDERS['local'].listComics(),
); );
} }
/**
* Library comics are those that the user is currently reading and have a saved bookmark for
*/
export function useLibraryComics() {
return useQuery<FullComicMetadata[], Error>(
'libraryComics',
async () => {
const keys = await AsyncStorage.getAllKeys();
const promises: Promise<FullComicMetadata | null>[] = [];
for (const key of keys) {
const matches = /^comic:([a-z]+):([a-zA-Z0-9-_]+):lastPageRead$/.exec(key);
if (!matches) continue;
const provider = matches[1] as ComicProviderKey;
const id = matches[2];
promises.push(COMIC_PROVIDERS[provider].getComicInfo(id).then(comic => {
if (!comic) return comic;
return {
...comic,
comicId: id,
provider,
};
}));
}
const comics = await Promise.all(promises);
return comics.filter(comic => comic !== null) as FullComicMetadata[];
},
);
}
export function useCurrentPage(provider: string, comicId: string) {
return useQuery<string | null, Error>(
['currentPage', provider, comicId],
() => AsyncStorage.getItem(`comic:${provider}:${comicId}:lastPageRead`)
);
}

View file

@ -1563,6 +1563,13 @@
mkdirp "^1.0.4" mkdirp "^1.0.4"
rimraf "^3.0.2" rimraf "^3.0.2"
"@react-native-async-storage/async-storage@~1.17.3":
version "1.17.10"
resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.17.10.tgz#8d6a4771912be8454a9e215eebd469b1b8e2e638"
integrity sha512-KrR021BmBLsA0TT1AAsfH16bHYy0MSbhdAeBAqpriak3GS1T2alFcdTUvn13p0ZW6FKRD6Bd3ryU2zhU/IYYJQ==
dependencies:
merge-options "^3.0.4"
"@react-native-community/cli-clean@^8.0.4": "@react-native-community/cli-clean@^8.0.4":
version "8.0.4" version "8.0.4"
resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-8.0.4.tgz#97e16a20e207b95de12e29b03816e8f2b2c80cc7" resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-8.0.4.tgz#97e16a20e207b95de12e29b03816e8f2b2c80cc7"
@ -5977,6 +5984,11 @@ is-path-inside@^3.0.2:
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
is-plain-obj@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
is-plain-object@^2.0.3, is-plain-object@^2.0.4: is-plain-object@^2.0.3, is-plain-object@^2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@ -6653,6 +6665,13 @@ merge-descriptors@1.0.1:
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==
merge-options@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.4.tgz#84709c2aa2a4b24c1981f66c179fe5565cc6dbb7"
integrity sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==
dependencies:
is-plain-obj "^2.1.0"
merge-stream@^2.0.0: merge-stream@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"