diff --git a/package.json b/package.json index 8e5d49a..229ef7a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@expo/vector-icons": "^13.0.0", "@expo/webpack-config": "^0.17.0", + "@react-native-async-storage/async-storage": "~1.17.3", "@react-native-picker/picker": "2.4.2", "@react-navigation/bottom-tabs": "^6.4.0", "@react-navigation/native": "^6.0.12", diff --git a/src/components/Chip.tsx b/src/components/Chip.tsx new file mode 100644 index 0000000..e97109f --- /dev/null +++ b/src/components/Chip.tsx @@ -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) => { + return + {props.children} + +}; + +export default Chip; diff --git a/src/components/ComicCard.tsx b/src/components/ComicCard.tsx index 6641243..6498bf6 100644 --- a/src/components/ComicCard.tsx +++ b/src/components/ComicCard.tsx @@ -1,16 +1,27 @@ +import { MaterialCommunityIcons } from "@expo/vector-icons"; import { FC } from "react"; import { Image, StyleSheet, Text, View } from "react-native"; import { ComicMetadata } from "../provider"; +import Chip from "./Chip"; interface Props { - comic: ComicMetadata + comic: ComicMetadata & { isLocal?: boolean } } const ComicCard: FC = (props) => { return - {props.comic.title} + + + {props.comic.title} + + {props.comic.isLocal && + + + + } + {props.comic.shortDescription} By {props.comic.author} @@ -29,9 +40,16 @@ const styles = StyleSheet.create({ flexDirection: 'column', paddingLeft: 8, }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + }, title: { fontSize: 24, }, + localChip: { + paddingLeft: 4, + }, subtitle: { fontSize: 16, }, diff --git a/src/components/ComicSelector.tsx b/src/components/ComicSelector.tsx new file mode 100644 index 0000000..5d5e3e6 --- /dev/null +++ b/src/components/ComicSelector.tsx @@ -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 void; isLocal?: boolean }> = (props) => { + return props.onClick()}> + + + + +} + +export default ComicSelector; + +const styles = StyleSheet.create({ + comic: { + padding: 8, + }, +}); diff --git a/src/pages/comic.tsx b/src/pages/comic.tsx index 276e839..be61f8e 100644 --- a/src/pages/comic.tsx +++ b/src/pages/comic.tsx @@ -1,8 +1,11 @@ import { MaterialCommunityIcons } from "@expo/vector-icons"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { useEffect } from "react"; import { ActivityIndicator, Image as NativeImage, ScrollView, StyleSheet, Text, View } from "react-native"; import { createImageProgress } from "react-native-image-progress"; import { Pie } from 'react-native-progress'; +import { useQueryClient } from "react-query"; import { RootStackParamList } from "../../App"; import Touchable from "../components/Touchable"; import { useComicPage } from "../provider"; @@ -12,6 +15,15 @@ const Image = createImageProgress(NativeImage); 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 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 ; if (error) return Failed to load comic: {error.toString()}; if (!data) return Whoops! We couldn't find that page :/; diff --git a/src/pages/comiclist.tsx b/src/pages/comiclist.tsx index f43578b..a3af9eb 100644 --- a/src/pages/comiclist.tsx +++ b/src/pages/comiclist.tsx @@ -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 { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { Text, View } from "react-native"; +import Library from "./comiclist/Library"; import LocalComicList from "./comiclist/Local"; const Tab = createBottomTabNavigator(); export type TabParamList = { Local: undefined; + Library: undefined; Remote: undefined; }; @@ -24,14 +26,14 @@ export default function ComicList() { screenOptions={({ route }) => { return { tabBarIcon: (({ focused, size, color }) => { - const iconName = route.name === 'Local' - ? (focused ? 'download' : 'download-outline') - : (focused ? 'cloud' : 'cloud-outline'); - return + if (route.name === 'Local') return + if (route.name === 'Remote') return + if (route.name === 'Library') return }) }; }} > + diff --git a/src/pages/comiclist/Library.tsx b/src/pages/comiclist/Library.tsx new file mode 100644 index 0000000..36aceae --- /dev/null +++ b/src/pages/comiclist/Library.tsx @@ -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, + NativeStackNavigationProp +>; + +export default function Library() { + const client = useQueryClient(); + const navigator = useNavigation(); + const { data, error, isLoading } = useLibraryComics(); + + useEffect(() => { + navigator.setOptions({ + headerRight: () => + + { + client.invalidateQueries('libraryComics'); + }} + > + + + + + }); + }, [client]); + + if (isLoading) return ; + if (error) return Failed to load comics: {error.toString()}; + if (!data) return Whoops! We couldn't load those comics :/; + + if (data.length === 0) { + return + + You aren't reading any comics + + } + + function openComic(provider: ComicProviderKey, id: string) { + navigator.navigate('ComicProfile', { + provider, + comicId: id, + }); + } + + return + 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, + }, +}); diff --git a/src/pages/comiclist/Local.tsx b/src/pages/comiclist/Local.tsx index 8e9a57f..9c6232e 100644 --- a/src/pages/comiclist/Local.tsx +++ b/src/pages/comiclist/Local.tsx @@ -11,20 +11,13 @@ import { RootStackParamList } from "../../../App"; import { LocalComicId } from "../../provider/local"; import { useQueryClient } from "react-query"; import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; +import ComicSelector from "../../components/ComicSelector"; export type ComicListNavigationProp = CompositeNavigationProp< BottomTabNavigationProp, NativeStackNavigationProp >; -const Comic: FC void }> = (props) => { - return props.onClick()}> - - - - -} - export default function LocalComicList() { const client = useQueryClient(); const navigator = useNavigation(); @@ -57,8 +50,8 @@ export default function LocalComicList() { }, [client]); if (isLoading) return ; - if (error) return Failed to load comic: {error.toString()}; - if (!data) return Whoops! We couldn't find that comic :/; + if (error) return Failed to load comics: {error.toString()}; + if (!data) return Whoops! We couldn't those comics :/; function openComic(comicId: LocalComicId) { navigation.navigate('ComicProfile', { @@ -74,7 +67,7 @@ export default function LocalComicList() { } - return openComic(item.id)} {...item} />} keyExtractor={item => item.id} /> + return openComic(item.id)} {...item} />} keyExtractor={item => item.id} /> } const styles = StyleSheet.create({ @@ -86,9 +79,6 @@ const styles = StyleSheet.create({ noComicsText: { color: '#666', }, - comic: { - padding: 8, - }, iconRow: { flexDirection: 'row', }, diff --git a/src/pages/comicprofile.tsx b/src/pages/comicprofile.tsx index 757bbcc..20281e3 100644 --- a/src/pages/comicprofile.tsx +++ b/src/pages/comicprofile.tsx @@ -2,10 +2,11 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { RootStackParamList } from "../../App"; import ComicCard from "../components/ComicCard"; -import { useComicMetadata } from "../provider"; +import { useComicMetadata, useCurrentPage } from "../provider"; import Divider from "../components/Divider"; import ChapterList from "../components/ChapterList"; import { MaterialCommunityIcons } from "@expo/vector-icons"; +import Touchable from "../components/Touchable"; export default function ComicProfile(props: Props) { 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 ; if (comicQuery.error) return Failed to load comic: {comicQuery.error.toString()}; if (!comicQuery.data) return Whoops! We couldn't find that comic :/; return + {currentPage && + props.navigation.push('Page', { + ...props.route.params, + pageId: currentPage, + })}> + + + } diff --git a/src/provider/index.ts b/src/provider/index.ts index 23d9574..f08bfcc 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -1,3 +1,4 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; import { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { useQuery } from "react-query"; import { RootStackParamList } from "../../App"; @@ -20,6 +21,8 @@ export interface ComicMetadata { avatarUrl: string; } +export type FullComicMetadata = ComicMetadata & { comicId: string; provider: ComicProviderKey; }; + export interface SimpleChapter { id: string; title: string; @@ -119,3 +122,39 @@ export function useLocalComics() { () => 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( + 'libraryComics', + async () => { + const keys = await AsyncStorage.getAllKeys(); + const promises: Promise[] = []; + 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( + ['currentPage', provider, comicId], + () => AsyncStorage.getItem(`comic:${provider}:${comicId}:lastPageRead`) + ); +} diff --git a/yarn.lock b/yarn.lock index ef24349..989d851 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1563,6 +1563,13 @@ mkdirp "^1.0.4" 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": version "8.0.4" 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" 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: version "2.0.4" 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" 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: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"