template_0205

This commit is contained in:
Sofio
2026-02-05 13:16:05 +08:00
commit d93e4d9c9f
197 changed files with 52810 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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,
}
}

View 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",
})