diff --git a/app/_layout.tsx b/app/_layout.tsx index 11919e3..a834ee6 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,5 +1,4 @@ import { useColorScheme } from "react-native"; -import { Provider } from "react-redux"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { @@ -12,8 +11,14 @@ import { adaptNavigationTheme, MD3DarkTheme as DarkPaperTheme, MD3LightTheme as LightPaperTheme, + ActivityIndicator, } from "react-native-paper"; import { useMaterial3Theme } from "@pchmn/expo-material3-theme"; +import { Provider } from "react-redux"; +import { + persistStore, +} from "redux-persist"; +import { PersistGate } from "redux-persist/integration/react"; import { store } from "../store"; import PaperNavigationBar from "../components/PaperNavigationBar"; @@ -25,25 +30,31 @@ const { LightTheme, DarkTheme } = adaptNavigationTheme({ const AppLayout = () => { const systemTheme = useColorScheme(); - const { theme: m3Theme } = useMaterial3Theme({ fallbackSourceColor: "#C8E6C9" }); + const { theme: m3Theme } = useMaterial3Theme({ + fallbackSourceColor: "#C8E6C9", + }); const paperTheme = systemTheme === "dark" ? { ...DarkPaperTheme, colors: m3Theme.dark } : { ...LightPaperTheme, colors: m3Theme.light }; + const persistor = persistStore(store); + return ( - - - - , - }} - /> - - + } persistor={persistor}> + + + + , + }} + /> + + + ); }; diff --git a/app/saved/index.tsx b/app/saved/index.tsx index 49d94b3..f0f828b 100644 --- a/app/saved/index.tsx +++ b/app/saved/index.tsx @@ -1,15 +1,25 @@ -import { Text } from "react-native-paper"; +import { FlatList, View } from "react-native"; +import { Stack } from "expo-router"; + import { useAppSelector } from "../../hooks/store"; import { selectSavedSetlists } from "../../store/saved/slice"; +import SetlistListItem from "../../components/SetlistListItem"; +import NoSavedSetlistsCard from "../../components/NoSavedSetlistsCard"; /** List of saved setlists marked by user */ const SavedSetlists = () => { const setlists = useAppSelector(selectSavedSetlists); - - // @TODO: add view for saved setlists with save/unsave button, navigation to setlist, title - return ( - slay! + + + } + ListEmptyComponent={} + /> + ) }; diff --git a/app/setlist/[setlistId].tsx b/app/setlist/[setlistId].tsx index f6d6d5f..4d51cd8 100644 --- a/app/setlist/[setlistId].tsx +++ b/app/setlist/[setlistId].tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Share, StyleSheet, View } from "react-native"; import { ActivityIndicator, @@ -6,29 +6,42 @@ import { Divider, FAB, List, + Snackbar, Text, } from "react-native-paper"; -import { Link, Stack, useLocalSearchParams } from "expo-router"; +import { Link, router, Stack, useLocalSearchParams } from "expo-router"; import * as Linking from "expo-linking"; import { isAfter, parse } from "date-fns"; import { getNetworkStateAsync } from "expo-network"; import { Image } from "expo-image"; +import { openBrowserAsync } from "expo-web-browser"; import { useGet10SetlistBySetlistIdQuery } from "../../store/services/setlistFm"; import SetlistEmptyCard from "../../components/SetlistEmptyCard"; import SetlistSectionList from "../../components/SetlistSectionList"; import SetlistMetadataList from "../../components/SetlistMetadataList"; import AddToPlaylistAppbarAction from "../../components/AddToPlaylistAppbarAction"; -import { openBrowserAsync } from "expo-web-browser"; +import { useAppDispatch, useAppSelector } from "../../hooks/store"; +import { + unsaveSetlistById, + selectSetlistIsSaved, + saveSetlist, +} from "../../store/saved/slice"; /** View for setlist set, metadata, links */ const SetlistDetails = () => { + const dispatch = useAppDispatch(); const { setlistId } = useLocalSearchParams<{ setlistId: string }>(); + const { data: setlist, isLoading } = useGet10SetlistBySetlistIdQuery({ setlistId: setlistId!, }); const [networkIsAvailable, setNetworkState] = useState(false); + const [savedSnackbarVisible, setSavedSnackbarVisible] = useState(false); + const isSaved = useAppSelector((store) => + selectSetlistIsSaved(store, setlistId!), + ); const setlistEmpty = !isLoading && !setlist?.sets?.set?.length; const setlistInPast = setlist?.eventDate && @@ -126,6 +139,23 @@ const SetlistDetails = () => { ); + const toggleSaveState = useCallback(() => { + if (isSaved) { + dispatch(unsaveSetlistById(setlistId!)); + setSavedSnackbarVisible(true); + return; + } + dispatch( + saveSetlist({ + id: setlistId!, + artist: setlist?.artist, + eventDate: setlist?.eventDate, + venue: setlist?.venue, + }), + ); + setSavedSnackbarVisible(true); + }, [isSaved, setlistId, setlist]); + useEffect(() => { const setNetworkStatus = async () => { const state = await getNetworkStateAsync(); @@ -143,7 +173,15 @@ const SetlistDetails = () => { headerRight: () => setlist && ( <> - + { style={styles.floatingButton} /> )} + setSavedSnackbarVisible(false)} + action={ + isSaved + ? { + label: "View", + onPress: () => { + router.navigate("/saved"); + }, + } + : undefined + } + > + {isSaved + ? "Added to your saved setlists" + : "Removed from your saved setlists"} + ); }; diff --git a/components/NoSavedSetlistsCard/index.tsx b/components/NoSavedSetlistsCard/index.tsx new file mode 100644 index 0000000..e670c3f --- /dev/null +++ b/components/NoSavedSetlistsCard/index.tsx @@ -0,0 +1,31 @@ +import { StyleSheet, StyleProp, ViewStyle } from "react-native"; +import { Card, Text } from "react-native-paper"; + +/** Message to display when no setlist data has been detected */ +const NoSavedSetlistsCard = () => ( + + + + + Search for your favourite setlists, then add them by clicking on the {`\u2b50`} button. + + + +); + +const styles = StyleSheet.create({ + container: { + marginHorizontal: 12, + }, + title: { + fontWeight: "bold", + paddingTop: 8, + }, +}); + +export default NoSavedSetlistsCard; diff --git a/components/SearchHeader/index.tsx b/components/SearchHeader/index.tsx index 32e53c8..ab24056 100644 --- a/components/SearchHeader/index.tsx +++ b/components/SearchHeader/index.tsx @@ -21,7 +21,7 @@ const SearchHeader = ({ loading, }: SearchHeaderProps) => ( - router.back()} /> + router.back()} isLeading /> AppDispatch = useDispatch; +export const createAppSelector = createSelector.withTypes(); export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/package.json b/package.json index a9750b3..0539cbb 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "@lomray/react-native-apple-music": "^1.2.1", "@pchmn/expo-material3-theme": "^1.3.2", + "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/elements": "^1.3.21", "@react-navigation/native": "^6.1.9", "@react-navigation/native-stack": "^6.9.17", @@ -50,7 +51,8 @@ "react-native-screens": "3.31.1", "react-native-svg": "15.2.0", "react-native-vector-icons": "^10.0.2", - "react-redux": "^9.1.0" + "react-redux": "^9.1.0", + "redux-persist": "^6.0.0" }, "devDependencies": { "@babel/core": "^7.24.0", diff --git a/store/index.ts b/store/index.ts index 891436e..378d26d 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,20 +1,43 @@ -import { configureStore } from "@reduxjs/toolkit"; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { setupListeners } from "@reduxjs/toolkit/query"; +import { + persistReducer, + FLUSH, + REHYDRATE, + PAUSE, + PERSIST, + PURGE, + REGISTER, +} from "redux-persist"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { setlistFmApi } from "./services/setlistFm"; import { spotifyApi } from "./services/spotify"; import { appleMusicApi } from "./services/appleMusic"; import { savedSlice } from "./saved/slice"; +const persistConfig = { + key: 'root', + version: 1, + storage: AsyncStorage, + whitelist: ['saved'], +} + +const rootReducer = combineReducers({ + [setlistFmApi.reducerPath]: setlistFmApi.reducer, + [spotifyApi.reducerPath]: spotifyApi.reducer, + [appleMusicApi.reducerPath]: appleMusicApi.reducer, + saved: savedSlice.reducer, +}); + export const store = configureStore({ - reducer: { - [setlistFmApi.reducerPath]: setlistFmApi.reducer, - [spotifyApi.reducerPath]: spotifyApi.reducer, - [appleMusicApi.reducerPath]: appleMusicApi.reducer, - saved: savedSlice.reducer, - }, + reducer: persistReducer(persistConfig, rootReducer), middleware: (getDefaultMiddleware) => - getDefaultMiddleware() + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }) .concat(setlistFmApi.middleware) .concat(spotifyApi.middleware) .concat(appleMusicApi.middleware), diff --git a/store/saved/slice.ts b/store/saved/slice.ts index af5d371..00fcf60 100644 --- a/store/saved/slice.ts +++ b/store/saved/slice.ts @@ -1,9 +1,10 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { Setlist } from "../services/setlistFm"; import { parse } from "date-fns"; import { RootState } from "../types"; +import { createAppSelector } from "../../hooks/store"; -type PartialSetlist = Pick; +type PartialSetlist = Pick; interface SavedState { setlists: PartialSetlist[]; @@ -19,7 +20,7 @@ export const savedSlice = createSlice({ name: "saved", initialState, reducers: { - addSetlist: (state, action: PayloadAction) => { + saveSetlist: (state, action: PayloadAction) => { if (state.setlistIds.includes(action.payload.id!)) { return; } @@ -28,7 +29,7 @@ export const savedSlice = createSlice({ state.setlists.sort((a, b) => (parse(b.eventDate!, "d-M-y", new Date()).valueOf() - parse(a.eventDate!, "d-M-y", new Date()).valueOf())); state.setlistIds = state.setlists.map(({ id }) => id!); }, - removeSetlistById: (state, action: PayloadAction) => { + unsaveSetlistById: (state, action: PayloadAction) => { const indexToRemove = state.setlistIds.findIndex(x => x === action.payload); if (indexToRemove === -1) { @@ -47,8 +48,12 @@ export const savedSlice = createSlice({ } }); -export const { addSetlist, clearList, removeSetlistById } = savedSlice.actions; +export const { saveSetlist, clearList, unsaveSetlistById } = savedSlice.actions; export const selectSavedSetlists = (state: RootState) => state.saved.setlists; +export const selectSetlistIsSaved = createAppSelector( + [(state) => state.saved.setlistIds, (_, setlistId: string) => setlistId], + (savedSetlistIds, currentId) => savedSetlistIds.includes(currentId) +); export default savedSlice.reducer; diff --git a/yarn.lock b/yarn.lock index 6c4be86..eca073a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2013,6 +2013,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.0" +"@react-native-async-storage/async-storage@1.23.1": + version "1.23.1" + resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz#cad3cd4fab7dacfe9838dce6ecb352f79150c883" + integrity sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA== + dependencies: + merge-options "^3.0.4" + "@react-native-community/cli-clean@13.6.6": version "13.6.6" resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-13.6.6.tgz#87c7ad8746c38dab0fe7b3c6ff89d44351d5d943" @@ -5697,6 +5704,11 @@ is-path-inside@^3.0.2, is-path-inside@^3.0.3: 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.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -6394,6 +6406,13 @@ memory-cache@~0.2.0: resolved "https://registry.yarnpkg.com/memory-cache/-/memory-cache-0.2.0.tgz#7890b01d52c00c8ebc9d533e1f8eb17e3034871a" integrity sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA== +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" @@ -7718,6 +7737,11 @@ recast@^0.21.0: source-map "~0.6.1" tslib "^2.0.1" +redux-persist@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" + integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== + redux-thunk@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"