diff --git a/.github/workflow/deploy.yml b/.github/workflow/deploy.yml new file mode 100644 index 0000000..e69de29 diff --git a/.txt b/.txt new file mode 100644 index 0000000..e69de29 diff --git a/app/app.tsx b/app/app.tsx index eae2fae..ec3bcfb 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -33,6 +33,9 @@ import { customFontsToLoad } from "./theme"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { loadDateFnsLocale } from "./utils/formatDate"; import "react-native-get-random-values"; +import { api } from "./services/api"; + + export const NAVIGATION_PERSISTENCE_KEY = "NAVIGATION_STATE"; @@ -72,7 +75,16 @@ export function App() { const [areFontsLoaded, fontLoadError] = useFonts(customFontsToLoad); const [isI18nInitialized, setIsI18nInitialized] = useState(false); + + const runTest = async () => { + const result = await api.getGlobalHealthcareNews(); + if (result.kind === "ok") { + console.log(result.articles); // Global health articles + } + }; + useEffect(() => { + initI18n() .then(() => setIsI18nInitialized(true)) .then(() => loadDateFnsLocale()); diff --git a/app/components/Icon.tsx b/app/components/Icon.tsx index 64eaaa3..13e5114 100644 --- a/app/components/Icon.tsx +++ b/app/components/Icon.tsx @@ -111,7 +111,7 @@ export function Icon(props: IconProps) { export const iconRegistry = { back: require("../../assets/icons/back.png"), bell: require("../../assets/icons/bell.png"), - calendar: require("../../assets/icons/calendar.png"), + calendar: require("../../assets/icons/schedule.png"), caretLeft: require("../../assets/icons/caretLeft.png"), caretRight: require("../../assets/icons/caretRight.png"), check: require("../../assets/icons/check.png"), @@ -134,6 +134,7 @@ export const iconRegistry = { x: require("../../assets/icons/x.png"), google: require("../../assets/icons/google.png"), spot: require("../../assets/icons/black-circle.png"), + news: require("../../assets/icons/newspaper.png"), } const $imageStyleBase: ImageStyle = { diff --git a/app/models/NewsArticle.test.ts b/app/models/NewsArticle.test.ts new file mode 100644 index 0000000..3db4a5d --- /dev/null +++ b/app/models/NewsArticle.test.ts @@ -0,0 +1,202 @@ +import { NewsArticleModel } from "./NewsArticle" + +describe("NewsArticle", () => { + const createTestArticle = (overrides = {}) => { + return NewsArticleModel.create({ + article_id: "test-1", + title: "Test Health Article", + link: "https://example.com/article", + pubDate: "2024-01-15T10:30:00Z", + source_id: "test-source", + source_name: "Healthcare Today", + source_url: "https://healthcaretoday.com", + keywords: ["health", "medicine"], + creator: ["Dr. Jane Smith"], + description: "This is a test article about healthcare developments and medical research findings.", + content: "Full article content with detailed information about healthcare trends and medical breakthroughs.", + country: ["US", "GH"], + category: ["health", "medicine"], + source_priority: 1500, + sentiment: "positive", + image_url: "https://example.com/image.jpg", + ...overrides, + }) + } + + test("can be created with minimal props", () => { + const instance = NewsArticleModel.create({ + article_id: "1", + title: "Test Article", + link: "https://example.com/test", + pubDate: new Date().toISOString(), + source_id: "source1", + source_name: "Test Source", + source_url: "https://example.com/source", + }) + + expect(instance).toBeTruthy() + expect(instance.keywords).toEqual([]) + expect(instance.description).toBe("") + expect(instance.language).toBe("en") + }) + + describe("date views", () => { + test("formats dates correctly", () => { + const article = createTestArticle({ pubDate: "2024-01-15T10:30:00Z" }) + + expect(article.formattedDate).toMatch(/2024/) + expect(article.formattedDateTime).toContain("2024") + }) + + test("handles invalid dates", () => { + const article = createTestArticle({ pubDate: "invalid-date" }) + expect(article.formattedDate).toBe("invalid-date") + expect(article.timeAgo).toBe("") + }) + + test("calculates time ago", () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) + const article = createTestArticle({ pubDate: oneHourAgo.toISOString() }) + expect(article.timeAgo).toBe("1h ago") + }) + }) + + describe("multimedia views", () => { + test("detects multimedia presence", () => { + const withImage = createTestArticle({ image_url: "test.jpg", video_url: null }) + const withVideo = createTestArticle({ image_url: null, video_url: "test.mp4" }) + const withNeither = createTestArticle({ image_url: null, video_url: null }) + + expect(withImage.hasImage).toBe(true) + expect(withImage.hasMultimedia).toBe(true) + expect(withVideo.hasVideo).toBe(true) + expect(withNeither.hasMultimedia).toBe(false) + }) + }) + + describe("content views", () => { + test("handles categories and countries", () => { + const article = createTestArticle({ category: ["health", "medicine"], country: ["US", "CA"] }) + const empty = createTestArticle({ category: [], country: [] }) + + expect(article.primaryCategory).toBe("health") + expect(article.allCategories).toBe("health, medicine") + expect(article.primaryCountry).toBe("US") + expect(empty.primaryCategory).toBe("General") + expect(empty.allCountries).toBe("Global") + }) + + test("handles authors", () => { + const withAuthors = createTestArticle({ creator: ["John Doe", "Jane Smith"] }) + const withoutAuthors = createTestArticle({ creator: [] }) + + expect(withAuthors.authorName).toBe("John Doe, Jane Smith") + expect(withAuthors.firstAuthor).toBe("John Doe") + expect(withoutAuthors.authorName).toBe("Unknown") + }) + + test("truncates descriptions", () => { + const longDesc = "A".repeat(150) + const article = createTestArticle({ description: longDesc }) + + expect(article.shortDescription).toBe("A".repeat(100) + "...") + expect(article.mediumDescription).toBe("A".repeat(150)) + }) + }) + + describe("health detection", () => { + test("detects health-related content", () => { + const healthArticle = createTestArticle({ + title: "Medical Breakthrough", + description: "", + keywords: [] + }) + const nonHealthArticle = createTestArticle({ + title: "Tech Update", + description: "Software news", + keywords: ["tech"] + }) + + expect(healthArticle.isHealthRelated).toBe(true) + expect(nonHealthArticle.isHealthRelated).toBe(false) + }) + }) + + describe("priority and sentiment", () => { + test("categorizes source priority", () => { + const highPriority = createTestArticle({ source_priority: 500 }) + const lowPriority = createTestArticle({ source_priority: 8000 }) + + expect(highPriority.sourcePriorityLabel).toBe("High Priority") + expect(highPriority.sourcePriorityBadgeColor).toBe("green") + expect(lowPriority.sourcePriorityLabel).toBe("Low Priority") + expect(lowPriority.sourcePriorityBadgeColor).toBe("gray") + }) + + test("handles sentiment", () => { + const positive = createTestArticle({ sentiment: "positive" }) + const unknown = createTestArticle({ sentiment: "" }) + + expect(positive.sentimentLabel).toBe("Positive") + expect(positive.sentimentColor).toBe("green") + expect(unknown.sentimentLabel).toBe("Unknown") + expect(unknown.sentimentColor).toBe("gray") + }) + }) + + describe("Ghana detection", () => { + test("identifies Ghana news", () => { + const ghanaCode = createTestArticle({ country: ["GH"] }) + const ghanaName = createTestArticle({ country: ["Ghana"] }) + const nonGhana = createTestArticle({ country: ["US"] }) + + expect(ghanaCode.isGhanaNews).toBe(true) + expect(ghanaName.isGhanaNews).toBe(true) + expect(nonGhana.isGhanaNews).toBe(false) + }) + }) + + describe("utility views", () => { + test("provides utility properties", () => { + const article = createTestArticle({ + content: "Test content", + title: "Test Title", + keywords: ["health", "test"] + }) + const emptyTitle = createTestArticle({ title: "" }) + + expect(article.hasContent).toBe(true) + expect(article.displayTitle).toBe("Test Title") + expect(article.shareUrl).toBe("https://example.com/article") + expect(article.keywordsList).toBe("health, test") + expect(article.readingTimeEstimate).toMatch(/min read/) + expect(emptyTitle.displayTitle).toBe("Untitled Article") + }) + }) + + describe("actions", () => { + test("updates from API data", () => { + const article = createTestArticle({ title: "Original" }) + + article.updateFromApi({ title: "Updated", sentiment: "negative" }) + + expect(article.title).toBe("Updated") + expect(article.sentiment).toBe("negative") + }) + + test("updates sentiment and AI summary", () => { + const article = createTestArticle() + + article.updateSentiment("neutral") + article.updateAiSummary("AI summary") + + expect(article.sentiment).toBe("neutral") + expect(article.ai_summary).toBe("AI summary") + }) + + test("markAsRead executes without error", () => { + const article = createTestArticle() + expect(() => article.markAsRead()).not.toThrow() + }) + }) +}) \ No newline at end of file diff --git a/app/models/NewsArticle.ts b/app/models/NewsArticle.ts new file mode 100644 index 0000000..fb41c01 --- /dev/null +++ b/app/models/NewsArticle.ts @@ -0,0 +1,242 @@ +import { Instance, SnapshotIn, SnapshotOut, types } from "mobx-state-tree" +import { withSetPropAction } from "./helpers/withSetPropAction" + +/** + * NewsArticle model for healthcare news articles with proper null handling + */ +export const NewsArticleModel = types + .model("NewsArticle") + .props({ + article_id: types.identifier, + title: types.optional(types.string, ""), + link: types.optional(types.string, ""), + keywords: types.maybeNull(types.array(types.string)), + creator: types.maybeNull(types.array(types.string)), + description: types.maybeNull(types.string), // Allow null from API + content: types.maybeNull(types.string), // Allow null from API + pubDate: types.string, + pubDateTZ: types.optional(types.string, ""), + image_url: types.maybeNull(types.string), + video_url: types.maybeNull(types.string), + source_id: types.string, + source_name: types.string, + source_priority: types.optional(types.number, 0), + source_url: types.string, + source_icon: types.optional(types.string, ""), + language: types.optional(types.string, "en"), + country: types.maybeNull(types.array(types.string)), + category: types.maybeNull(types.array(types.string)), + ai_tag: types.optional(types.string, ""), + sentiment: types.optional(types.string, ""), + ai_summary: types.optional(types.string, ""), + }) + .actions(withSetPropAction) + .views((article) => ({ + get formattedDate() { + try { + return new Date(article.pubDate).toLocaleDateString() + } catch { + return article.pubDate + } + }, + + get formattedDateTime() { + try { + return new Date(article.pubDate).toLocaleString() + } catch { + return article.pubDate + } + }, + + get timeAgo() { + try { + const now = new Date() + const pubDate = new Date(article.pubDate) + const diffInMs = now.getTime() - pubDate.getTime() + const diffInMinutes = Math.floor(diffInMs / (1000 * 60)) + const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)) + const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)) + + if (diffInMinutes < 1) return "Just now" + if (diffInMinutes < 60) return `${diffInMinutes}m ago` + if (diffInHours < 24) return `${diffInHours}h ago` + if (diffInDays < 30) return `${diffInDays}d ago` + + const diffInMonths = Math.floor(diffInDays / 30) + if (diffInMonths < 12) return `${diffInMonths}mo ago` + + const diffInYears = Math.floor(diffInMonths / 12) + return `${diffInYears}y ago` + } catch { + return "" + } + }, + + get hasImage() { + return !!article.image_url + }, + + get hasVideo() { + return !!article.video_url + }, + + get hasMultimedia() { + return !!article.image_url || !!article.video_url + }, + + get primaryCategory() { + const categories = article.category || [] + return categories.length > 0 ? categories[0] : "General" + }, + + get allCategories() { + const categories = article.category || [] + return categories.join(", ") || "General" + }, + + get primaryCountry() { + const countries = article.country || [] + return countries.length > 0 ? countries[0] : "Global" + }, + + get allCountries() { + const countries = article.country || [] + return countries.join(", ") || "Global" + }, + + get authorName() { + const creators = article.creator || [] + return creators.length > 0 ? creators.join(", ") : "Unknown" + }, + + get firstAuthor() { + const creators = article.creator || [] + return creators.length > 0 ? creators[0] : "Unknown" + }, + + get shortDescription() { + if (!article.description) return "" + if (article.description.length <= 100) return article.description + return article.description.substring(0, 100) + "..." + }, + + get mediumDescription() { + if (!article.description) return "" + if (article.description.length <= 200) return article.description + return article.description.substring(0, 200) + "..." + }, + + get keywordsList() { + const keywords = article.keywords || [] + return keywords.join(", ") + }, + + get isHealthRelated() { + const healthKeywords = [ + "health", "medical", "healthcare", "medicine", "hospital", "doctor", + "disease", "treatment", "patient", "clinic", "therapy", "diagnosis", + "pharmaceutical", "vaccine", "epidemic", "pandemic", "wellness" + ] + const searchText = `${article.title || ""} ${article.description || ""} ${(article.keywords || []).join(" ")}`.toLowerCase() + return healthKeywords.some(keyword => searchText.includes(keyword)) + }, + + get isRecent() { + try { + const now = new Date() + const pubDate = new Date(article.pubDate) + const diffInHours = (now.getTime() - pubDate.getTime()) / (1000 * 60 * 60) + return diffInHours <= 24 // Within last 24 hours + } catch { + return false + } + }, + + get isToday() { + try { + const now = new Date() + const pubDate = new Date(article.pubDate) + return now.toDateString() === pubDate.toDateString() + } catch { + return false + } + }, + + get sourcePriorityLabel() { + if (article.source_priority <= 1000) return "High Priority" + if (article.source_priority <= 5000) return "Medium Priority" + return "Low Priority" + }, + + get sourcePriorityBadgeColor() { + if (article.source_priority <= 1000) return "green" + if (article.source_priority <= 5000) return "yellow" + return "gray" + }, + + get sentimentLabel() { + switch (article.sentiment?.toLowerCase()) { + case "positive": return "Positive" + case "negative": return "Negative" + case "neutral": return "Neutral" + default: return "Unknown" + } + }, + + get sentimentColor() { + switch (article.sentiment?.toLowerCase()) { + case "positive": return "green" + case "negative": return "red" + case "neutral": return "blue" + default: return "gray" + } + }, + + get isGhanaNews() { + const countries = article.country || [] + return countries.some(country => country === "GH" || country === "Ghana") + }, + + get shareUrl() { + return article.link + }, + + get displayTitle() { + return article.title || "Untitled Article" + }, + + get hasContent() { + return !!(article.content && article.content.trim().length > 0) + }, + + get contentPreview() { + if (!article.content) return article.description || "" + if (article.content.length <= 150) return article.content + return article.content.substring(0, 150) + "..." + }, + + get readingTimeEstimate() { + const wordsPerMinute = 200 + const contentLength = (article.content || article.description || "").length + const wordCount = contentLength / 5 // Rough estimate: 5 characters per word + const minutes = Math.ceil(wordCount / wordsPerMinute) + return minutes > 0 ? `${minutes} min read` : "< 1 min read" + }, + })) + .actions((article) => ({ + markAsRead() { + // Could be used to track read status + }, + + updateSentiment(sentiment: string) { + article.setProp("sentiment", sentiment) + }, + + updateAiSummary(summary: string) { + article.setProp("ai_summary", summary) + }, + })) + +export interface NewsArticle extends Instance {} +export interface NewsArticleSnapshotOut extends SnapshotOut {} +export interface NewsArticleSnapshotIn extends SnapshotIn {} \ No newline at end of file diff --git a/app/models/NewsStore.test.ts b/app/models/NewsStore.test.ts new file mode 100644 index 0000000..6550369 --- /dev/null +++ b/app/models/NewsStore.test.ts @@ -0,0 +1,115 @@ +import { NewsStoreModel } from "./NewsStore" + +describe("NewsStore", () => { + test("can be created", () => { + const instance = NewsStoreModel.create({}) + expect(instance).toBeTruthy() + }) + + test("can be created with default values", () => { + const instance = NewsStoreModel.create({}) + + expect(instance.articles).toEqual([]) + expect(instance.favorites).toEqual([]) + expect(instance.favoritesOnly).toBe(false) + expect(instance.loading).toBe(false) + expect(instance.currentCountry).toBe("") + expect(instance.currentQuery).toBe("") + expect(instance.totalResults).toBe(0) + expect(instance.nextPage).toBe(null) + }) + + test("can toggle favoritesOnly", () => { + const instance = NewsStoreModel.create({}) + + expect(instance.favoritesOnly).toBe(false) + + instance.toggleFavoritesOnly() + expect(instance.favoritesOnly).toBe(true) + + instance.toggleFavoritesOnly() + expect(instance.favoritesOnly).toBe(false) + }) + + test("can clear articles", () => { + const instance = NewsStoreModel.create({ + articles: [], + totalResults: 10, + nextPage: "page2", + currentQuery: "test query", + currentCountry: "US" + }) + + instance.clearArticles() + + expect(instance.articles).toEqual([]) + expect(instance.totalResults).toBe(0) + expect(instance.nextPage).toBe(null) + expect(instance.currentQuery).toBe("") + expect(instance.currentCountry).toBe("") + }) + + test("view properties work correctly", () => { + const instance = NewsStoreModel.create({}) + + expect(instance.articlesForList).toEqual([]) + expect(instance.hasMoreArticles).toBe(false) + expect(instance.isSearchMode).toBe(false) + expect(instance.isCountryMode).toBe(false) + expect(instance.currentModeLabel).toBe("Global Healthcare News") + expect(instance.favoriteCount).toBe(0) + }) + + test("currentModeLabel updates based on state", () => { + const instance = NewsStoreModel.create({}) + + // Default state + expect(instance.currentModeLabel).toBe("Global Healthcare News") + + // Country mode + instance.setProp("currentCountry", "US") + expect(instance.currentModeLabel).toBe("US Healthcare News") + + // Search mode + instance.setProp("currentQuery", "covid") + expect(instance.currentModeLabel).toBe('Search: "covid" in US') + + // Search without country + instance.setProp("currentCountry", "") + expect(instance.currentModeLabel).toBe('Search: "covid"') + }) + + test("mode detection works correctly", () => { + const instance = NewsStoreModel.create({}) + + // Default state + expect(instance.isSearchMode).toBe(false) + expect(instance.isCountryMode).toBe(false) + + // Country mode + instance.setProp("currentCountry", "US") + expect(instance.isSearchMode).toBe(false) + expect(instance.isCountryMode).toBe(true) + + // Search mode (overrides country mode) + instance.setProp("currentQuery", "covid") + expect(instance.isSearchMode).toBe(true) + expect(instance.isCountryMode).toBe(false) + }) + + test("hasMoreArticles works correctly", () => { + const instance = NewsStoreModel.create({}) + + // No next page + expect(instance.hasMoreArticles).toBe(false) + + // Has next page but loading + instance.setProp("nextPage", "page2") + instance.setProp("loading", true) + expect(instance.hasMoreArticles).toBe(false) + + // Has next page and not loading + instance.setProp("loading", false) + expect(instance.hasMoreArticles).toBe(true) + }) +}) \ No newline at end of file diff --git a/app/models/NewsStore.ts b/app/models/NewsStore.ts new file mode 100644 index 0000000..8594e0f --- /dev/null +++ b/app/models/NewsStore.ts @@ -0,0 +1,288 @@ +import { Instance, SnapshotIn, SnapshotOut, types } from "mobx-state-tree" +import { api } from "../services/api" +import { NewsArticle, NewsArticleModel } from "./NewsArticle" +import { withSetPropAction } from "./helpers/withSetPropAction" + +// TypeScript interfaces for better type safety and documentation +interface NewsStoreProps { + articles: NewsArticle[] + favorites: NewsArticle[] + favoritesOnly: boolean + loading: boolean + currentCountry: string + currentQuery: string + totalResults: number + nextPage: string | null +} + +interface NewsStoreActions { + fetchGlobalHealthcareNews: (page?: string) => Promise + fetchCountryHealthcareNews: (country: string, page?: string) => Promise + searchHealthcareNews: (query: string, country?: string, page?: string) => Promise + loadMoreArticles: () => Promise + addFavorite: (article: NewsArticle) => void + removeFavorite: (article: NewsArticle) => void + clearArticles: () => void + toggleFavorite: (article: NewsArticle) => void + toggleFavoritesOnly: () => void +} + +interface NewsStoreViews { + articlesForList: NewsArticle[] + hasMoreArticles: boolean + isSearchMode: boolean + isCountryMode: boolean + currentModeLabel: string + hasFavorite: (article: NewsArticle) => boolean + favoriteCount: number + getArticlesByCategory: (category: string) => NewsArticle[] + getRecentArticles: (days?: number) => NewsArticle[] +} + +export const NewsStoreModel = types + .model("NewsStore") + .props({ + articles: types.array(NewsArticleModel), + // Store favorites as plain objects instead of references to avoid reference resolution issues + favoriteArticles: types.array(NewsArticleModel), + favoritesOnly: false, + loading: false, + currentCountry: types.optional(types.string, ""), + currentQuery: types.optional(types.string, ""), + totalResults: 0, + nextPage: types.maybeNull(types.string), + }) + .actions(withSetPropAction) + .actions((store) => { + // Core API methods + const fetchGlobalHealthcareNews = async (page?: string): Promise => { + store.setProp("loading", true) + try { + const response = await api.getGlobalHealthcareNews(page) + if (response.kind === "ok") { + // Ensure articles have proper IDs and data before adding to store + const validArticles = response.articles.filter(article => + article && article.article_id && typeof article.article_id === 'string' + ) + + if (page && store.nextPage) { + // For pagination, append to existing articles + store.setProp("articles", [...store.articles, ...validArticles]) + } else { + // For new search, replace articles + store.setProp("articles", validArticles) + } + store.setProp("totalResults", response.totalResults) + store.setProp("nextPage", response.nextPage || null) + store.setProp("currentCountry", "") + store.setProp("currentQuery", "") + } else { + console.error(`Error fetching global healthcare news: ${JSON.stringify(response)}`) + } + } catch (error) { + console.error("Error in fetchGlobalHealthcareNews:", error) + } finally { + store.setProp("loading", false) + } + } + + const fetchCountryHealthcareNews = async (country: string, page?: string): Promise => { + store.setProp("loading", true) + try { + const response = await api.getCountryHealthcareNews(country, page) + if (response.kind === "ok") { + // Ensure articles have proper IDs and data before adding to store + const validArticles = response.articles.filter(article => + article && article.article_id && typeof article.article_id === 'string' + ) + + if (page && store.nextPage) { + store.setProp("articles", [...store.articles, ...validArticles]) + } else { + store.setProp("articles", validArticles) + } + store.setProp("totalResults", response.totalResults) + store.setProp("nextPage", response.nextPage || null) + store.setProp("currentCountry", country) + store.setProp("currentQuery", "") + } else { + console.error(`Error fetching ${country} healthcare news: ${JSON.stringify(response)}`) + } + } catch (error) { + console.error("Error in fetchCountryHealthcareNews:", error) + } finally { + store.setProp("loading", false) + } + } + + const searchHealthcareNews = async (query: string, country?: string, page?: string): Promise => { + store.setProp("loading", true) + try { + const response = await api.searchHealthcareNews(query, country, page) + if (response.kind === "ok") { + // Ensure articles have proper IDs and data before adding to store + const validArticles = response.articles.filter(article => + article && article.article_id && typeof article.article_id === 'string' + ) + + if (page && store.nextPage) { + store.setProp("articles", [...store.articles, ...validArticles]) + } else { + store.setProp("articles", validArticles) + } + store.setProp("totalResults", response.totalResults) + store.setProp("nextPage", response.nextPage || null) + store.setProp("currentQuery", query) + store.setProp("currentCountry", country || "") + } else { + console.error(`Error searching healthcare news: ${JSON.stringify(response)}`) + } + } catch (error) { + console.error("Error in searchHealthcareNews:", error) + } finally { + store.setProp("loading", false) + } + } + + const loadMoreArticles = async (): Promise => { + if (!store.nextPage || store.loading) return + + if (store.currentQuery) { + await searchHealthcareNews( + store.currentQuery, + store.currentCountry || undefined, + store.nextPage + ) + } else if (store.currentCountry) { + await fetchCountryHealthcareNews(store.currentCountry, store.nextPage) + } else { + await fetchGlobalHealthcareNews(store.nextPage) + } + } + + const addFavorite = (article: NewsArticle): void => { + if (!store.favoriteArticles.some(fav => fav.article_id === article.article_id)) { + store.favoriteArticles.push(article) + } + } + + const removeFavorite = (article: NewsArticle): void => { + const index = store.favoriteArticles.findIndex(fav => fav.article_id === article.article_id) + if (index !== -1) { + store.favoriteArticles.splice(index, 1) + } + } + + const clearArticles = (): void => { + store.setProp("articles", []) + store.setProp("totalResults", 0) + store.setProp("nextPage", null) + store.setProp("currentQuery", "") + store.setProp("currentCountry", "") + } + + const cleanupInvalidFavorites = (): void => { + // Remove any favorites that might have invalid references + const validFavorites = store.favoriteArticles.filter(fav => + fav && fav.article_id && typeof fav.article_id === 'string' + ) + if (validFavorites.length !== store.favoriteArticles.length) { + store.setProp("favoriteArticles", validFavorites) + } + } + + const toggleFavoritesOnly = (): void => { + store.setProp("favoritesOnly", !store.favoritesOnly) + } + + return { + fetchGlobalHealthcareNews, + fetchCountryHealthcareNews, + searchHealthcareNews, + loadMoreArticles, + addFavorite, + removeFavorite, + clearArticles, + cleanupInvalidFavorites, + toggleFavoritesOnly, + } + }) + .views((store) => ({ + get articlesForList(): NewsArticle[] { + return store.favoritesOnly ? store.favoriteArticles.slice() : store.articles + }, + + get hasMoreArticles(): boolean { + return !!store.nextPage && !store.loading + }, + + get isSearchMode(): boolean { + return !!store.currentQuery + }, + + get isCountryMode(): boolean { + return !!store.currentCountry && !store.currentQuery + }, + + get currentModeLabel(): string { + if (store.currentQuery) { + return `Search: "${store.currentQuery}"${store.currentCountry ? ` in ${store.currentCountry}` : ""}` + } + if (store.currentCountry) { + return `${store.currentCountry} Healthcare News` + } + return "Global Healthcare News" + }, + + hasFavorite(article: NewsArticle): boolean { + return store.favoriteArticles.some(fav => fav.article_id === article.article_id) + }, + + get favoriteCount(): number { + return store.favoriteArticles.length + }, + + get favorites(): NewsArticle[] { + return store.favoriteArticles.slice() + }, + + getArticlesByCategory(category: string): NewsArticle[] { + return store.articles.filter(article => + article.category?.some(cat => cat.toLowerCase().includes(category.toLowerCase())) + ) + }, + + getRecentArticles(days: number = 7): NewsArticle[] { + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - days) + + return store.articles.filter(article => { + try { + const pubDate = new Date(article.pubDate) + return pubDate >= cutoffDate + } catch { + return false + } + }) + }, + })) + .actions((store) => ({ + // Actions that depend on views - must come after views are defined + // Clean up method that can be called from UI components + toggleFavorite(article: NewsArticle): void { + // Clean up any invalid favorites first + store.cleanupInvalidFavorites() + + if (store.hasFavorite(article)) { + store.removeFavorite(article) + } else { + store.addFavorite(article) + } + }, + })) + +export interface NewsStore extends Instance {} +export interface NewsStoreSnapshotOut extends SnapshotOut {} +export interface NewsStoreSnapshotIn extends SnapshotIn {} + +export const createNewsStoreDefaultModel = () => types.optional(NewsStoreModel, {}) \ No newline at end of file diff --git a/app/models/RootStore.ts b/app/models/RootStore.ts index 9e62886..ec9c046 100644 --- a/app/models/RootStore.ts +++ b/app/models/RootStore.ts @@ -1,4 +1,5 @@ import { Instance, SnapshotOut, types, flow } from "mobx-state-tree" +import { NewsStoreModel } from "./NewsStore" import { AuthenticationStoreModel } from "./AuthenticationStore" import { EpisodeStoreModel } from "./EpisodeStore" import { TaskStoreModel } from "./TaskStore" @@ -8,6 +9,7 @@ import Parse from "@/lib/Parse/parse" * A RootStore model. */ export const RootStoreModel = types.model("RootStore").props({ + newsStore: types.optional(NewsStoreModel, {} as any), authenticationStore: types.optional(AuthenticationStoreModel, {}), episodeStore: types.optional(EpisodeStoreModel, {}), taskStore: types.optional(TaskStoreModel, {}), diff --git a/app/models/index.ts b/app/models/index.ts index 24e3346..1822681 100644 --- a/app/models/index.ts +++ b/app/models/index.ts @@ -3,3 +3,6 @@ export * from "./helpers/getRootStore" export * from "./helpers/useStores" export * from "./helpers/setupRootStore" export * from "./Firebase" +export * from "./NewsStore" +export * from "./NewsStore" +export * from "./NewsArticle" diff --git a/app/navigators/DemoNavigator.tsx b/app/navigators/DemoNavigator.tsx index 2add0ad..b3c24e2 100644 --- a/app/navigators/DemoNavigator.tsx +++ b/app/navigators/DemoNavigator.tsx @@ -72,7 +72,7 @@ export function DemoNavigator() { options={{ tabBarLabel: translate("demoNavigator:communityTab"), tabBarIcon: ({ focused }) => ( - + ), }} /> diff --git a/app/screens/DemoCommunityScreen.tsx b/app/screens/DemoCommunityScreen.tsx index 695796f..bf52c67 100644 --- a/app/screens/DemoCommunityScreen.tsx +++ b/app/screens/DemoCommunityScreen.tsx @@ -1,138 +1,510 @@ -import { FC } from "react" -import { Image, ImageStyle, TextStyle, View, ViewStyle } from "react-native" -import { ListItem, Screen, Text } from "../components" -import { DemoTabScreenProps } from "../navigators/DemoNavigator" -import { $styles } from "../theme" -import { openLinkInBrowser } from "../utils/openLinkInBrowser" +import { ComponentType, FC, useCallback, useEffect, useMemo, useState } from "react" +import { + AccessibilityProps, + ActivityIndicator, + Image, + ImageStyle, + Platform, + StyleSheet, + TextStyle, + View, + ViewStyle, +} from "react-native" +import { type ContentStyle } from "@shopify/flash-list" +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, + useSharedValue, + withSpring, +} from "react-native-reanimated" + +import { Button, type ButtonAccessoryProps } from "@/components/Button" +import { Card } from "@/components/Card" +import { EmptyState } from "@/components/EmptyState" +import { Icon } from "@/components/Icon" +import { ListView } from "@/components/ListView" +import { Screen } from "@/components/Screen" +import { Text } from "@/components/Text" +import { Switch } from "@/components/Toggle/Switch" import { isRTL } from "@/i18n" -import type { ThemedStyle } from "@/theme" +import { DemoTabScreenProps } from "@/navigators/DemoNavigator" +import type { NewsArticle } from "@/models/NewsArticle" +import type { ThemedStyle, Theme } from "@/theme" import { useAppTheme } from "@/utils/useAppTheme" +import { $styles } from "@/theme" +import { delay } from "@/utils/delay" +import { openLinkInBrowser } from "@/utils/openLinkInBrowser" +import { useStores } from "@/models" +const ICON_SIZE = 14 +// Custom hooks for better separation of concerns +const useNewsData = () => { + const { newsStore } = useStores() + const [refreshing, setRefreshing] = useState(false) -const chainReactLogo = require("../../assets/images/demo/cr-logo.png") -const reactNativeLiveLogo = require("../../assets/images/demo/rnl-logo.png") -const reactNativeRadioLogo = require("../../assets/images/demo/rnr-logo.png") -const reactNativeNewsletterLogo = require("../../assets/images/demo/rnn-logo.png") + const loadInitialData = useCallback(async () => { + await newsStore.fetchGlobalHealthcareNews() + }, [newsStore]) -export const DemoCommunityScreen: FC> = - function DemoCommunityScreen(_props) { - const { themed } = useAppTheme() - return ( - - - + const manualRefresh = useCallback(async () => { + setRefreshing(true) + try { + await Promise.allSettled([ + newsStore.currentQuery + ? newsStore.searchHealthcareNews(newsStore.currentQuery, newsStore.currentCountry || undefined) + : newsStore.currentCountry + ? newsStore.fetchCountryHealthcareNews(newsStore.currentCountry) + : newsStore.fetchGlobalHealthcareNews(), + delay(750) + ]) + } finally { + setRefreshing(false) + } + }, [newsStore]) - - - openLinkInBrowser("https://community.infinite.red/")} - /> - - - openLinkInBrowser("https://github.com/infinitered/ignite")} - /> + const loadMore = useCallback(async () => { + if (!newsStore.hasMoreArticles) return - - - - - - } - onPress={() => openLinkInBrowser("https://reactnativeradio.com/")} - /> - - - - } - onPress={() => openLinkInBrowser("https://reactnativenewsletter.com/")} - /> - - - - } - onPress={() => openLinkInBrowser("https://rn.live/")} - /> - - - - } - onPress={() => openLinkInBrowser("https://cr.infinite.red/")} - /> - - - openLinkInBrowser("https://infinite.red/contact")} + try { + await newsStore.loadMoreArticles() + } catch (error) { + console.error('Error loading more articles:', error) + } + }, [newsStore]) + + return { + newsStore, + refreshing, + loadInitialData, + manualRefresh, + loadMore, + } +} + +// Separated header component for better organization +const NewsListHeader: FC<{ + currentModeLabel: string + favoritesOnly: boolean + favoriteCount: number + totalResults: number + onToggleFavorites: () => void + hasArticles: boolean +}> = ({ + currentModeLabel, + favoritesOnly, + favoriteCount, + totalResults, + onToggleFavorites, + hasArticles +}) => { + const { themed } = useAppTheme() + + return ( + + + + + {(favoritesOnly || hasArticles) && ( + + + + )} + + {totalResults > 0 && ( + - - ) + )} + + ) +} + +// Separated footer component +const NewsListFooter: FC<{ loading: boolean; hasArticles: boolean }> = ({ + loading, + hasArticles +}) => { + const { themed } = useAppTheme() + + if (!loading || !hasArticles) return null + + return ( + + + + + ) +} + +// Separated empty state component +const NewsEmptyState: FC<{ + loading: boolean + favoritesOnly: boolean + onRefresh: () => void +}> = ({ loading, favoritesOnly, onRefresh }) => { + const { themed } = useAppTheme() + + if (loading) { + return } -const $title: ThemedStyle = ({ spacing }) => ({ + return ( + + ) +} + +// Main component - now much cleaner +export const DemoCommunityScreen: FC> = () => { + const { themed } = useAppTheme() + const { + newsStore, + refreshing, + loadInitialData, + manualRefresh, + loadMore + } = useNewsData() + + const { + articlesForList, + loading, + favoritesOnly, + currentModeLabel, + totalResults, + favoriteCount, + } = newsStore + + // Load initial data + useEffect(() => { + loadInitialData() + }, [loadInitialData]) + + const handleToggleFavorites = useCallback(() => { + newsStore.toggleFavoritesOnly() + }, [newsStore]) + + const handleToggleFavorite = useCallback((article: NewsArticle) => { + newsStore.toggleFavorite(article) + }, [newsStore]) + + const renderItem = useCallback(({ item }: { item: NewsArticle }) => ( + handleToggleFavorite(item)} + isFavorite={newsStore.hasFavorite(item)} + /> + ), [handleToggleFavorite, newsStore]) + + return ( + + + contentContainerStyle={themed([$styles.container, $listContentContainer])} + data={articlesForList} + extraData={articlesForList.length} + refreshing={refreshing} + estimatedItemSize={200} + onRefresh={manualRefresh} + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListEmptyComponent={ + + } + ListHeaderComponent={ + 0} + /> + } + ListFooterComponent={ + 0} + /> + } + renderItem={renderItem} + /> + + ) +} + +// Animated heart button component +const AnimatedHeartButton: FC<{ + isFavorite: boolean + onPress: () => void +}> = ({ isFavorite, onPress }) => { + const { theme: { colors }, themed } = useAppTheme() + const liked = useSharedValue(isFavorite ? 1 : 0) + + useEffect(() => { + liked.value = withSpring(isFavorite ? 1 : 0) + }, [isFavorite, liked]) + + const animatedLikeButtonStyles = useAnimatedStyle(() => ({ + transform: [{ scale: interpolate(liked.value, [0, 1], [1, 0], Extrapolation.EXTEND) }], + opacity: interpolate(liked.value, [0, 1], [1, 0], Extrapolation.CLAMP), + })) + + const animatedUnlikeButtonStyles = useAnimatedStyle(() => ({ + transform: [{ scale: liked.value }], + opacity: liked.value, + })) + + const handlePress = useCallback(() => { + onPress() + liked.value = withSpring(liked.value ? 0 : 1) + }, [liked, onPress]) + + const ButtonLeftAccessory: ComponentType = useMemo( + () => function ButtonLeftAccessory() { + return ( + + + + + + + + + ) + }, + [animatedLikeButtonStyles, animatedUnlikeButtonStyles, colors, themed], + ) + + return ( + + ) +} + +// News article card component - simplified +const NewsArticleCard: FC<{ + article: NewsArticle + onPressFavorite: () => void + isFavorite: boolean +}> = ({ article, onPressFavorite, isFavorite }) => { + const { themed } = useAppTheme() + + const imageSource = useMemo(() => + article.image_url ? { uri: article.image_url } : undefined + , [article.image_url]) + + const accessibilityProps = useMemo( + (): AccessibilityProps => ({ + accessibilityLabel: article.title, + accessibilityActions: [{ name: "longpress", label: "Toggle favorite" }], + onAccessibilityAction: ({ nativeEvent }) => { + if (nativeEvent.actionName === "longpress") { + onPressFavorite() + } + }, + accessibilityRole: "button", + accessibilityHint: "Tap to view article, long press to toggle favorite", + }), + [article.title, onPressFavorite, isFavorite], + ) + + const handlePressCard = useCallback(() => { + openLinkInBrowser(article.link) + }, [article.link]) + + return ( + + + + {article.primaryCountry && ( + + )} + + } + content={article.title} + RightComponent={ + imageSource ? ( + + ) : undefined + } + FooterComponent={ + + {article.shortDescription && ( + + )} + + + } + /> + ) +} + +// Styles remain the same +const $listContentContainer: ThemedStyle = ({ spacing }) => ({ + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg + spacing.xl, + paddingBottom: spacing.lg, +}) + +const $heading: ThemedStyle = ({ spacing }) => ({ + marginBottom: spacing.md, +}) + +const $subtitle: ThemedStyle = ({ colors, spacing }) => ({ + color: colors.textDim, + marginTop: spacing.xs, +}) + +const $resultsCount: ThemedStyle = ({ colors, spacing }) => ({ + color: colors.textDim, + marginTop: spacing.sm, +}) + +const $item: ThemedStyle = ({ colors, spacing }) => ({ + padding: spacing.md, + marginTop: spacing.md, + minHeight: 140, + backgroundColor: colors.palette.neutral100, +}) + +const $itemThumbnail: ThemedStyle = ({ spacing }) => ({ + marginTop: spacing.sm, + borderRadius: 8, + alignSelf: "flex-start", + width: 80, + height: 80, +}) + +const $description: ThemedStyle = ({ colors, spacing }) => ({ + color: colors.textDim, + marginTop: spacing.xs, marginBottom: spacing.sm, }) -const $tagline: ThemedStyle = ({ spacing }) => ({ - marginBottom: spacing.xxl, +const $toggle: ThemedStyle = ({ spacing }) => ({ + marginTop: spacing.md, }) -const $description: ThemedStyle = ({ spacing }) => ({ - marginBottom: spacing.lg, +const $labelStyle: TextStyle = { + textAlign: "left", +} + +const $iconContainer: ThemedStyle = ({ spacing }) => ({ + height: ICON_SIZE, + width: ICON_SIZE, + marginEnd: spacing.sm, }) -const $sectionTitle: ThemedStyle = ({ spacing }) => ({ - marginTop: spacing.xxl, +const $metadata: ThemedStyle = ({ spacing }) => ({ + marginTop: spacing.xs, }) -const $logoContainer: ThemedStyle = ({ spacing }) => ({ +const $metadataText: ThemedStyle = ({ colors, spacing }) => ({ + color: colors.textDim, marginEnd: spacing.md, - flexWrap: "wrap", - alignContent: "center", - alignSelf: "stretch", + marginBottom: spacing.xs, }) -const $logo: ImageStyle = { - height: 38, - width: 38, -} +const $favoriteButton: ThemedStyle = ({ colors, spacing }) => ({ + borderRadius: 17, + marginTop: spacing.md, + justifyContent: "flex-start", + backgroundColor: colors.palette.neutral300, + borderColor: colors.palette.neutral300, + paddingHorizontal: spacing.md, + paddingTop: spacing.xxxs, + paddingBottom: 0, + minHeight: 32, + alignSelf: "flex-start", +}) + +const $unFavoriteButton: ThemedStyle = ({ colors }) => ({ + borderColor: colors.palette.primary100, + backgroundColor: colors.palette.primary100, +}) + +const $loadingFooter: ThemedStyle = ({ spacing }) => ({ + paddingVertical: spacing.lg, + alignItems: "center", +}) + +const $loadingText: ThemedStyle = ({ colors, spacing }) => ({ + color: colors.textDim, + marginTop: spacing.sm, +}) + +const $emptyState: ThemedStyle = ({ spacing }) => ({ + marginTop: spacing.xxl, +}) + +const $emptyStateImage: ImageStyle = { + transform: [{ scaleX: isRTL ? -1 : 1 }], +} \ No newline at end of file diff --git a/app/screens/DemoDebugScreen.tsx b/app/screens/DemoDebugScreen.tsx index d35adf7..f9e5ab7 100644 --- a/app/screens/DemoDebugScreen.tsx +++ b/app/screens/DemoDebugScreen.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useMemo } from "react"; +import { FC, useCallback, useMemo, useState, useEffect } from "react"; import * as Application from "expo-application"; import { LayoutAnimation, @@ -8,6 +8,7 @@ import { useColorScheme, View, ViewStyle, + Alert, } from "react-native"; import { Button, ListItem, Screen, Text } from "../components"; import { DemoTabScreenProps } from "../navigators/DemoNavigator"; @@ -16,6 +17,12 @@ import { $styles } from "../theme"; import { isRTL } from "@/i18n"; import { useStores } from "../models"; import { useAppTheme } from "@/utils/useAppTheme"; +import { + registerForPushNotificationsAsync, + sendPushNotification, + setupNotificationListeners, + NotificationPermissionError +} from "../services/notifications/notification"; // Adjust path as needed /** * @param {string} url - The URL to open in the browser. @@ -31,14 +38,45 @@ const usingHermes = export const DemoDebugScreen: FC> = function DemoDebugScreen(_props) { - const { setThemeContextOverride, themeContext, themed } = useAppTheme(); + const { setThemeContextOverride, themeContext, themed, theme } = useAppTheme(); const { authenticationStore: { logout }, } = useStores(); + // Push notification state + const [expoPushToken, setExpoPushToken] = useState(''); + const [notificationError, setNotificationError] = useState(null); + const [isNotificationLoading, setIsNotificationLoading] = useState(false); + // @ts-expect-error const usingFabric = global.nativeFabricUIManager != null; + // Setup push notifications + useEffect(() => { + const initializeNotifications = async () => { + setIsNotificationLoading(true); + try { + const token = await registerForPushNotificationsAsync( + theme.colors.palette.primary500 + ); + setExpoPushToken(token || ''); + setNotificationError(null); + } catch (error) { + setNotificationError(error as NotificationPermissionError); + console.error('Push notification setup failed:', error); + } finally { + setIsNotificationLoading(false); + } + }; + + initializeNotifications(); + + // Set up notification listeners + const cleanup = setupNotificationListeners(); + + return cleanup; + }, [theme.colors.palette.primary500]); + const demoReactotron = useMemo( () => async () => { if (__DEV__) { @@ -50,12 +88,13 @@ export const DemoDebugScreen: FC { @@ -70,6 +109,55 @@ export const DemoDebugScreen: FC { + if (!expoPushToken) { + Alert.alert('Error', 'No push token available. Make sure notifications are enabled.'); + return; + } + + try { + const success = await sendPushNotification( + expoPushToken, + 'Test Notification 🚀', + 'This is a test notification from the debug screen!', + { + screen: 'debug', + timestamp: new Date().toISOString(), + testData: 'Hello from Dooit!' + } + ); + + if (success) { + Alert.alert('Success', 'Test notification sent! Check your notification tray.'); + } else { + Alert.alert('Error', 'Failed to send test notification. Check console for details.'); + } + } catch (error) { + Alert.alert('Error', `Failed to send notification: ${error}`); + } + }, [expoPushToken]); + + const getNotificationStatus = () => { + if (isNotificationLoading) return 'Setting up...'; + if (notificationError) { + switch (notificationError.code) { + case 'PERMISSION_DENIED': + return 'Permission denied'; + case 'NO_DEVICE': + return 'Simulator (not supported)'; + case 'NO_PROJECT_ID': + return 'Config error'; + case 'TOKEN_ERROR': + return 'Token error'; + default: + return 'Setup failed'; + } + } + if (expoPushToken) return 'Ready'; + return 'Not available'; + }; + return ( - openLinkInBrowser("https://github.com/infinitered/ignite/issues") + openLinkInBrowser("https://github.com/neweracy/Dooit/issues") } /> Current system theme: {colorScheme} Current app theme: {themeContext} @@ -96,6 +184,64 @@ export const DemoDebugScreen: FC