From a86b9f724f09006a5cce9af07db6ec1c47fd4d15 Mon Sep 17 00:00:00 2001 From: James Zheng Date: Fri, 9 May 2025 12:00:40 +0800 Subject: [PATCH] refactor and modularize learn tab --- src/app/(app)/(tabs)/learn/index.tsx | 833 ++--------------------- src/components/learn/CarbonFootprint.tsx | 235 +++++++ src/components/learn/ChatInput.tsx | 71 ++ src/components/learn/ChatMessage.tsx | 129 ++++ src/components/learn/GreenEvents.tsx | 203 ++++++ src/components/learn/LearnHeader.tsx | 61 ++ src/components/learn/TabNavigation.tsx | 72 ++ src/types/learn.ts | 22 + 8 files changed, 846 insertions(+), 780 deletions(-) create mode 100644 src/components/learn/CarbonFootprint.tsx create mode 100644 src/components/learn/ChatInput.tsx create mode 100644 src/components/learn/ChatMessage.tsx create mode 100644 src/components/learn/GreenEvents.tsx create mode 100644 src/components/learn/LearnHeader.tsx create mode 100644 src/components/learn/TabNavigation.tsx create mode 100644 src/types/learn.ts diff --git a/src/app/(app)/(tabs)/learn/index.tsx b/src/app/(app)/(tabs)/learn/index.tsx index 4d06b03..2b428ae 100644 --- a/src/app/(app)/(tabs)/learn/index.tsx +++ b/src/app/(app)/(tabs)/learn/index.tsx @@ -3,56 +3,30 @@ import { View, Text, ScrollView, - TextInput, - TouchableOpacity, - Animated, - Dimensions, - KeyboardAvoidingView, - Platform, ActivityIndicator, SafeAreaView, StatusBar, - Image, + Platform, + Dimensions, + KeyboardAvoidingView, + Animated, } from "react-native"; -import { LinearGradient } from "expo-linear-gradient"; -import Markdown from "react-native-markdown-display"; -import { Ionicons } from "@expo/vector-icons"; -import * as Haptics from "expo-haptics"; import { useRouter } from "expo-router"; +import * as Haptics from "expo-haptics"; +import { Message, CarbonFootprintResult, GreenEvent } from "@/types/learn"; +import { LearnHeader } from "@/components/learn/LearnHeader"; +import { TabNavigation, TabType } from "@/components/learn/TabNavigation"; +import { ChatMessage } from "@/components/learn/ChatMessage"; +import { ChatInput } from "@/components/learn/ChatInput"; +import { CarbonFootprint } from "@/components/learn/CarbonFootprint"; +import { GreenEvents } from "@/components/learn/GreenEvents"; // Screen dimensions const { width } = Dimensions.get("window"); -// Chat message interface -interface Message { - id: string; - text: string; - sender: "user" | "ai"; - timestamp: Date; -} - -// JSON response interfaces -interface CarbonFootprintResult { - footprint: number; - unit: string; - breakdown: Array<{ category: string; amount: number }>; - tips: string[]; -} - -interface GreenEvent { - title: string; - description: string; - date: string; - location: string; - impact: string; - imageUrl?: string; -} - export default function LearnScreen() { // State variables - const [activeTab, setActiveTab] = useState<"chat" | "carbon" | "events">( - "chat" - ); + const [activeTab, setActiveTab] = useState("chat"); const [messages, setMessages] = useState([]); const [inputMessage, setInputMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -66,7 +40,6 @@ export default function LearnScreen() { const scrollViewRef = useRef(null); const fadeAnim = useRef(new Animated.Value(0)).current; const slideAnim = useRef(new Animated.Value(30)).current; - const tabIndicatorPosition = useRef(new Animated.Value(0)).current; const router = useRouter(); @@ -145,7 +118,7 @@ export default function LearnScreen() { timestamp: new Date(), }; - setMessages((prevMessages) => [...prevMessages, userMessage]); + setMessages((prev) => [...prev, userMessage]); setInputMessage(""); setIsLoading(true); @@ -177,7 +150,7 @@ export default function LearnScreen() { timestamp: new Date(), }; - setMessages((prevMessages) => [...prevMessages, errorMessage]); + setMessages((prev) => [...prev, errorMessage]); } finally { setIsLoading(false); @@ -213,7 +186,7 @@ export default function LearnScreen() { timestamp: new Date(), }; - setMessages((prevMessages) => [...prevMessages, aiMessage]); + setMessages((prev) => [...prev, aiMessage]); } else { throw new Error(data.error || "Failed to get response"); } @@ -295,7 +268,7 @@ export default function LearnScreen() { timestamp: new Date(), }; - setMessages((prevMessages) => [...prevMessages, aiMessage]); + setMessages((prev) => [...prev, aiMessage]); } else { throw new Error("Couldn't parse JSON response"); } @@ -310,7 +283,7 @@ export default function LearnScreen() { timestamp: new Date(), }; - setMessages((prevMessages) => [...prevMessages, aiMessage]); + setMessages((prev) => [...prev, aiMessage]); } } else { throw new Error(data.error || "Failed to get response"); @@ -369,7 +342,7 @@ export default function LearnScreen() { timestamp: new Date(), }; - setMessages((prevMessages) => [...prevMessages, aiMessage]); + setMessages((prev) => [...prev, aiMessage]); } else { throw new Error("Couldn't parse JSON response"); } @@ -384,7 +357,7 @@ export default function LearnScreen() { timestamp: new Date(), }; - setMessages((prevMessages) => [...prevMessages, aiMessage]); + setMessages((prev) => [...prev, aiMessage]); } } else { throw new Error(data.error || "Failed to get response"); @@ -392,7 +365,7 @@ export default function LearnScreen() { }; // Handle tab switching - const handleTabChange = (tab: "chat" | "carbon" | "events") => { + const handleTabChange = (tab: TabType) => { // Haptic feedback for tab change if (Platform.OS === "ios") { Haptics.selectionAsync(); @@ -407,16 +380,6 @@ export default function LearnScreen() { } setActiveTab(tab); - - // Animate tab indicator - const position = - tab === "chat" ? 0 : tab === "carbon" ? width / 3 : (width / 3) * 2; - Animated.spring(tabIndicatorPosition, { - toValue: position, - tension: 300, - friction: 30, - useNativeDriver: false, - }).start(); }; // Handle create event navigation @@ -432,562 +395,6 @@ export default function LearnScreen() { router.push(`/events/new?${params.toString()}`); }; - // Render message item - // Render message item - const renderMessage = (message: Message, index: number) => { - const isUser = message.sender === "user"; - - return ( - - - {/* Profile Image */} - - - - {isUser ? "You" : "SoularAI"} - - - {/* Message Content */} - - {/* Username */} - - {/* Message Bubble */} - - {isUser ? ( - - {message.text} - - ) : ( - - {message.text} - - )} - - - {/* Timestamp */} - - - ); - }; - - // Render carbon footprint section - const renderCarbonFootprint = () => { - if (!carbonData) { - return ( - - - - - Ask me to calculate your carbon footprint in the chat. For example, - try asking: "What's my carbon footprint if I drive 20km daily, use - air conditioning for 5 hours, and eat meat twice a week?" - - - ); - } - - return ( - - - - Carbon Footprint - - - Result - - - - - {carbonData.footprint} - - - - {carbonData.unit} - - - - - - Breakdown - - - {Array.isArray(carbonData.breakdown) && - carbonData.breakdown.map((item, index) => ( - - - {item.category} - - - {item.amount} {carbonData.unit} - - - ))} - - - - - Tips to Reduce Your Footprint - - - {Array.isArray(carbonData.tips) && - carbonData.tips.map((tip, index) => ( - - - - {index + 1} - - - - {tip} - - - ))} - - - ); - }; - - // Render green events section - const renderGreenEvents = () => { - if (greenEvents.length === 0) { - return ( - - - - - Ask me to suggest green events in the chat. For example, try asking: - "Generate green events for this weekend in Hong Kong" or "What - environmental activities can I join this month?" - - - ); - } - - return ( - - {greenEvents.map((event, index) => ( - - - - - - - - - - {event.title} - - - - - - {event.date} - - - - - - {event.location} - - - - - {event.description} - - - - - {event.impact} - - - - - handleCreateEvent(event)} - style={{ - backgroundColor: "#1aea9f", - padding: 12, - borderRadius: 8, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - margin: 20, - marginTop: 0, - }} - > - - - Create Event - - - - ))} - - ); - }; - // Main render return ( - {/* Header */} - - - - Soular - - - Learning - - - - - {/* Tab Navigation */} - {/* Tab Navigation */} - - {["chat", "carbon", "events"].map((tab, index) => ( - handleTabChange(tab as "chat" | "carbon" | "events")} - style={{ - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingVertical: 5, - }} - > - {/* Gradient Background for Active Tab */} - {activeTab === tab ? ( - - ) : null} + - {/* Tab Text */} - - {tab.charAt(0).toUpperCase() + tab.slice(1)} - - - ))} - + - {/* Main Content */} - {/* Tab Content */} {activeTab === "chat" && ( - {messages.map(renderMessage)} + {messages.map((message) => ( + + ))} {isLoading && ( - {/* Input Area */} - - - - inputMessage.trim() && sendMessage(inputMessage.trim()) - } - disabled={!inputMessage.trim() || isLoading} - style={{ - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: inputMessage.trim() ? "#1aea9f" : "#E9ECEF", - justifyContent: "center", - alignItems: "center", - marginBottom: 0, // Add a small margin at the bottom to align with text - }} - > - - - + inputMessage.trim() && sendMessage(inputMessage.trim())} + isLoading={isLoading} + /> )} - {activeTab === "carbon" && renderCarbonFootprint()} - {activeTab === "events" && renderGreenEvents()} + {activeTab === "carbon" && ( + + )} + + {activeTab === "events" && ( + + )} ); diff --git a/src/components/learn/CarbonFootprint.tsx b/src/components/learn/CarbonFootprint.tsx new file mode 100644 index 0000000..3972644 --- /dev/null +++ b/src/components/learn/CarbonFootprint.tsx @@ -0,0 +1,235 @@ +import React from "react"; +import { View, Text, Image, Animated, ScrollView } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import { CarbonFootprintResult } from "@/types/learn"; + +interface CarbonFootprintProps { + data: CarbonFootprintResult | null; + fadeAnim: Animated.Value; + slideAnim: Animated.Value; +} + +export const CarbonFootprint: React.FC = ({ + data, + fadeAnim, + slideAnim, +}) => { + if (!data) { + return ( + + + + Ask me to calculate your carbon footprint in the chat. For example, try + asking: "What's my carbon footprint if I drive 20km daily, use air + conditioning for 5 hours, and eat meat twice a week?" + + + ); + } + + return ( + + + + Carbon Footprint + + + Result + + + + + {data.footprint} + + + + {data.unit} + + + + + + Breakdown + + + {Array.isArray(data.breakdown) && + data.breakdown.map((item, index) => ( + + + {item.category} + + + {item.amount} {data.unit} + + + ))} + + + + + Tips to Reduce Your Footprint + + + {Array.isArray(data.tips) && + data.tips.map((tip, index) => ( + + + + {index + 1} + + + + {tip} + + + ))} + + + ); +}; \ No newline at end of file diff --git a/src/components/learn/ChatInput.tsx b/src/components/learn/ChatInput.tsx new file mode 100644 index 0000000..15696c2 --- /dev/null +++ b/src/components/learn/ChatInput.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { View, TextInput, TouchableOpacity } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; + +interface ChatInputProps { + value: string; + onChangeText: (text: string) => void; + onSend: () => void; + isLoading?: boolean; +} + +export const ChatInput: React.FC = ({ + value, + onChangeText, + onSend, + isLoading, +}) => { + return ( + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/learn/ChatMessage.tsx b/src/components/learn/ChatMessage.tsx new file mode 100644 index 0000000..1456022 --- /dev/null +++ b/src/components/learn/ChatMessage.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { View, Text, Image, Animated } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import Markdown from "react-native-markdown-display"; +import { Message } from "@/types/learn"; + +interface ChatMessageProps { + message: Message; + fadeAnim: Animated.Value; + slideAnim: Animated.Value; +} + +export const ChatMessage: React.FC = ({ + message, + fadeAnim, + slideAnim, +}) => { + const isUser = message.sender === "user"; + + return ( + + + + + {isUser ? "You" : "SoularAI"} + + + + + + {isUser ? ( + + {message.text} + + ) : ( + + {message.text} + + )} + + + + ); +}; \ No newline at end of file diff --git a/src/components/learn/GreenEvents.tsx b/src/components/learn/GreenEvents.tsx new file mode 100644 index 0000000..345a545 --- /dev/null +++ b/src/components/learn/GreenEvents.tsx @@ -0,0 +1,203 @@ +import React from "react"; +import { View, Text, Image, TouchableOpacity, Animated } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import { Ionicons } from "@expo/vector-icons"; +import { GreenEvent } from "@/types/learn"; + +interface GreenEventsProps { + events: GreenEvent[]; + fadeAnim: Animated.Value; + slideAnim: Animated.Value; + onCreateEvent: (event: GreenEvent) => void; +} + +export const GreenEvents: React.FC = ({ + events, + fadeAnim, + slideAnim, + onCreateEvent, +}) => { + if (events.length === 0) { + return ( + + + + Ask me to suggest green events in the chat. For example, try asking: + "Generate green events for this weekend in Hong Kong" or "What + environmental activities can I join this month?" + + + ); + } + + return ( + + {events.map((event, index) => ( + + + + + + + + + + {event.title} + + + + + + {event.date} + + + + + + + {event.location} + + + + + {event.description} + + + + + {event.impact} + + + + onCreateEvent(event)} + style={{ + backgroundColor: "#1aea9f", + padding: 12, + borderRadius: 8, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + margin: 20, + marginTop: 20, + }} + > + + + Create Event + + + + + ))} + + ); +}; \ No newline at end of file diff --git a/src/components/learn/LearnHeader.tsx b/src/components/learn/LearnHeader.tsx new file mode 100644 index 0000000..1021c38 --- /dev/null +++ b/src/components/learn/LearnHeader.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { View, Text, Platform, StatusBar, Animated } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; + +interface LearnHeaderProps { + fadeAnim: Animated.Value; + slideAnim: Animated.Value; +} + +export const LearnHeader: React.FC = ({ fadeAnim, slideAnim }) => { + return ( + + + + Soular + + + Learning + + + + ); +}; \ No newline at end of file diff --git a/src/components/learn/TabNavigation.tsx b/src/components/learn/TabNavigation.tsx new file mode 100644 index 0000000..6a77882 --- /dev/null +++ b/src/components/learn/TabNavigation.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { View, Text, TouchableOpacity, Dimensions } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; + +const { width } = Dimensions.get("window"); + +export type TabType = "chat" | "carbon" | "events"; + +interface TabNavigationProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +export const TabNavigation: React.FC = ({ + activeTab, + onTabChange, +}) => { + return ( + + {["chat", "carbon", "events"].map((tab) => ( + onTabChange(tab as TabType)} + style={{ + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingVertical: 5, + }} + > + {activeTab === tab ? ( + + ) : null} + + + {tab.charAt(0).toUpperCase() + tab.slice(1)} + + + ))} + + ); +}; \ No newline at end of file diff --git a/src/types/learn.ts b/src/types/learn.ts new file mode 100644 index 0000000..c67b91e --- /dev/null +++ b/src/types/learn.ts @@ -0,0 +1,22 @@ +export interface Message { + id: string; + text: string; + sender: "user" | "ai"; + timestamp: Date; +} + +export interface CarbonFootprintResult { + footprint: number; + unit: string; + breakdown: Array<{ category: string; amount: number }>; + tips: string[]; +} + +export interface GreenEvent { + title: string; + description: string; + date: string; + location: string; + impact: string; + imageUrl?: string; +} \ No newline at end of file