template_0205
This commit is contained in:
1223
RN_TEMPLATE/app/context/AuthContext.tsx
Normal file
1223
RN_TEMPLATE/app/context/AuthContext.tsx
Normal file
File diff suppressed because it is too large
Load Diff
134
RN_TEMPLATE/app/context/EpisodeContext.tsx
Normal file
134
RN_TEMPLATE/app/context/EpisodeContext.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
createContext,
|
||||
FC,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import { translate } from "@/i18n/translate"
|
||||
import { api } from "@/services/api"
|
||||
import type { EpisodeItem } from "@/services/api/types"
|
||||
import { formatDate } from "@/utils/formatDate"
|
||||
|
||||
export type EpisodeContextType = {
|
||||
totalEpisodes: number
|
||||
totalFavorites: number
|
||||
episodesForList: EpisodeItem[]
|
||||
fetchEpisodes: () => Promise<void>
|
||||
favoritesOnly: boolean
|
||||
toggleFavoritesOnly: () => void
|
||||
hasFavorite: (episode: EpisodeItem) => boolean
|
||||
toggleFavorite: (episode: EpisodeItem) => void
|
||||
}
|
||||
|
||||
export const EpisodeContext = createContext<EpisodeContextType | null>(null)
|
||||
|
||||
export interface EpisodeProviderProps {}
|
||||
|
||||
export const EpisodeProvider: FC<PropsWithChildren<EpisodeProviderProps>> = ({ children }) => {
|
||||
const [episodes, setEpisodes] = useState<EpisodeItem[]>([])
|
||||
const [favorites, setFavorites] = useState<string[]>([])
|
||||
const [favoritesOnly, setFavoritesOnly] = useState<boolean>(false)
|
||||
|
||||
const fetchEpisodes = useCallback(async () => {
|
||||
const response = await api.getEpisodes()
|
||||
if (response.kind === "ok") {
|
||||
setEpisodes(response.episodes)
|
||||
} else {
|
||||
console.error(`Error fetching episodes: ${JSON.stringify(response)}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleFavoritesOnly = useCallback(() => {
|
||||
setFavoritesOnly((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
(episode: EpisodeItem) => {
|
||||
if (favorites.some((fav) => fav === episode.guid)) {
|
||||
setFavorites((prev) => prev.filter((fav) => fav !== episode.guid))
|
||||
} else {
|
||||
setFavorites((prev) => [...prev, episode.guid])
|
||||
}
|
||||
},
|
||||
[favorites],
|
||||
)
|
||||
|
||||
const hasFavorite = useCallback(
|
||||
(episode: EpisodeItem) => favorites.some((fav) => fav === episode.guid),
|
||||
[favorites],
|
||||
)
|
||||
|
||||
const episodesForList = useMemo(() => {
|
||||
return favoritesOnly ? episodes.filter((episode) => favorites.includes(episode.guid)) : episodes
|
||||
}, [episodes, favorites, favoritesOnly])
|
||||
|
||||
const value = {
|
||||
totalEpisodes: episodes.length,
|
||||
totalFavorites: favorites.length,
|
||||
episodesForList,
|
||||
fetchEpisodes,
|
||||
favoritesOnly,
|
||||
toggleFavoritesOnly,
|
||||
hasFavorite,
|
||||
toggleFavorite,
|
||||
}
|
||||
|
||||
return <EpisodeContext.Provider value={value}>{children}</EpisodeContext.Provider>
|
||||
}
|
||||
|
||||
export const useEpisodes = () => {
|
||||
const context = useContext(EpisodeContext)
|
||||
if (!context) throw new Error("useEpisodes must be used within an EpisodeProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
// A helper hook to extract and format episode details
|
||||
export const useEpisode = (episode: EpisodeItem) => {
|
||||
const { hasFavorite } = useEpisodes()
|
||||
|
||||
const isFavorite = hasFavorite(episode)
|
||||
|
||||
let datePublished
|
||||
try {
|
||||
const formatted = formatDate(episode.pubDate)
|
||||
datePublished = {
|
||||
textLabel: formatted,
|
||||
accessibilityLabel: translate("demoPodcastListScreen:accessibility.publishLabel", {
|
||||
date: formatted,
|
||||
}),
|
||||
}
|
||||
} catch {
|
||||
datePublished = { textLabel: "", accessibilityLabel: "" }
|
||||
}
|
||||
|
||||
const seconds = Number(episode.enclosure?.duration ?? 0)
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor((seconds % 3600) % 60)
|
||||
const duration = {
|
||||
textLabel: `${h > 0 ? `${h}:` : ""}${m > 0 ? `${m}:` : ""}${s}`,
|
||||
accessibilityLabel: translate("demoPodcastListScreen:accessibility.durationLabel", {
|
||||
hours: h,
|
||||
minutes: m,
|
||||
seconds: s,
|
||||
}),
|
||||
}
|
||||
|
||||
const trimmedTitle = episode.title?.trim()
|
||||
const titleMatches = trimmedTitle?.match(/^(RNR.*\d)(?: - )(.*$)/)
|
||||
const parsedTitleAndSubtitle =
|
||||
titleMatches && titleMatches.length === 3
|
||||
? { title: titleMatches[1], subtitle: titleMatches[2] }
|
||||
: { title: trimmedTitle, subtitle: "" }
|
||||
|
||||
return {
|
||||
isFavorite,
|
||||
datePublished,
|
||||
duration,
|
||||
parsedTitleAndSubtitle,
|
||||
}
|
||||
}
|
||||
228
RN_TEMPLATE/app/context/ToastContext.tsx
Normal file
228
RN_TEMPLATE/app/context/ToastContext.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
createContext,
|
||||
FC,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Animated, Modal, TextStyle, View, ViewStyle } from "react-native"
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||
|
||||
import { Text } from "@/components/Text"
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
export type ToastType = "success" | "error" | "warning" | "info"
|
||||
|
||||
export interface ToastConfig {
|
||||
message: string
|
||||
type?: ToastType
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface ToastContextType {
|
||||
show: (config: ToastConfig) => void
|
||||
success: (message: string, duration?: number) => void
|
||||
error: (message: string, duration?: number) => void
|
||||
warning: (message: string, duration?: number) => void
|
||||
info: (message: string, duration?: number) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
// Default durations by type (in ms)
|
||||
const DEFAULT_DURATIONS: Record<ToastType, number> = {
|
||||
success: 3000,
|
||||
error: 5000, // Errors show longer
|
||||
warning: 4000,
|
||||
info: 3000,
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null)
|
||||
|
||||
export const ToastProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [config, setConfig] = useState<ToastConfig>({ message: "" })
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const {
|
||||
themed,
|
||||
theme: { colors },
|
||||
} = useAppTheme()
|
||||
|
||||
// Clear timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hide = useCallback(() => {
|
||||
// Clear any pending timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
// Fade out animation
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setVisible(false)
|
||||
})
|
||||
}, [fadeAnim])
|
||||
|
||||
const show = useCallback(
|
||||
(newConfig: ToastConfig) => {
|
||||
// Clear any existing timeout to prevent premature hide
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
|
||||
setConfig(newConfig)
|
||||
setVisible(true)
|
||||
|
||||
// Fade in animation
|
||||
fadeAnim.setValue(0)
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
|
||||
// Auto hide after duration (use type-specific default if not provided)
|
||||
const type = newConfig.type ?? "info"
|
||||
const duration = newConfig.duration ?? DEFAULT_DURATIONS[type]
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
hide()
|
||||
}, duration)
|
||||
},
|
||||
[hide, fadeAnim],
|
||||
)
|
||||
|
||||
const success = useCallback(
|
||||
(message: string, duration?: number) => {
|
||||
show({ message, type: "success", duration })
|
||||
},
|
||||
[show],
|
||||
)
|
||||
|
||||
const error = useCallback(
|
||||
(message: string, duration?: number) => {
|
||||
show({ message, type: "error", duration })
|
||||
},
|
||||
[show],
|
||||
)
|
||||
|
||||
const warning = useCallback(
|
||||
(message: string, duration?: number) => {
|
||||
show({ message, type: "warning", duration })
|
||||
},
|
||||
[show],
|
||||
)
|
||||
|
||||
const info = useCallback(
|
||||
(message: string, duration?: number) => {
|
||||
show({ message, type: "info", duration })
|
||||
},
|
||||
[show],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ show, success, error, warning, info, hide }),
|
||||
[show, success, error, warning, info, hide],
|
||||
)
|
||||
|
||||
const getBackgroundColor = (type: ToastType = "info") => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return colors.palette.primary500
|
||||
case "error":
|
||||
return colors.error
|
||||
case "warning":
|
||||
return colors.palette.accent500
|
||||
case "info":
|
||||
default:
|
||||
return colors.palette.secondary400
|
||||
}
|
||||
}
|
||||
|
||||
const getTextColor = (type: ToastType = "info") => {
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return colors.palette.neutral800
|
||||
default:
|
||||
return colors.palette.neutral100
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
presentationStyle="overFullScreen"
|
||||
statusBarTranslucent
|
||||
onRequestClose={hide}
|
||||
>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[$overlay, { paddingTop: insets.top + 20 }, { opacity: fadeAnim }]}
|
||||
>
|
||||
<View
|
||||
style={[themed($toastContainer), { backgroundColor: getBackgroundColor(config.type) }]}
|
||||
>
|
||||
<Text
|
||||
text={config.message}
|
||||
size="sm"
|
||||
style={[themed($toastText), { color: getTextColor(config.type) }]}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useToast = (): ToastContextType => {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
const $overlay: ViewStyle = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
}
|
||||
|
||||
const $toastContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
minWidth: s(120),
|
||||
maxWidth: "80%",
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: s(8),
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: s(2) },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: s(4),
|
||||
elevation: 5,
|
||||
})
|
||||
|
||||
const $toastText: ThemedStyle<TextStyle> = () => ({
|
||||
textAlign: "center",
|
||||
})
|
||||
Reference in New Issue
Block a user