From 8b2204b6f9e836d3881e37c2c9df4c792c7fe2f2 Mon Sep 17 00:00:00 2001 From: neweracy Date: Wed, 30 Jul 2025 02:11:55 +0000 Subject: [PATCH 1/4] feat(DemoDebugScreen): Update issue link and change title text to 'Account' --- app/screens/DemoDebugScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/screens/DemoDebugScreen.tsx b/app/screens/DemoDebugScreen.tsx index d35adf7..6bbbbe5 100644 --- a/app/screens/DemoDebugScreen.tsx +++ b/app/screens/DemoDebugScreen.tsx @@ -80,14 +80,14 @@ export const DemoDebugScreen: FC - openLinkInBrowser("https://github.com/infinitered/ignite/issues") + openLinkInBrowser("https://github.com/neweracy/Dooit/issues") } /> Current system theme: {colorScheme} Current app theme: {themeContext} From 8c35913bf7b9c7899ce82cab39d5c8a7c8a9fe4a Mon Sep 17 00:00:00 2001 From: neweracy Date: Fri, 1 Aug 2025 01:56:51 +0000 Subject: [PATCH 2/4] feat: Implement NewsStore for managing healthcare news articles - Added NewsStore model with actions for fetching global and country-specific healthcare news, searching articles, and managing favorites. - Integrated NewsStore into RootStore. - Updated API service to include methods for fetching news articles from NewsData.io. - Enhanced DemoCommunityScreen to display healthcare news articles with features for favoriting and searching. - Added new icons for the news feature. --- app/app.tsx | 12 + app/components/Icon.tsx | 3 +- app/models/NewsArticle.test.ts | 202 ++++++++++ app/models/NewsArticle.ts | 242 +++++++++++ app/models/NewsStore.test.ts | 115 ++++++ app/models/NewsStore.ts | 288 +++++++++++++ app/models/RootStore.ts | 2 + app/models/index.ts | 3 + app/navigators/DemoNavigator.tsx | 2 +- app/screens/DemoCommunityScreen.tsx | 602 ++++++++++++++++++++++------ app/services/api/api.ts | 242 ++++++++++- app/services/api/api.types.ts | 33 +- assets/icons/newspaper.png | Bin 0 -> 5395 bytes assets/icons/schedule.png | Bin 0 -> 15851 bytes 14 files changed, 1580 insertions(+), 166 deletions(-) create mode 100644 app/models/NewsArticle.test.ts create mode 100644 app/models/NewsArticle.ts create mode 100644 app/models/NewsStore.test.ts create mode 100644 app/models/NewsStore.ts create mode 100644 assets/icons/newspaper.png create mode 100644 assets/icons/schedule.png 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/services/api/api.ts b/app/services/api/api.ts index 1ea4c2c..20cd6e6 100644 --- a/app/services/api/api.ts +++ b/app/services/api/api.ts @@ -8,8 +8,66 @@ import { ApiResponse, ApisauceInstance, create } from "apisauce" import Config from "../../config" import { GeneralApiProblem, getGeneralApiProblem } from "./apiProblem" -import type { ApiConfig, ApiFeedResponse } from "./api.types" -import type { EpisodeSnapshotIn } from "../../models/Episode" +import type { ApiConfig } from "./api.types" + +// NewsData.io API types +export interface NewsArticle { + article_id: string + title: string + link: string + keywords: string[] + creator: string[] + description: string + content: string + pubDate: string + pubDateTZ: string + image_url: string | null + video_url: string | null + source_id: string + source_name: string + source_priority: number + source_url: string + source_icon: string + language: string + country: string[] + category: string[] + ai_tag?: string + sentiment?: string + ai_summary?: string +} + +export interface NewsDataResponse { + status: string + totalResults: number + results: NewsArticle[] + nextPage?: string +} + +export interface NewsSource { + id: string + name: string + url: string + category: string[] + language: string[] + country: string[] + description: string + status: string +} + +export interface NewsSourcesResponse { + status: string + results: NewsSource[] +} + +export interface NewsSearchParams { + q?: string + country?: string + category?: string + language?: string + from_date?: string + to_date?: string + page?: string +} /** * Configuring the apisauce instance. @@ -19,12 +77,22 @@ export const DEFAULT_API_CONFIG: ApiConfig = { timeout: 10000, } +/** + * NewsData.io API configuration + */ +export const NEWSDATA_API_CONFIG = { + baseURL: "https://newsdata.io/api/1/", + apiKey: "pub_ba0b6114ab7a4c4bb4c770a6fe1de3fd", + timeout: 15000, +} + /** * Manages all requests to the API. You can use this class to build out * various requests that you need to call from your backend API. */ export class Api { apisauce: ApisauceInstance + newsDataApi: ApisauceInstance config: ApiConfig /** @@ -39,34 +107,131 @@ export class Api { Accept: "application/json", }, }) + + // Set up NewsData.io API instance + this.newsDataApi = create({ + baseURL: NEWSDATA_API_CONFIG.baseURL, + timeout: NEWSDATA_API_CONFIG.timeout, + headers: { + Accept: "application/json", + }, + }) + } + + /** + * Gets latest news articles from NewsData.io + */ + async getLatestNews(params: NewsSearchParams = {}): Promise<{ kind: "ok"; articles: NewsArticle[]; totalResults: number; nextPage?: string } | GeneralApiProblem> { + // Build query parameters + const queryParams = { + apikey: NEWSDATA_API_CONFIG.apiKey, + ...params, + } + + const response: ApiResponse = await this.newsDataApi.get("latest", queryParams) + + if (!response.ok) { + const problem = getGeneralApiProblem(response) + if (problem) return problem + } + + try { + const rawData = response.data + + if (rawData?.status !== "success") { + return { kind: "bad-data" } + } + + return { + kind: "ok", + articles: rawData.results || [], + totalResults: rawData.totalResults || 0, + nextPage: rawData.nextPage, + } + } catch (e) { + if (__DEV__ && e instanceof Error) { + console.error(`Bad data: ${e.message}\n${response.data}`, e.stack) + } + return { kind: "bad-data" } + } + } + + /** + * Gets global healthcare news + */ + async getGlobalHealthcareNews(page?: string): Promise<{ kind: "ok"; articles: NewsArticle[]; totalResults: number; nextPage?: string } | GeneralApiProblem> { + return this.getLatestNews({ + category: "health", + language: "en", + ...(page && { page }), + }) + } + + /** + * Gets healthcare news for a specific country + */ + async getCountryHealthcareNews(country: string, page?: string): Promise<{ kind: "ok"; articles: NewsArticle[]; totalResults: number; nextPage?: string } | GeneralApiProblem> { + return this.getLatestNews({ + country, + category: "health", + language: "en", + ...(page && { page }), + }) + } + + /** + * Gets Ghana-specific healthcare news + */ + async getGhanaHealthcareNews(page?: string): Promise<{ kind: "ok"; articles: NewsArticle[]; totalResults: number; nextPage?: string } | GeneralApiProblem> { + return this.getCountryHealthcareNews("GH", page) + } + + /** + * Search for specific healthcare topics + */ + async searchHealthcareNews(query: string, country?: string, page?: string): Promise<{ kind: "ok"; articles: NewsArticle[]; totalResults: number; nextPage?: string } | GeneralApiProblem> { + return this.getLatestNews({ + q: query, + category: "health", + language: "en", + ...(country && { country }), + ...(page && { page }), + }) } /** - * Gets a list of recent React Native Radio episodes. + * Gets archived healthcare news within a date range */ - async getEpisodes(): Promise<{ kind: "ok"; episodes: EpisodeSnapshotIn[] } | GeneralApiProblem> { - // make the api call - const response: ApiResponse = await this.apisauce.get( - `api.json?rss_url=https%3A%2F%2Ffeeds.simplecast.com%2FhEI_f9Dx`, - ) + async getHealthcareNewsArchive(fromDate: string, toDate: string, query?: string, country?: string): Promise<{ kind: "ok"; articles: NewsArticle[]; totalResults: number; nextPage?: string } | GeneralApiProblem> { + const queryParams = { + apikey: NEWSDATA_API_CONFIG.apiKey, + from_date: fromDate, + to_date: toDate, + language: "en", + ...(query && { q: query }), + ...(country && { country }), + } + + const response: ApiResponse = await this.newsDataApi.get("archive", queryParams) - // the typical ways to die when calling an api if (!response.ok) { const problem = getGeneralApiProblem(response) if (problem) return problem } - // transform the data into the format we are expecting try { const rawData = response.data - // This is where we transform the data into the shape we expect for our MST model. - const episodes: EpisodeSnapshotIn[] = - rawData?.items.map((raw) => ({ - ...raw, - })) ?? [] + if (rawData?.status !== "success") { + return { kind: "bad-data" } + } - return { kind: "ok", episodes } + return { + kind: "ok", + articles: rawData.results || [], + totalResults: rawData.totalResults || 0, + nextPage: rawData.nextPage, + } } catch (e) { if (__DEV__ && e instanceof Error) { console.error(`Bad data: ${e.message}\n${response.data}`, e.stack) @@ -74,7 +239,50 @@ export class Api { return { kind: "bad-data" } } } + + /** + * Gets available news sources + */ + async getNewsSources(country?: string, category?: string): Promise<{ kind: "ok"; sources: NewsSource[] } | GeneralApiProblem> { + const queryParams = { + apikey: NEWSDATA_API_CONFIG.apiKey, + ...(country && { country }), + ...(category && { category }), + } + + const response: ApiResponse = await this.newsDataApi.get("sources", queryParams) + + if (!response.ok) { + const problem = getGeneralApiProblem(response) + if (problem) return problem + } + + try { + const rawData = response.data + + if (rawData?.status !== "success") { + return { kind: "bad-data" } + } + + return { + kind: "ok", + sources: rawData.results || [], + } + } catch (e) { + if (__DEV__ && e instanceof Error) { + console.error(`Bad data: ${e.message}\n${response.data}`, e.stack) + } + return { kind: "bad-data" } + } + } + + /** + * Gets Ghana healthcare news sources + */ + async getGhanaHealthcareSources(): Promise<{ kind: "ok"; sources: NewsSource[] } | GeneralApiProblem> { + return this.getNewsSources("GH", "health") + } } // Singleton instance of the API for convenience -export const api = new Api() +export const api = new Api() \ No newline at end of file diff --git a/app/services/api/api.types.ts b/app/services/api/api.types.ts index fef3afd..aded0dc 100644 --- a/app/services/api/api.types.ts +++ b/app/services/api/api.types.ts @@ -2,37 +2,6 @@ * These types indicate the shape of the data you expect to receive from your * API endpoint, assuming it's a JSON object like we have. */ -export interface EpisodeItem { - title: string - pubDate: string - link: string - guid: string - author: string - thumbnail: string - description: string - content: string - enclosure: { - link: string - type: string - length: number - duration: number - rating: { scheme: string; value: string } - } - categories: string[] -} - -export interface ApiFeedResponse { - status: string - feed: { - url: string - title: string - link: string - author: string - description: string - image: string - } - items: EpisodeItem[] -} /** * The options used to configure apisauce. @@ -47,4 +16,4 @@ export interface ApiConfig { * Milliseconds before we timeout the request. */ timeout: number -} +} \ No newline at end of file diff --git a/assets/icons/newspaper.png b/assets/icons/newspaper.png new file mode 100644 index 0000000000000000000000000000000000000000..8dcfd848879f91fecc308599fbe0c752f8c1ea90 GIT binary patch literal 5395 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 btD)F>+Z6=Fe0DMVD>hdy8tIi^K!^VqKNr_} literal 0 HcmV?d00001 diff --git a/assets/icons/schedule.png b/assets/icons/schedule.png new file mode 100644 index 0000000000000000000000000000000000000000..ff3f111546b894a0380d8db0e190dc82682cc6c3 GIT binary patch literal 15851 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 literal 0 HcmV?d00001 From f2c681b7555d658ca8a8d912b7b92b1d2933ab8d Mon Sep 17 00:00:00 2001 From: neweracy Date: Mon, 4 Aug 2025 14:39:35 +0000 Subject: [PATCH 3/4] feat: Add initial deployment workflow and text file --- .github/workflow/deploy.yml | 0 .txt | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/workflow/deploy.yml create mode 100644 .txt 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 From 1b125d3b6566765cf31ac1ab72edd7061942bbf0 Mon Sep 17 00:00:00 2001 From: neweracy Date: Tue, 5 Aug 2025 05:50:34 +0000 Subject: [PATCH 4/4] feat(Notifications): Enhance notification handling with error management and setup hooks --- app/screens/DemoDebugScreen.tsx | 162 +++++++++++++++++- app/services/notifications/notification.ts | 183 +++++++++++++++++---- bun.lockb | Bin 614669 -> 614677 bytes 3 files changed, 311 insertions(+), 34 deletions(-) diff --git a/app/screens/DemoDebugScreen.tsx b/app/screens/DemoDebugScreen.tsx index 6bbbbe5..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 (