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"