diff --git a/.github/workflows/bundle-analysis.yml b/.github/workflows/bundle-analysis.yml index 312ac57b..2bc4de12 100644 --- a/.github/workflows/bundle-analysis.yml +++ b/.github/workflows/bundle-analysis.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index a288eb67..df7dfb3b 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,7 +1,7 @@ name: Create EAS Preview on: - pull_request_target: + pull_request: permissions: contents: read @@ -9,13 +9,10 @@ permissions: jobs: preview: - if: contains(github.event.pull_request.labels.*.name, 'run-preview') runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v5 - with: - ref: ${{ github.event.pull_request.head.sha }} + uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 56cf5a4e..a89d03ad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🏗 Setup repo - uses: actions/checkout@v5 + uses: actions/checkout@v4 - name: 🏗 Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/rn-bundle-analysis.yml b/.github/workflows/rn-bundle-analysis.yml index 47a857ac..28df9029 100644 --- a/.github/workflows/rn-bundle-analysis.yml +++ b/.github/workflows/rn-bundle-analysis.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 254312af..f1f5d2cb 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 with: fetch-depth: 0 # Full history for better diff analysis diff --git a/Design Revamp.html b/Design Revamp.html new file mode 100644 index 00000000..735d45cb --- /dev/null +++ b/Design Revamp.html @@ -0,0 +1,557 @@ + + + + + + Interactive Blueprint: Splitwiser Redesign + + + + + + + + + + + + + + +
+ +
+
+

A New Vision for Expense Splitting

+

Moving from a '90s feel to a modern, Gen Z-centric experience through the philosophy of Expressive Minimalism.

+
+
+ +
+
+

Core Principles for Gen Z

+

These four pillars translate Gen Z's digital behaviors into an actionable design framework, ensuring the app is not just used, but loved.

+
+
+
+ ⚡️ +
+

Immediacy

+

Designing for an 8-second attention span. Performance is a feature, minimalism is a functional requirement, and every action gets instant feedback.

+
+
+
+ 🤝 +
+

Authenticity

+

Building trust through transparency. No fake fronts, just clear financial data, upfront policies, and honest communication.

+
+
+
+ 🎨 +
+

Personalization

+

The interface as an extension of identity. Offering meaningful customization like dark mode and accent colors to make the app feel personal.

+
+
+
+ 🌍 +
+

Values-Driven

+

Embedding ethics into the design. Prioritizing accessibility, inclusive representation, and gender-neutral language as a baseline requirement.

+
+
+
+
+ +
+
+

The Design System

+

The single source of truth that codifies "Expressive Minimalism" into a set of reusable components and clear standards for a consistent, high-quality experience.

+ +
+
+

Aesthetic: Strategic Glassmorphism

+
+
+
+
+

Floating Content

+

This effect is used for background surfaces like cards to create depth and a modern feel, while critical text remains on opaque surfaces for accessibility.

+
+
+
+ +
+

Color Palette

+
+
+

Primary (Fintech Trust)

+
+
Deep Blue
+
Dark Green
+
+
+
+

Accent (Gen Z Expression)

+
+
+
+
+
+
+
+

Neutral (Foundation)

+
+
+
+
+
+
+
+
+

Semantic (Status)

+
+
+
+
+
+
+
+
+ +
+

Typography: Inter

+
+

Display Text

+

Screen Title (H1)

+

Section Heading (H2)

+

This is body text. Inter was designed for high legibility on screens, making it perfect for both expressive headlines and dense financial data.

+

This is caption text for metadata.

+
+
+
+
+
+ +
+
+

Core Components

+

The reusable building blocks of the interface. Standardized for consistency, accessibility, and development efficiency.

+
+
+

Buttons & CTAs

+
+ + + +
+
+

States:

+
+ + +
+
+
+
+

Input Fields & Forms

+
+
+ + +
+
+ + +
+
+ + +

Please enter a valid email address.

+
+
+
+
+

Cards & Navigation

+
+
+
+
+
+

Dinner with Friends

+

$42.50

+
+
🍕
+
+

August 15, 2025

+
+
+
+
+
+ 📊 +

Dashboard

+
+
+ 👥 +

Groups

+
+
+ 🔔 +

Activity

+
+
+

A bottom tab bar provides global navigation. The active tab is clearly differentiated with a filled icon, background, and bolder text.

+
+
+
+
+
+
+ +
+
+

Screen Redesigns

+

Applying the design system to create an intuitive, efficient, and visually cohesive user journey across the app's most critical screens.

+
+
+

Dashboard

+
+

Dashboard

+
+

Net Balance

+

$112.50

+
+

You are owed: $150.00

+

You owe: $37.50

+
+
+

Recent Activity

+
+
☕️Coffee$5.00
+
groceriesGroceries$37.50
+
movieMovie Tickets$30.00
+
+
+
+
+

Add Expense

+
+
+

Add Expense

+
+ $ + 75.00 +
+
+
📝Description
+
👥Split with...
+
🏷️Category
+
+
+
+ +
+
+
+
+

Settle Up

+
+

Settle Up

+
+
🧑
+

You pay

+

Jane Doe

+

$37.50

+
+
+ + +
+
+
+
+
+
+ +
+
+

Motion & Microinteractions

+

Subtle, fast, and meaningful animations provide feedback, guide attention, and make the app feel alive and responsive.

+
+
+

Feedback

+

Instant confirmation of user actions. Try tapping the button.

+ +
+
+

Guidance

+

Motion orients users. Click to simulate opening a modal.

+ +
+
+

Delight

+

Create positive emotional connections. Click to see a success animation.

+ +
+
+ +
+
+
+ +
+ + + + + + diff --git a/backend/app/expenses/routes.py b/backend/app/expenses/routes.py index a21e4ca9..cd02d66c 100644 --- a/backend/app/expenses/routes.py +++ b/backend/app/expenses/routes.py @@ -460,3 +460,52 @@ async def group_expense_analytics( raise HTTPException(status_code=404, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail="Failed to fetch analytics") + + +# Debug endpoint (remove in production) +@router.get("/expenses/{expense_id}/debug") +async def debug_expense( + group_id: str, + expense_id: str, + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Debug endpoint to check expense details and user permissions""" + try: + from app.database import mongodb + from bson import ObjectId + + # Check if expense exists + expense = await mongodb.database.expenses.find_one( + {"_id": ObjectId(expense_id)} + ) + if not expense: + return {"error": "Expense not found", "expense_id": expense_id} + + # Check group membership + group = await mongodb.database.groups.find_one( + {"_id": ObjectId(group_id), "members.userId": current_user["_id"]} + ) + + # Check if user created the expense + user_created = expense.get("createdBy") == current_user["_id"] + + return { + "expense_exists": True, + "expense_id": expense_id, + "group_id": group_id, + "user_id": current_user["_id"], + "expense_created_by": expense.get("createdBy"), + "user_created_expense": user_created, + "user_in_group": group is not None, + "expense_group_id": expense.get("groupId"), + "group_id_match": expense.get("groupId") == group_id, + "expense_data": { + "description": expense.get("description"), + "amount": expense.get("amount"), + "splits_count": len(expense.get("splits", [])), + "created_at": expense.get("createdAt"), + "updated_at": expense.get("updatedAt"), + }, + } + except Exception as e: + return {"error": str(e), "type": type(e).__name__} diff --git a/frontend/assets/adaptive-icon.png b/frontend/assets/adaptive-icon.png index adc6e21d..03d6f6b6 100644 Binary files a/frontend/assets/adaptive-icon.png and b/frontend/assets/adaptive-icon.png differ diff --git a/frontend/assets/adaptive-icon/background.png b/frontend/assets/adaptive-icon/background.png deleted file mode 100644 index 0a4332a6..00000000 Binary files a/frontend/assets/adaptive-icon/background.png and /dev/null differ diff --git a/frontend/assets/adaptive-icon/foreground.png b/frontend/assets/adaptive-icon/foreground.png deleted file mode 100644 index b56ddd29..00000000 Binary files a/frontend/assets/adaptive-icon/foreground.png and /dev/null differ diff --git a/frontend/assets/favicon.png b/frontend/assets/favicon.png index ffd2b4ae..e75f697b 100644 Binary files a/frontend/assets/favicon.png and b/frontend/assets/favicon.png differ diff --git a/frontend/assets/icon.png b/frontend/assets/icon.png index adc6e21d..a0b1526f 100644 Binary files a/frontend/assets/icon.png and b/frontend/assets/icon.png differ diff --git a/frontend/assets/pwa/chrome-icon/chrome-icon-144.png b/frontend/assets/pwa/chrome-icon/chrome-icon-144.png deleted file mode 100644 index 8afeeeaa..00000000 Binary files a/frontend/assets/pwa/chrome-icon/chrome-icon-144.png and /dev/null differ diff --git a/frontend/assets/pwa/chrome-icon/chrome-icon-192.png b/frontend/assets/pwa/chrome-icon/chrome-icon-192.png deleted file mode 100644 index 77bb31f9..00000000 Binary files a/frontend/assets/pwa/chrome-icon/chrome-icon-192.png and /dev/null differ diff --git a/frontend/assets/pwa/chrome-icon/chrome-icon-512.png b/frontend/assets/pwa/chrome-icon/chrome-icon-512.png deleted file mode 100644 index 3ef334e5..00000000 Binary files a/frontend/assets/pwa/chrome-icon/chrome-icon-512.png and /dev/null differ diff --git a/frontend/assets/splash.png b/frontend/assets/splash.png deleted file mode 100644 index 26c97a31..00000000 Binary files a/frontend/assets/splash.png and /dev/null differ diff --git a/frontend/components/SkeletonLoader.js b/frontend/components/SkeletonLoader.js new file mode 100644 index 00000000..e469b025 --- /dev/null +++ b/frontend/components/SkeletonLoader.js @@ -0,0 +1,40 @@ +import React, { useEffect } from "react"; +import { View, StyleSheet } from "react-native"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + interpolateColor, +} from "react-native-reanimated"; +import { colors } from "../styles/theme"; + +const SkeletonLoader = ({ style }) => { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); + }, []); + + const animatedStyle = useAnimatedStyle(() => { + const backgroundColor = interpolateColor( + progress.value, + [0, 1], + [colors.secondary, colors.white] + ); + return { + backgroundColor, + }; + }); + + return ; +}; + +const styles = StyleSheet.create({ + skeleton: { + backgroundColor: colors.secondary, + borderRadius: 4, + }, +}); + +export default SkeletonLoader; diff --git a/frontend/navigation/GroupsStackNavigator.js b/frontend/navigation/GroupsStackNavigator.js index 5ede954d..dc0fe648 100644 --- a/frontend/navigation/GroupsStackNavigator.js +++ b/frontend/navigation/GroupsStackNavigator.js @@ -12,9 +12,9 @@ const GroupsStackNavigator = () => { - + - + ); }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e1481a1..003c981e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "react-dom": "19.0.0", "react-native": "0.79.5", "react-native-paper": "^5.14.5", + "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "^5.5.2", "react-native-screens": "^4.13.1", "react-native-web": "^0.20.0" @@ -1355,6 +1356,21 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", @@ -7147,6 +7163,41 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "license": "MIT" }, + "node_modules/react-native-reanimated": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz", + "integrity": "sha512-SxBK7wQfJ4UoWoJqQnmIC7ZjuNgVb9rcY5Xc67upXAFKftWg0rnkknTw6vgwnjRcvYThrjzUVti66XoZdDJGtw==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.0.0-0", + "@babel/plugin-transform-class-properties": "^7.0.0-0", + "@babel/plugin-transform-classes": "^7.0.0-0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", + "@babel/plugin-transform-optional-chaining": "^7.0.0-0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", + "@babel/plugin-transform-template-literals": "^7.0.0-0", + "@babel/plugin-transform-unicode-regex": "^7.0.0-0", + "@babel/preset-typescript": "^7.16.7", + "convert-source-map": "^2.0.0", + "invariant": "^2.2.4", + "react-native-is-edge-to-edge": "1.1.7" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0", + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz", + "integrity": "sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.5.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 39a8d506..a8291ea6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,8 @@ "react-native-paper": "^5.14.5", "react-native-safe-area-context": "^5.5.2", "react-native-screens": "^4.13.1", - "react-native-web": "^0.20.0" + "react-native-web": "^0.20.0", + "react-native-reanimated": "~3.17.4" }, "devDependencies": { "@babel/core": "^7.20.0" diff --git a/frontend/screens/AccountScreen.js b/frontend/screens/AccountScreen.js index 16ce8392..7bda33b5 100644 --- a/frontend/screens/AccountScreen.js +++ b/frontend/screens/AccountScreen.js @@ -1,7 +1,9 @@ +import { Ionicons } from "@expo/vector-icons"; import { useContext } from "react"; -import { Alert, StyleSheet, View } from "react-native"; -import { Appbar, Avatar, Divider, List, Text } from "react-native-paper"; +import { Alert, StyleSheet, TouchableOpacity, View } from "react-native"; +import { Appbar, Avatar, Divider, Text } from "react-native-paper"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const AccountScreen = ({ navigation }) => { const { user, logout } = useContext(AuthContext); @@ -14,51 +16,84 @@ const AccountScreen = ({ navigation }) => { Alert.alert("Coming Soon", "This feature is not yet implemented."); }; + const menuItems = [ + { + title: "Edit Profile", + icon: "person-outline", + onPress: () => navigation.navigate("EditProfile"), + }, + { + title: "Email Settings", + icon: "mail-outline", + onPress: handleComingSoon, + }, + { + title: "Send Feedback", + icon: "chatbubble-ellipses-outline", + onPress: handleComingSoon, + }, + { + title: "Logout", + icon: "log-out-outline", + onPress: handleLogout, + color: colors.error, + }, + ]; + return ( - - + + {user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl) ? ( - + ) : ( - + )} - - {user?.name} - - - {user?.email} - + {user?.name} + {user?.email} - - } - onPress={() => navigation.navigate("EditProfile")} - /> - - } - onPress={handleComingSoon} - /> - - } - onPress={handleComingSoon} - /> - - } - onPress={handleLogout} - /> - + + {menuItems.map((item, index) => ( + + + + + {item.title} + + + + {index < menuItems.length - 1 && } + + ))} + ); @@ -67,20 +102,47 @@ const AccountScreen = ({ navigation }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, content: { - padding: 16, + padding: spacing.md, }, profileSection: { alignItems: "center", - marginBottom: 24, + marginBottom: spacing.xl, + backgroundColor: colors.white, + padding: spacing.lg, + borderRadius: spacing.sm, + }, + avatar: { + backgroundColor: colors.primary, }, name: { - marginTop: 16, + ...typography.h2, + marginTop: spacing.md, + color: colors.text, }, email: { - marginTop: 4, - color: "gray", + ...typography.body, + marginTop: spacing.xs, + color: colors.textSecondary, + }, + menuContainer: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + }, + menuItem: { + flexDirection: "row", + alignItems: "center", + paddingVertical: spacing.md, + paddingHorizontal: spacing.md, + }, + menuIcon: { + marginRight: spacing.md, + }, + menuItemText: { + ...typography.body, + flex: 1, }, }); diff --git a/frontend/screens/AddExpenseScreen.js b/frontend/screens/AddExpenseScreen.js index 59cb65ed..a720579a 100644 --- a/frontend/screens/AddExpenseScreen.js +++ b/frontend/screens/AddExpenseScreen.js @@ -1,79 +1,120 @@ +import { Ionicons } from "@expo/vector-icons"; import { useContext, useEffect, useState } from "react"; import { Alert, KeyboardAvoidingView, Platform, + ScrollView, StyleSheet, + Text, + TouchableOpacity, View, } from "react-native"; import { ActivityIndicator, + Appbar, Button, - Checkbox, Menu, - Paragraph, SegmentedButtons, - Text, TextInput, - Title, } from "react-native-paper"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { createExpense, getGroupMembers } from "../api/groups"; +import SkeletonLoader from "../components/SkeletonLoader"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; + +const CustomCheckbox = ({ label, status, onPress }) => ( + + + {label} + +); + +const SplitInputRow = ({ + label, + value, + onChangeText, + keyboardType = "numeric", + disabled = false, + isPercentage = false, +}) => ( + + {label} + + + {isPercentage && %} + + +); const AddExpenseScreen = ({ route, navigation }) => { const { groupId } = route.params; - const { token, user } = useContext(AuthContext); + const { user } = useContext(AuthContext); const [description, setDescription] = useState(""); const [amount, setAmount] = useState(""); const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [splitMethod, setSplitMethod] = useState("equal"); - const [payerId, setPayerId] = useState(null); // Initialize as null until members are loaded + const [payerId, setPayerId] = useState(null); const [menuVisible, setMenuVisible] = useState(false); - // State for different split methods const [percentages, setPercentages] = useState({}); const [shares, setShares] = useState({}); const [exactAmounts, setExactAmounts] = useState({}); - const [selectedMembers, setSelectedMembers] = useState({}); // For equal split + const [selectedMembers, setSelectedMembers] = useState({}); + const opacity = useSharedValue(0); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); useEffect(() => { const fetchMembers = async () => { try { const response = await getGroupMembers(groupId); setMembers(response.data); - // Initialize split states const initialShares = {}; const initialPercentages = {}; const initialExactAmounts = {}; const initialSelectedMembers = {}; const numMembers = response.data.length; - - // Calculate percentages using integer math to avoid floating-point errors const basePercentage = Math.floor(100 / numMembers); const remainder = 100 - basePercentage * numMembers; response.data.forEach((member, index) => { initialShares[member.userId] = "1"; - - // Distribute percentages using integer math let memberPercentage = basePercentage; - // Distribute remainder to first members (could also be last, but first is simpler) if (index < remainder) { memberPercentage += 1; } initialPercentages[member.userId] = memberPercentage.toString(); - initialExactAmounts[member.userId] = "0.00"; - initialSelectedMembers[member.userId] = true; // Select all by default + initialSelectedMembers[member.userId] = true; }); setShares(initialShares); setPercentages(initialPercentages); setExactAmounts(initialExactAmounts); setSelectedMembers(initialSelectedMembers); - // Set default payer to current user if they're a member const currentUserMember = response.data.find( (member) => member.userId === user._id ); @@ -87,20 +128,17 @@ const AddExpenseScreen = ({ route, navigation }) => { Alert.alert("Error", "Failed to fetch group members."); } finally { setIsLoading(false); + opacity.value = withTiming(1, { duration: 500 }); } }; - if (token && groupId) { + if (groupId) { fetchMembers(); } - }, [token, groupId]); + }, [groupId]); const handleAddExpense = async () => { - if (!description || !amount) { - Alert.alert("Error", "Please fill in all fields."); - return; - } - if (!payerId) { - Alert.alert("Error", "Please select who paid for this expense."); + if (!description || !amount || !payerId) { + Alert.alert("Error", "Please fill in all required fields."); return; } const numericAmount = parseFloat(amount); @@ -110,122 +148,77 @@ const AddExpenseScreen = ({ route, navigation }) => { } setIsSubmitting(true); - let expenseData; - try { let splits = []; - let splitType = splitMethod; - if (splitMethod === "equal") { const includedMembers = Object.keys(selectedMembers).filter( (userId) => selectedMembers[userId] ); - if (includedMembers.length === 0) { - throw new Error("You must select at least one member for the split."); - } + if (includedMembers.length === 0) + throw new Error("Select at least one member for the split."); const splitAmount = Math.round((numericAmount / includedMembers.length) * 100) / 100; - // Calculate remainder to handle rounding - const totalSplitAmount = splitAmount * includedMembers.length; const remainder = - Math.round((numericAmount - totalSplitAmount) * 100) / 100; - + Math.round( + (numericAmount - splitAmount * includedMembers.length) * 100 + ) / 100; splits = includedMembers.map((userId, index) => ({ userId, - amount: index === 0 ? splitAmount + remainder : splitAmount, // Add remainder to first member + amount: index === 0 ? splitAmount + remainder : splitAmount, type: "equal", })); - splitType = "equal"; } else if (splitMethod === "exact") { const total = Object.values(exactAmounts).reduce( (sum, val) => sum + parseFloat(val || "0"), 0 ); - if (Math.abs(total - numericAmount) > 0.01) { - throw new Error( - `The exact amounts must add up to ${numericAmount.toFixed( - 2 - )}. Current total: ${total.toFixed(2)}` - ); - } + if (Math.abs(total - numericAmount) > 0.01) + throw new Error("Exact amounts must add up to the total."); splits = Object.entries(exactAmounts) - .filter(([userId, value]) => parseFloat(value || "0") > 0) + .filter(([, value]) => parseFloat(value || "0") > 0) .map(([userId, value]) => ({ userId, - amount: Math.round(parseFloat(value) * 100) / 100, + amount: parseFloat(value), type: "unequal", })); - splitType = "unequal"; // Backend uses 'unequal' for exact amounts } else if (splitMethod === "percentage") { - const total = Object.values(percentages).reduce( + const totalPercentage = Object.values(percentages).reduce( (sum, val) => sum + parseFloat(val || "0"), 0 ); - if (Math.abs(total - 100) > 0.01) { - throw new Error( - `Percentages must add up to 100%. Current total: ${total.toFixed( - 2 - )}%` - ); + if (Math.abs(totalPercentage - 100) > 0.01) { + throw new Error("Percentages must add up to 100."); } splits = Object.entries(percentages) - .filter(([userId, value]) => parseFloat(value || "0") > 0) + .filter(([, value]) => parseFloat(value || "0") > 0) .map(([userId, value]) => ({ userId, - amount: - Math.round(numericAmount * (parseFloat(value) / 100) * 100) / 100, + amount: (numericAmount * parseFloat(value)) / 100, type: "percentage", })); - splitType = "percentage"; } else if (splitMethod === "shares") { - const nonZeroShares = Object.entries(shares).filter( - ([userId, value]) => parseInt(value || "0", 10) > 0 - ); - const totalShares = nonZeroShares.reduce( - (sum, [, value]) => sum + parseInt(value || "0", 10), + const totalShares = Object.values(shares).reduce( + (sum, val) => sum + parseFloat(val || "0"), 0 ); - if (totalShares === 0) { throw new Error("Total shares cannot be zero."); } - - // Calculate amounts with proper rounding - const amounts = nonZeroShares.map(([userId, value]) => { - const shareRatio = parseInt(value, 10) / totalShares; - return { + splits = Object.entries(shares) + .filter(([, value]) => parseFloat(value || "0") > 0) + .map(([userId, value]) => ({ userId, - amount: Math.round(numericAmount * shareRatio * 100) / 100, - type: "unequal", - }; - }); - - // Adjust for rounding errors - const totalCalculated = amounts.reduce( - (sum, item) => sum + item.amount, - 0 - ); - const difference = - Math.round((numericAmount - totalCalculated) * 100) / 100; - - if (Math.abs(difference) > 0) { - amounts[0].amount = - Math.round((amounts[0].amount + difference) * 100) / 100; - } - - splits = amounts; - splitType = "unequal"; // Backend uses 'unequal' for shares + amount: (numericAmount * parseFloat(value)) / totalShares, + type: "shares", + })); } - - expenseData = { + const expenseData = { description, amount: numericAmount, - paidBy: payerId, // Use the selected payer - splitType, + paidBy: payerId, + splitType: splitMethod, splits, - tags: [], }; - await createExpense(groupId, expenseData); Alert.alert("Success", "Expense added successfully."); navigation.goBack(); @@ -240,44 +233,11 @@ const AddExpenseScreen = ({ route, navigation }) => { setSelectedMembers((prev) => ({ ...prev, [userId]: !prev[userId] })); }; - // Helper function to auto-balance percentages - const balancePercentages = (updatedPercentages) => { - const total = Object.values(updatedPercentages).reduce( - (sum, val) => sum + parseFloat(val || "0"), - 0 - ); - const memberIds = Object.keys(updatedPercentages); - - if (total !== 100 && memberIds.length > 1) { - // Find the last non-zero percentage to adjust - const lastMemberId = memberIds[memberIds.length - 1]; - const otherTotal = Object.entries(updatedPercentages) - .filter(([id]) => id !== lastMemberId) - .reduce((sum, [, val]) => sum + parseFloat(val || "0"), 0); - - const newValue = Math.max(0, 100 - otherTotal); - updatedPercentages[lastMemberId] = newValue.toFixed(2); - } - - return updatedPercentages; - }; - const renderSplitInputs = () => { - const handleSplitChange = (setter, userId, value) => { - if (setter === setPercentages) { - // Auto-balance percentages when one changes - const updatedPercentages = { ...percentages, [userId]: value }; - const balanced = balancePercentages(updatedPercentages); - setter(balanced); - } else { - setter((prev) => ({ ...prev, [userId]: value })); - } - }; - switch (splitMethod) { case "equal": return members.map((member) => ( - { )); case "exact": return members.map((member) => ( - - handleSplitChange(setExactAmounts, member.userId, text) + setExactAmounts({ ...exactAmounts, [member.userId]: text }) } - keyboardType="numeric" - style={styles.splitInput} /> )); case "percentage": return members.map((member) => ( - - handleSplitChange(setPercentages, member.userId, text) + setPercentages({ ...percentages, [member.userId]: text }) } - keyboardType="numeric" - style={styles.splitInput} + isPercentage /> )); case "shares": return members.map((member) => ( - - handleSplitChange(setShares, member.userId, text) + setShares({ ...shares, [member.userId]: text }) } - keyboardType="numeric" - style={styles.splitInput} /> )); default: @@ -330,164 +285,218 @@ const AddExpenseScreen = ({ route, navigation }) => { if (isLoading) { return ( - - + + + navigation.goBack()} color={colors.white} /> + + + ); } - const selectedPayerName = payerId - ? members.find((m) => m.userId === payerId)?.user.name || "Select Payer" - : "Select Payer"; + const selectedPayerName = + members.find((m) => m.userId === payerId)?.user.name || "Select Payer"; return ( - - - - - - setMenuVisible(false)} - anchor={ - - } - > - {members.map((member) => ( - { - setPayerId(member.userId); - setMenuVisible(false); - }} - title={member.user.name} - /> - ))} - - - Split Method - - - {splitMethod === "equal" && ( - - Select members to split the expense equally among them. - - )} - {splitMethod === "exact" && ( - - Enter exact amounts for each member. Total must equal $ - {amount || "0"}. - {amount && ( - - {" "} - Current total: $ - {Object.values(exactAmounts) - .reduce((sum, val) => sum + parseFloat(val || "0"), 0) - .toFixed(2)} - - )} - - )} - {splitMethod === "percentage" && ( - - Enter percentages for each member. Total must equal 100%. - - {" "} - Current total:{" "} - {Object.values(percentages) - .reduce((sum, val) => sum + parseFloat(val || "0"), 0) - .toFixed(2)} - % - - - )} - {splitMethod === "shares" && ( - - Enter shares for each member. Higher shares = larger portion of the - expense. - - )} + + + + navigation.goBack()} + color={colors.white} + /> + + + + + - {renderSplitInputs()} + + Paid by + setMenuVisible(false)} + anchor={ + setMenuVisible(true)} + > + {selectedPayerName} + + + } + > + {members.map((member) => ( + { + setPayerId(member.userId); + setMenuVisible(false); + }} + title={member.user.name} + /> + ))} + + + + Split Method + - - - + {renderSplitInputs()} + + + + + ); }; +const AddExpenseSkeleton = () => ( + + + + + + + + + + + +); + const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, content: { - flex: 1, - padding: 16, - paddingBottom: 32, + padding: spacing.lg, + paddingBottom: spacing.xl, }, loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center", + backgroundColor: colors.secondary, }, input: { - marginBottom: 16, + marginBottom: spacing.md, + backgroundColor: colors.white, }, button: { - marginTop: 24, + marginTop: spacing.lg, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: "bold", }, splitTitle: { - marginTop: 16, - marginBottom: 8, + ...typography.h3, + color: colors.text, + marginTop: spacing.lg, + marginBottom: spacing.md, }, splitInputsContainer: { - marginTop: 8, + marginTop: spacing.md, }, - splitInput: { - marginBottom: 8, + menuAnchor: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: spacing.md, + backgroundColor: colors.white, + borderRadius: spacing.sm, + borderWidth: 1, + borderColor: colors.textSecondary, }, - helperText: { - fontSize: 12, - marginBottom: 8, - opacity: 0.7, + menuAnchorText: { + ...typography.body, + color: colors.text, }, - totalText: { - fontWeight: "bold", - opacity: 1, + label: { + ...typography.body, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + checkboxContainer: { + flexDirection: "row", + alignItems: "center", + paddingVertical: spacing.sm, + }, + checkboxLabel: { + ...typography.body, + color: colors.text, + marginLeft: spacing.md, + }, + splitRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: spacing.sm, + }, + splitLabel: { + ...typography.body, + color: colors.text, + flex: 1, + }, + splitInput: { + width: 100, + textAlign: "right", + backgroundColor: colors.white, + }, + percentageSymbol: { + ...typography.body, + color: colors.textSecondary, + marginLeft: spacing.sm, }, }); diff --git a/frontend/screens/EditProfileScreen.js b/frontend/screens/EditProfileScreen.js index 8201b708..078d724f 100644 --- a/frontend/screens/EditProfileScreen.js +++ b/frontend/screens/EditProfileScreen.js @@ -1,14 +1,15 @@ import * as ImagePicker from "expo-image-picker"; import { useContext, useState } from "react"; -import { Alert, StyleSheet, View } from "react-native"; -import { Appbar, Avatar, Button, TextInput, Title } from "react-native-paper"; +import { Alert, StyleSheet, View, Text } from "react-native"; +import { Appbar, Avatar, Button, TextInput } from "react-native-paper"; import { updateUser } from "../api/auth"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const EditProfileScreen = ({ navigation }) => { - const { user, token, updateUserInContext } = useContext(AuthContext); + const { user, updateUserInContext } = useContext(AuthContext); const [name, setName] = useState(user?.name || ""); - const [pickedImage, setPickedImage] = useState(null); // { uri, base64 } + const [pickedImage, setPickedImage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const handleUpdateProfile = async () => { @@ -19,17 +20,10 @@ const EditProfileScreen = ({ navigation }) => { setIsSubmitting(true); try { const updates = { name }; - - // Add image if picked if (pickedImage?.base64) { - // Dynamically determine MIME type from picker metadata - const mime = - pickedImage.mimeType && /image\//.test(pickedImage.mimeType) - ? pickedImage.mimeType - : "image/jpeg"; // fallback + const mime = pickedImage.mimeType || "image/jpeg"; updates.imageUrl = `data:${mime};base64,${pickedImage.base64}`; } - const response = await updateUser(updates); updateUserInContext(response.data); Alert.alert("Success", "Profile updated successfully."); @@ -43,7 +37,6 @@ const EditProfileScreen = ({ navigation }) => { }; const pickImage = async () => { - // Ask permissions const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== "granted") { Alert.alert( @@ -61,48 +54,47 @@ const EditProfileScreen = ({ navigation }) => { }); if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; - // Capture mimeType (expo-image-picker provides mimeType on iOS/Android SDK 49+) - let mimeType = asset.mimeType || asset.type; // expo sometimes supplies type like 'image' - if (mimeType && !/image\//.test(mimeType)) { - // if it's just 'image', normalize - if (mimeType === 'image') mimeType = 'image/jpeg'; - } - if (!mimeType || !/image\//.test(mimeType)) { - // Attempt to infer from file extension as a lightweight fallback - const ext = (asset.uri || "").split(".").pop()?.toLowerCase(); - if (ext === "png") mimeType = "image/png"; - else if (ext === "webp") mimeType = "image/webp"; - else if (ext === "gif") mimeType = "image/gif"; - else if (ext === "jpg" || ext === "jpeg") mimeType = "image/jpeg"; - else mimeType = "image/jpeg"; // safe default - } + let mimeType = asset.mimeType || "image/jpeg"; setPickedImage({ uri: asset.uri, base64: asset.base64, mimeType }); } }; return ( - - navigation.goBack()} /> - + + navigation.goBack()} + color={colors.white} + /> + - Edit Your Details + Edit Your Details - {/* Profile Picture Section */} - {pickedImage?.uri ? ( - - ) : user?.imageUrl && /^(https?:|data:image)/.test(user.imageUrl) ? ( - - ) : ( - - )} + @@ -113,6 +105,7 @@ const EditProfileScreen = ({ navigation }) => { value={name} onChangeText={setName} style={styles.input} + theme={{ colors: { primary: colors.accent } }} /> @@ -131,22 +125,43 @@ const EditProfileScreen = ({ navigation }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, content: { - padding: 16, + padding: spacing.lg, + }, + title: { + ...typography.h2, + color: colors.text, + marginBottom: spacing.lg, + textAlign: "center", }, profilePictureSection: { alignItems: "center", - marginBottom: 24, + marginBottom: spacing.xl, + }, + avatar: { + backgroundColor: colors.primary, }, imageButton: { - marginTop: 12, + marginTop: spacing.md, + }, + imageButtonLabel: { + color: colors.primary, }, input: { - marginBottom: 16, + marginBottom: spacing.lg, + backgroundColor: colors.white, }, button: { - marginTop: 8, + marginTop: spacing.md, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: "bold", }, }); diff --git a/frontend/screens/FriendsScreen.js b/frontend/screens/FriendsScreen.js index 0da27955..4b52dfc0 100644 --- a/frontend/screens/FriendsScreen.js +++ b/frontend/screens/FriendsScreen.js @@ -1,205 +1,163 @@ +import { Ionicons } from "@expo/vector-icons"; import { useIsFocused } from "@react-navigation/native"; -import { useContext, useEffect, useRef, useState } from "react"; -import { Alert, Animated, FlatList, StyleSheet, View } from "react-native"; +import { useContext, useEffect, useState } from "react"; import { + Alert, + FlatList, + LayoutAnimation, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { + ActivityIndicator, Appbar, Avatar, Divider, - IconButton, - List, Text, } from "react-native-paper"; import { getFriendsBalance, getGroups } from "../api/groups"; +import SkeletonLoader from "../components/SkeletonLoader"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; import { formatCurrency } from "../utils/currency"; +const FriendItem = ({ item, onToggle, isExpanded }) => { + const balanceColor = + item.netBalance < 0 ? colors.error : colors.success; + const balanceText = + item.netBalance < 0 + ? `You owe ${formatCurrency(Math.abs(item.netBalance))}` + : `Owes you ${formatCurrency(item.netBalance)}`; + + return ( + + + + + {item.name} + + {item.netBalance !== 0 ? balanceText : "Settled up"} + + + + + {isExpanded && ( + + + {item.groups.map((group) => ( + + + {group.name} + + {formatCurrency(group.balance)} + + + ))} + + )} + + ); +}; + +const FriendItemSkeleton = () => ( + + + + + + + + + +); + const FriendsScreen = () => { - const { token, user } = useContext(AuthContext); + const { token } = useContext(AuthContext); const [friends, setFriends] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [showTooltip, setShowTooltip] = useState(true); + const [expandedFriend, setExpandedFriend] = useState(null); const isFocused = useIsFocused(); useEffect(() => { const fetchData = async () => { setIsLoading(true); try { - // Fetch friends balance + groups concurrently for group icons - const friendsResponse = await getFriendsBalance(); + const [friendsResponse, groupsResponse] = await Promise.all([ + getFriendsBalance(), + getGroups(), + ]); const friendsData = friendsResponse.data.friendsBalance || []; - const groupsResponse = await getGroups(); - const groups = groupsResponse?.data?.groups || []; const groupMeta = new Map( - groups.map((g) => [g._id, { name: g.name, imageUrl: g.imageUrl }]) + (groupsResponse.data.groups || []).map((g) => [ + g._id, + { name: g.name, imageUrl: g.imageUrl }, + ]) ); - const transformedFriends = friendsData.map((friend) => ({ id: friend.userId, name: friend.userName, - imageUrl: friend.userImageUrl || null, + imageUrl: friend.userImageUrl, netBalance: friend.netBalance, groups: (friend.breakdown || []).map((group) => ({ id: group.groupId, - name: group.groupName, + name: groupMeta.get(group.groupId)?.name || "Unknown Group", balance: group.balance, - imageUrl: groupMeta.get(group.groupId)?.imageUrl || null, + imageUrl: groupMeta.get(group.groupId)?.imageUrl, })), })); - setFriends(transformedFriends); } catch (error) { - console.error("Failed to fetch friends balance data:", error); Alert.alert("Error", "Failed to load friends balance data."); } finally { setIsLoading(false); } }; - - if (token && isFocused) { - fetchData(); - } + if (token && isFocused) fetchData(); }, [token, isFocused]); - const renderFriend = ({ item }) => { - const balanceColor = item.netBalance < 0 ? "red" : "green"; - const balanceText = - item.netBalance < 0 - ? `You owe ${formatCurrency(Math.abs(item.netBalance))}` - : `Owes you ${formatCurrency(item.netBalance)}`; - - // Determine if we have an image URL or a base64 payload - const hasImage = !!item.imageUrl; - let imageUri = null; - if (hasImage) { - // If it's a raw base64 string without prefix, add a default MIME prefix - if ( - /^data:image/.test(item.imageUrl) || - /^https?:\/\//.test(item.imageUrl) - ) { - imageUri = item.imageUrl; - } else if (/^[A-Za-z0-9+/=]+$/.test(item.imageUrl.substring(0, 50))) { - imageUri = `data:image/jpeg;base64,${item.imageUrl}`; - } - } - - return ( - - imageUri ? ( - - ) : ( - - ) - } - > - {item.groups.map((group) => { - const groupBalanceColor = group.balance < 0 ? "red" : "green"; - const groupBalanceText = - group.balance < 0 - ? `You owe ${formatCurrency(Math.abs(group.balance))}` - : `Owes you ${formatCurrency(group.balance)}`; - // Prepare group icon (imageUrl may be base64 or URL) - let groupImageUri = null; - if (group.imageUrl) { - if ( - /^data:image/.test(group.imageUrl) || - /^https?:\/\//.test(group.imageUrl) - ) { - groupImageUri = group.imageUrl; - } else if ( - /^[A-Za-z0-9+/=]+$/.test(group.imageUrl.substring(0, 50)) - ) { - groupImageUri = `data:image/jpeg;base64,${group.imageUrl}`; - } - } - - return ( - - groupImageUri ? ( - - ) : ( - - ) - } - /> - ); - })} - - ); + const handleToggleFriend = (friendId) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setExpandedFriend(expandedFriend === friendId ? null : friendId); }; - // Shimmer skeleton components - const opacityAnim = useRef(new Animated.Value(0.3)).current; - useEffect(() => { - const loop = Animated.loop( - Animated.sequence([ - Animated.timing(opacityAnim, { - toValue: 1, - duration: 700, - useNativeDriver: true, - }), - Animated.timing(opacityAnim, { - toValue: 0.3, - duration: 700, - useNativeDriver: true, - }), - ]) - ); - loop.start(); - return () => loop.stop(); - }, [opacityAnim]); - - const SkeletonRow = () => ( - - - - - - - - ); - if (isLoading) { return ( - - + + - - {Array.from({ length: 5 }).map((_, i) => ( - - ))} + + + + + ); @@ -207,31 +165,24 @@ const FriendsScreen = () => { return ( - - + + - {showTooltip && ( - - - - 💡 These amounts show your direct balance with each friend across - all shared groups. Check individual group details for optimized - settlement suggestions. - - setShowTooltip(false)} - style={styles.closeButton} - /> - - - )} ( + handleToggleFriend(item.id)} + /> + )} keyExtractor={(item) => item.id} - ItemSeparatorComponent={Divider} + contentContainerStyle={styles.listContent} ListEmptyComponent={ No balances with friends yet. } @@ -243,63 +194,51 @@ const FriendsScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, - loaderContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", + listContent: { + padding: spacing.md, }, - explanationContainer: { - backgroundColor: "#f0f8ff", - margin: 8, - borderRadius: 8, - borderLeftWidth: 4, - borderLeftColor: "#2196f3", + friendCard: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + marginBottom: spacing.md, + padding: spacing.md, }, - explanationContent: { + friendHeader: { flexDirection: "row", - alignItems: "flex-start", - padding: 12, + alignItems: "center", }, - explanationText: { - fontSize: 12, - color: "#555", - lineHeight: 16, + friendInfo: { flex: 1, - paddingRight: 8, + marginLeft: spacing.md, }, - closeButton: { - margin: 0, - marginTop: -4, + friendName: { + ...typography.h3, + color: colors.text, }, - emptyText: { - textAlign: "center", - marginTop: 20, + friendBalance: { + ...typography.body, }, - skeletonContainer: { - padding: 16, + groupBreakdown: { + marginTop: spacing.md, }, - skeletonRow: { + groupItem: { flexDirection: "row", alignItems: "center", - marginBottom: 14, - }, - skeletonAvatar: { - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: "#e0e0e0", + paddingVertical: spacing.sm, }, - skeletonLine: { - height: 14, - backgroundColor: "#e0e0e0", - borderRadius: 6, - marginBottom: 6, + groupName: { + flex: 1, + marginLeft: spacing.md, + ...typography.body, + color: colors.text, }, - skeletonLineSmall: { - height: 12, - backgroundColor: "#e0e0e0", - borderRadius: 6, + emptyText: { + textAlign: "center", + marginTop: spacing.xl, + ...typography.body, + color: colors.textSecondary, }, }); diff --git a/frontend/screens/GroupDetailsScreen.js b/frontend/screens/GroupDetailsScreen.js index a1050b9f..1bc45e2b 100644 --- a/frontend/screens/GroupDetailsScreen.js +++ b/frontend/screens/GroupDetailsScreen.js @@ -1,19 +1,28 @@ +import { Ionicons } from "@expo/vector-icons"; import { useContext, useEffect, useState } from "react"; -import { Alert, FlatList, StyleSheet, Text, View } from "react-native"; import { - ActivityIndicator, - Card, - FAB, - IconButton, - Paragraph, - Title, -} from "react-native-paper"; + Alert, + FlatList, + LayoutAnimation, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { ActivityIndicator, FAB } from "react-native-paper"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { getGroupExpenses, getGroupMembers, getOptimizedSettlements, } from "../api/groups"; +import SkeletonLoader from "../components/SkeletonLoader"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const GroupDetailsScreen = ({ route, navigation }) => { const { groupId, groupName } = route.params; @@ -22,17 +31,21 @@ const GroupDetailsScreen = ({ route, navigation }) => { const [expenses, setExpenses] = useState([]); const [settlements, setSettlements] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [settlementExpanded, setSettlementExpanded] = useState(false); + const opacity = useSharedValue(0); - // Currency configuration - can be made configurable later - const currency = "₹"; // Default to INR, can be changed to '$' for USD + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); - // Helper function to format currency amounts + const currency = "₹"; const formatCurrency = (amount) => `${currency}${amount.toFixed(2)}`; const fetchData = async () => { try { setIsLoading(true); - // Fetch members, expenses, and settlements in parallel const [membersResponse, expensesResponse, settlementsResponse] = await Promise.all([ getGroupMembers(groupId), @@ -47,6 +60,7 @@ const GroupDetailsScreen = ({ route, navigation }) => { Alert.alert("Error", "Failed to fetch group details."); } finally { setIsLoading(false); + opacity.value = withTiming(1, { duration: 500 }); } }; @@ -54,16 +68,25 @@ const GroupDetailsScreen = ({ route, navigation }) => { navigation.setOptions({ title: groupName, headerRight: () => ( - navigation.navigate("GroupSettings", { groupId })} - /> + style={{ marginRight: spacing.md }} + > + + ), + headerStyle: { + backgroundColor: colors.primary, + }, + headerTintColor: colors.white, + headerTitleStyle: { + ...typography.h3, + }, }); if (token && groupId) { fetchData(); } - }, [token, groupId]); + }, [token, groupId, navigation]); const getMemberName = (userId) => { const member = members.find((m) => m.userId === userId); @@ -76,30 +99,36 @@ const GroupDetailsScreen = ({ route, navigation }) => { const paidByMe = (item.paidBy || item.createdBy) === user._id; const net = paidByMe ? item.amount - userShare : -userShare; - let balanceText; - let balanceColor = "black"; - + let balanceText, balanceColor; if (net > 0) { - balanceText = `You are owed ${formatCurrency(net)}`; - balanceColor = "green"; + balanceText = `You get back ${formatCurrency(net)}`; + balanceColor = colors.success; } else if (net < 0) { - balanceText = `You borrowed ${formatCurrency(Math.abs(net))}`; - balanceColor = "red"; + balanceText = `You owe ${formatCurrency(Math.abs(net))}`; + balanceColor = colors.error; } else { - balanceText = "You are settled for this expense."; + balanceText = "You are settled for this expense"; + balanceColor = colors.textSecondary; } return ( - - - {item.description} - Amount: {formatCurrency(item.amount)} - - Paid by: {getMemberName(item.paidBy || item.createdBy)} - - {balanceText} - - + + + + + + {item.description} + + Paid by {getMemberName(item.paidBy || item.createdBy)} + + + {balanceText} + + + + {formatCurrency(item.amount)} + + ); }; @@ -109,91 +138,92 @@ const GroupDetailsScreen = ({ route, navigation }) => { const totalOwed = userOwes.reduce((sum, s) => sum + s.amount, 0); const totalToReceive = userIsOwed.reduce((sum, s) => sum + s.amount, 0); - // If user is all settled up + const toggleExpansion = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setSettlementExpanded(!settlementExpanded); + }; + if (userOwes.length === 0 && userIsOwed.length === 0) { return ( - ✓ You are all settled up! + + You are all settled up! ); } return ( - - {/* You owe section - only show if totalOwed > 0 */} - {totalOwed > 0 && ( - - - You need to pay:{" "} - {formatCurrency(totalOwed)} + + + + You Owe + + {formatCurrency(totalOwed)} + + + + You Are Owed + + {formatCurrency(totalToReceive)} + + + + {settlementExpanded && ( + {userOwes.map((s, index) => ( - - - {getMemberName(s.toUserId)} - - - {formatCurrency(s.amount)} - - + + Pay {getMemberName(s.toUserId)} + + + {formatCurrency(s.amount)} + ))} - - )} - - {/* You receive section - only show if totalToReceive > 0 */} - {totalToReceive > 0 && ( - - - You will receive:{" "} - - {formatCurrency(totalToReceive)} - - {userIsOwed.map((s, index) => ( - - - {getMemberName(s.fromUserId)} - - - {formatCurrency(s.amount)} - - + + Receive from {getMemberName(s.fromUserId)} + + + {formatCurrency(s.amount)} + ))} )} - + ); }; if (isLoading) { return ( - - + + + + + ); } const renderHeader = () => ( <> - - - Settlement Summary - {renderSettlementSummary()} - - - - Expenses + {renderSettlementSummary()} + Expenses ); return ( - + item._id} @@ -201,114 +231,173 @@ const GroupDetailsScreen = ({ route, navigation }) => { ListEmptyComponent={ No expenses recorded yet. } - contentContainerStyle={{ paddingBottom: 80 }} // To avoid FAB overlap + contentContainerStyle={styles.contentContainer} /> - navigation.navigate("AddExpense", { groupId: groupId })} + color={colors.white} + onPress={() => navigation.navigate("AddExpense", { groupId })} /> - + ); }; +const SettlementSummarySkeleton = () => ( + + + + + + + + + + + + +); + +const ExpenseCardSkeleton = () => ( + + + + + + + + +); + + const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, contentContainer: { - flex: 1, - padding: 16, + padding: spacing.md, + paddingBottom: 80, }, loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center", + backgroundColor: colors.secondary, }, - card: { - marginBottom: 16, + summaryCard: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + marginBottom: spacing.lg, + ...Platform.select({ + ios: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + android: { + elevation: 3, + }, + }), }, - expensesTitle: { - marginTop: 16, - marginBottom: 8, - fontSize: 20, - fontWeight: "bold", + summaryTotals: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", }, - memberText: { - fontSize: 16, - lineHeight: 24, + summaryTotal: { + alignItems: "center", }, - fab: { - position: "absolute", - margin: 16, - right: 0, - bottom: 0, + summaryLabel: { + ...typography.caption, + color: colors.textSecondary, }, - // Settlement Summary Styles - settlementContainer: { - marginBottom: 16, + summaryAmount: { + ...typography.h2, + }, + settlementDetails: { + marginTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.secondary, + paddingTop: spacing.md, + }, + settlementItem: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: spacing.sm, + }, + settlementText: { + ...typography.body, + color: colors.text, + }, + settlementAmount: { + ...typography.body, + fontWeight: "bold", + color: colors.text, }, settledContainer: { + flexDirection: "row", alignItems: "center", - paddingVertical: 12, + justifyContent: "center", + padding: spacing.md, + backgroundColor: colors.white, + borderRadius: spacing.sm, + marginBottom: spacing.lg, }, settledText: { - fontSize: 16, - color: "#2e7d32", - fontWeight: "500", + ...typography.body, + color: colors.success, + marginLeft: spacing.sm, }, - owedSection: { - backgroundColor: "#ffebee", - borderRadius: 8, - padding: 12, - borderLeftWidth: 4, - borderLeftColor: "#d32f2f", + expensesTitle: { + ...typography.h3, + color: colors.text, + marginBottom: spacing.md, }, - receiveSection: { - backgroundColor: "#e8f5e8", - borderRadius: 8, - padding: 12, - borderLeftWidth: 4, - borderLeftColor: "#2e7d32", + expenseCard: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + marginBottom: spacing.md, + flexDirection: "row", + alignItems: "center", }, - sectionTitle: { - fontSize: 16, - fontWeight: "600", - marginBottom: 8, - color: "#333", + expenseIcon: { + marginRight: spacing.md, }, - amountOwed: { - color: "#d32f2f", - fontWeight: "bold", + expenseDetails: { + flex: 1, }, - amountReceive: { - color: "#2e7d32", + expenseDescription: { + ...typography.body, fontWeight: "bold", + color: colors.text, }, - settlementItem: { - marginVertical: 4, + expensePaidBy: { + ...typography.caption, + color: colors.textSecondary, }, - personInfo: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 4, + expenseBalance: { + ...typography.body, + marginTop: spacing.xs, }, - personName: { - fontSize: 14, - color: "#555", - flex: 1, + expenseAmount: { + ...typography.h3, + color: colors.text, }, - settlementAmount: { - fontSize: 14, - fontWeight: "600", - color: "#333", + fab: { + position: "absolute", + margin: spacing.md, + right: 0, + bottom: 0, }, emptyText: { - fontSize: 14, - color: "#666", - paddingVertical: 8, + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + marginTop: spacing.lg, }, }); diff --git a/frontend/screens/GroupSettingsScreen.js b/frontend/screens/GroupSettingsScreen.js index 90de5d16..da5f8595 100644 --- a/frontend/screens/GroupSettingsScreen.js +++ b/frontend/screens/GroupSettingsScreen.js @@ -1,57 +1,66 @@ +import { Ionicons } from "@expo/vector-icons"; import * as ImagePicker from "expo-image-picker"; -import { - useContext, - useEffect, - useLayoutEffect, - useMemo, - useState, -} from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { Alert, - Image, ScrollView, Share, StyleSheet, + TouchableOpacity, View, } from "react-native"; import { ActivityIndicator, + Appbar, Avatar, Button, - Card, + Divider, IconButton, - List, Text, TextInput, } from "react-native-paper"; import { deleteGroup as apiDeleteGroup, - leaveGroup as apiLeaveGroup, - removeMember as apiRemoveMember, - updateGroup as apiUpdateGroup, getGroupById, getGroupMembers, getOptimizedSettlements, + leaveGroup as apiLeaveGroup, + removeMember as apiRemoveMember, + updateGroup as apiUpdateGroup, } from "../api/groups"; +import SkeletonLoader from "../components/SkeletonLoader"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; const ICON_CHOICES = ["👥", "🏠", "🎉", "🧳", "🍽️", "🚗", "🏖️", "🎮", "💼"]; const GroupSettingsScreen = ({ route, navigation }) => { const { groupId } = route.params; - const { token, user } = useContext(AuthContext); + const { user } = useContext(AuthContext); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [members, setMembers] = useState([]); const [group, setGroup] = useState(null); const [name, setName] = useState(""); const [icon, setIcon] = useState(""); - const [pickedImage, setPickedImage] = useState(null); // { uri, base64 } + const [pickedImage, setPickedImage] = useState(null); + const opacity = useSharedValue(0); - const isAdmin = useMemo(() => { - const me = members.find((m) => m.userId === user?._id); - return me?.role === "admin"; - }, [members, user?._id]); + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + const isAdmin = useMemo( + () => members.find((m) => m.userId === user?._id)?.role === "admin", + [members, user?._id] + ); const load = async () => { try { @@ -62,143 +71,71 @@ const GroupSettingsScreen = ({ route, navigation }) => { ]); setGroup(gRes.data); setName(gRes.data.name); - setIcon(gRes.data.imageUrl || gRes.data.icon || ""); + setIcon(gRes.data.imageUrl || ""); setMembers(mRes.data); } catch (e) { - console.error("Failed to load group settings", e); Alert.alert("Error", "Failed to load group settings."); } finally { setLoading(false); + opacity.value = withTiming(1, { duration: 500 }); } }; useEffect(() => { - if (token && groupId) load(); - }, [token, groupId]); - - useLayoutEffect(() => { - navigation.setOptions({ title: "Group Settings" }); - }, [navigation]); + if (groupId) load(); + }, [groupId]); const onSave = async () => { if (!isAdmin) return; const updates = {}; if (name && name !== group?.name) updates.name = name; - - // Handle different icon types if (pickedImage?.base64) { - // If user picked an image, use it as imageUrl updates.imageUrl = `data:image/jpeg;base64,${pickedImage.base64}`; - } else if (icon && icon !== (group?.imageUrl || group?.icon || "")) { - // If user selected an emoji and it's different from current - // Check if it's an emoji (not a URL) - const isEmoji = ICON_CHOICES.includes(icon); - if (isEmoji) { - updates.imageUrl = icon; // Store emoji as imageUrl for now - } else { - updates.imageUrl = icon; // Store other text/URL as imageUrl - } + } else if (icon !== group?.imageUrl) { + updates.imageUrl = icon; } - if (Object.keys(updates).length === 0) - return Alert.alert("Nothing to update"); + if (Object.keys(updates).length === 0) return; try { setSaving(true); const res = await apiUpdateGroup(groupId, updates); setGroup(res.data); if (pickedImage) setPickedImage(null); - Alert.alert("Updated", "Group updated successfully."); + Alert.alert("Success", "Group updated successfully."); } catch (e) { - console.error("Update failed", e); - Alert.alert( - "Error", - e.response?.data?.detail || "Failed to update group" - ); + Alert.alert("Error", e.response?.data?.detail || "Failed to update."); } finally { setSaving(false); } }; - const pickImage = async () => { - if (!isAdmin) return; - // Ask permissions - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== "granted") { - Alert.alert( - "Permission required", - "We need media library permission to select an image." - ); - return; - } - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - base64: true, - allowsEditing: true, - aspect: [1, 1], - quality: 0.8, - }); - if (!result.canceled && result.assets && result.assets.length > 0) { - const asset = result.assets[0]; - setPickedImage({ uri: asset.uri, base64: asset.base64 }); - } - }; - - const onShareInvite = async () => { - try { - const code = group?.joinCode; - if (!code) return; - await Share.share({ - message: `Join my group on Splitwiser! Use code ${code}`, - }); - } catch (e) { - console.error("Share failed", e); - } - }; - - const onKick = (memberId, name) => { - if (!isAdmin) return; - if (memberId === user?._id) return; // safeguard - Alert.alert("Remove member", `Are you sure you want to remove ${name}?`, [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: async () => { - try { - // Pre-check balances using optimized settlements - const settlementsRes = await getOptimizedSettlements(groupId); - const settlements = - settlementsRes?.data?.optimizedSettlements || []; - const hasUnsettled = settlements.some( - (s) => - (s.fromUserId === memberId || s.toUserId === memberId) && - (s.amount || 0) > 0 - ); - if (hasUnsettled) { - Alert.alert( - "Cannot remove", - "This member has unsettled balances in the group." - ); - return; + const onKick = (memberId, memberName) => { + Alert.alert( + "Kick Member", + `Are you sure you want to kick ${memberName} from the group?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Kick", + style: "destructive", + onPress: async () => { + try { + await apiRemoveMember(groupId, memberId); + setMembers(members.filter((m) => m.userId !== memberId)); + Alert.alert("Success", `${memberName} has been kicked.`); + } catch (error) { + Alert.alert("Error", "Failed to kick member."); } - await apiRemoveMember(groupId, memberId); - await load(); - } catch (e) { - console.error("Remove failed", e); - Alert.alert( - "Error", - e.response?.data?.detail || "Failed to remove member" - ); - } + }, }, - }, - ]); + ] + ); }; const onLeave = () => { Alert.alert( - "Leave group", - "You can leave only when your balances are settled. Continue?", + "Leave Group", + "Are you sure you want to leave this group?", [ { text: "Cancel", style: "cancel" }, { @@ -207,14 +144,9 @@ const GroupSettingsScreen = ({ route, navigation }) => { onPress: async () => { try { await apiLeaveGroup(groupId); - Alert.alert("Left group"); navigation.popToTop(); - } catch (e) { - console.error("Leave failed", e); - Alert.alert( - "Cannot leave", - e.response?.data?.detail || "Please settle balances first" - ); + } catch (error) { + Alert.alert("Error", "Failed to leave group."); } }, }, @@ -222,20 +154,36 @@ const GroupSettingsScreen = ({ route, navigation }) => { ); }; - const onDeleteGroup = () => { + const onShare = async () => { + try { + await Share.share({ + message: `Join my group on MySplitApp! Use this code: ${group?.joinCode}`, + }); + } catch (error) { + Alert.alert("Error", "Failed to share invite code."); + } + }; + + const pickImage = async () => { if (!isAdmin) return; - // Only allow delete if no other members present - const others = members.filter((m) => m.userId !== user?._id); - if (others.length > 0) { - Alert.alert( - "Cannot delete", - "Remove all members first, or transfer admin." - ); - return; + let result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 1, + base64: true, + }); + + if (!result.canceled) { + setPickedImage(result.assets[0]); + setIcon(""); // Clear emoji icon when image is picked } + }; + + const onDeleteGroup = () => { Alert.alert( - "Delete group", - "This will permanently delete the group. Continue?", + "Delete Group", + "Are you sure you want to delete this group? This action is irreversible.", [ { text: "Cancel", style: "cancel" }, { @@ -244,14 +192,9 @@ const GroupSettingsScreen = ({ route, navigation }) => { onPress: async () => { try { await apiDeleteGroup(groupId); - Alert.alert("Group deleted"); navigation.popToTop(); - } catch (e) { - console.error("Delete failed", e); - Alert.alert( - "Error", - e.response?.data?.detail || "Failed to delete group" - ); + } catch (error) { + Alert.alert("Error", "Failed to delete group."); } }, }, @@ -261,166 +204,255 @@ const GroupSettingsScreen = ({ route, navigation }) => { const renderMemberItem = (m) => { const isSelf = m.userId === user?._id; - const displayName = m.user?.name || "Unknown"; - const imageUrl = m.user?.imageUrl; return ( - - imageUrl ? ( - - ) : ( - - ) - } - right={() => - isAdmin && !isSelf ? ( - onKick(m.userId, displayName)} - /> - ) : null - } - /> + + + + {m.user?.name || "Unknown"} + {m.role === "admin" && ( + Admin + )} + + {isAdmin && !isSelf && ( + onKick(m.userId, m.user?.name)} + /> + )} + ); }; if (loading) { return ( - - + + + navigation.goBack()} color={colors.white} /> + + + ); } return ( - + + + navigation.goBack()} + color={colors.white} + /> + + - - - - - Icon - - {ICON_CHOICES.map((i) => ( - - ))} - - - - {pickedImage?.uri ? ( - - ) : group?.imageUrl && - /^(https?:|data:image)/.test(group.imageUrl) ? ( - - ) : group?.imageUrl ? ( - {group.imageUrl} - ) : null} - - {isAdmin && ( - - )} - - + {i} + + ))} + + + {isAdmin && ( + + )} + - - - {members.map(renderMemberItem)} - + + Members + {members.map(renderMemberItem)} + - - - - - Join Code: {group?.joinCode} - + + Invite + + Join Code: {group?.joinCode} - - + + - - - - - - {isAdmin && ( - - )} - - - + + + Danger Zone + + + {isAdmin && ( + + )} + - + ); }; +const GroupSettingsSkeleton = () => ( + + + + + + + {Array.from({ length: 9 }).map((_, i) => ( + + ))} + + + + + + + + + + +); + const styles = StyleSheet.create({ - container: { flex: 1 }, - scrollContent: { padding: 16 }, - loaderContainer: { flex: 1, justifyContent: "center", alignItems: "center" }, - card: { marginBottom: 16 }, - iconRow: { flexDirection: "row", flexWrap: "wrap", marginBottom: 8 }, - iconBtn: { marginRight: 8, marginBottom: 8 }, + container: { flex: 1, backgroundColor: colors.secondary }, + scrollContent: { padding: spacing.md }, + loaderContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: colors.secondary, + }, + card: { + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + marginBottom: spacing.md, + }, + cardTitle: { + ...typography.h3, + marginBottom: spacing.md, + color: colors.text, + }, + input: { + marginBottom: spacing.md, + backgroundColor: colors.white, + }, + label: { + ...typography.body, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + iconRow: { + flexDirection: "row", + flexWrap: "wrap", + marginBottom: spacing.md, + }, + iconBtn: { + padding: spacing.sm, + borderRadius: spacing.sm, + borderWidth: 1, + borderColor: colors.primary, + marginRight: spacing.sm, + marginBottom: spacing.sm, + }, + imageButton: { + borderColor: colors.primary, + }, + saveButton: { + marginTop: spacing.md, + backgroundColor: colors.primary, + }, + memberItem: { + flexDirection: "row", + alignItems: "center", + paddingVertical: spacing.sm, + }, + memberDetails: { + flex: 1, + marginLeft: spacing.md, + }, + memberName: { + ...typography.body, + fontWeight: "bold", + }, + memberRole: { + ...typography.caption, + color: colors.textSecondary, + }, + inviteContent: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + joinCode: { + ...typography.body, + color: colors.text, + }, }); export default GroupSettingsScreen; diff --git a/frontend/screens/HomeScreen.js b/frontend/screens/HomeScreen.js index dfb0eadd..664a03f0 100644 --- a/frontend/screens/HomeScreen.js +++ b/frontend/screens/HomeScreen.js @@ -1,27 +1,45 @@ -import { useContext, useEffect, useState } from "react"; -import { Alert, FlatList, StyleSheet, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { + Alert, + FlatList, + LayoutAnimation, + Platform, + StyleSheet, + TouchableOpacity, + UIManager, + View, +} from "react-native"; import { ActivityIndicator, Appbar, Avatar, Button, - Card, Modal, Portal, Text, TextInput, } from "react-native-paper"; import { createGroup, getGroups, getOptimizedSettlements } from "../api/groups"; +import SkeletonLoader from "../components/SkeletonLoader"; import { AuthContext } from "../context/AuthContext"; -import { formatCurrency, getCurrencySymbol } from "../utils/currency"; +import { colors, spacing, typography } from "../styles/theme"; +import { formatCurrency } from "../utils/currency"; + +if ( + Platform.OS === "android" && + UIManager.setLayoutAnimationEnabledExperimental +) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} const HomeScreen = ({ navigation }) => { - const { token, logout, user } = useContext(AuthContext); - const [groups, setGroups] = useState([]); + const { token, user } = useContext(AuthContext); + const [activeGroups, setActiveGroups] = useState([]); + const [settledGroups, setSettledGroups] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [groupSettlements, setGroupSettlements] = useState({}); // Track settlement status for each group + const [isSettledExpanded, setIsSettledExpanded] = useState(false); - // State for the Create Group modal const [modalVisible, setModalVisible] = useState(false); const [newGroupName, setNewGroupName] = useState(""); const [isCreatingGroup, setIsCreatingGroup] = useState(false); @@ -29,63 +47,28 @@ const HomeScreen = ({ navigation }) => { const showModal = () => setModalVisible(true); const hideModal = () => setModalVisible(false); - // Calculate settlement status for a group - const calculateSettlementStatus = async (groupId, userId) => { - try { - const response = await getOptimizedSettlements(groupId); - const settlements = response.data.optimizedSettlements || []; - - // Check if user has any pending settlements - const userOwes = settlements.filter((s) => s.fromUserId === userId); - const userIsOwed = settlements.filter((s) => s.toUserId === userId); - - const totalOwed = userOwes.reduce((sum, s) => sum + (s.amount || 0), 0); - const totalToReceive = userIsOwed.reduce( - (sum, s) => sum + (s.amount || 0), - 0 - ); - - return { - isSettled: totalOwed === 0 && totalToReceive === 0, - owesAmount: totalOwed, - owedAmount: totalToReceive, - netBalance: totalToReceive - totalOwed, - }; - } catch (error) { - console.error( - "Failed to fetch settlement status for group:", - groupId, - error - ); - return { - isSettled: true, - owesAmount: 0, - owedAmount: 0, - netBalance: 0, - }; - } - }; - const fetchGroups = async () => { try { setIsLoading(true); const response = await getGroups(); const groupsList = response.data.groups; - setGroups(groupsList); - // Fetch settlement status for each group if (user?._id) { const settlementPromises = groupsList.map(async (group) => { const status = await calculateSettlementStatus(group._id, user._id); - return { groupId: group._id, status }; + return { ...group, settlementStatus: status }; }); + const groupsWithSettlements = await Promise.all(settlementPromises); - const settlementResults = await Promise.all(settlementPromises); - const settlementMap = {}; - settlementResults.forEach(({ groupId, status }) => { - settlementMap[groupId] = status; - }); - setGroupSettlements(settlementMap); + const active = groupsWithSettlements.filter( + (g) => !g.settlementStatus.isSettled + ); + const settled = groupsWithSettlements.filter( + (g) => g.settlementStatus.isSettled + ); + + setActiveGroups(active); + setSettledGroups(settled); } } catch (error) { console.error("Failed to fetch groups:", error); @@ -95,6 +78,23 @@ const HomeScreen = ({ navigation }) => { } }; + const calculateSettlementStatus = async (groupId, userId) => { + try { + const response = await getOptimizedSettlements(groupId); + const settlements = response.data.optimizedSettlements || []; + const userOwes = settlements.filter((s) => s.fromUserId === userId); + const userIsOwed = settlements.filter((s) => s.toUserId === userId); + return { + isSettled: userOwes.length === 0 && userIsOwed.length === 0, + netBalance: + userIsOwed.reduce((sum, s) => sum + s.amount, 0) - + userOwes.reduce((sum, s) => sum + s.amount, 0), + }; + } catch (error) { + return { isSettled: true, netBalance: 0 }; + } + }; + useEffect(() => { if (token) { fetchGroups(); @@ -111,7 +111,7 @@ const HomeScreen = ({ navigation }) => { await createGroup(newGroupName); hideModal(); setNewGroupName(""); - await fetchGroups(); // Refresh the groups list + await fetchGroups(); } catch (error) { console.error("Failed to create group:", error); Alert.alert("Error", "Failed to create group."); @@ -120,77 +120,111 @@ const HomeScreen = ({ navigation }) => { } }; - const currencySymbol = getCurrencySymbol(); - - const renderGroup = ({ item }) => { - const settlementStatus = groupSettlements[item._id]; - - // Generate settlement status text - const getSettlementStatusText = () => { - if (!settlementStatus) { - return "Calculating balances..."; - } - - if (settlementStatus.isSettled) { - return "✓ You are settled up."; - } - - if (settlementStatus.netBalance > 0) { - return `You are owed ${formatCurrency(settlementStatus.netBalance)}.`; - } else if (settlementStatus.netBalance < 0) { - return `You owe ${formatCurrency( - Math.abs(settlementStatus.netBalance) - )}.`; - } + const toggleSettledGroups = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setIsSettledExpanded(!isSettledExpanded); + }; - return "You are settled up."; - }; + const activeGroupRows = useMemo(() => { + const rows = []; + for (let i = 0; i < activeGroups.length; i += 2) { + rows.push(activeGroups.slice(i, i + 2)); + } + return rows; + }, [activeGroups]); - // Get text color based on settlement status - const getStatusColor = () => { - if (!settlementStatus || settlementStatus.isSettled) { - return "#4CAF50"; // Green for settled - } + const renderGroupRow = ({ item: row }) => ( + + {row.map((item) => ( + + navigation.navigate("GroupDetails", { + groupId: item._id, + groupName: item.name, + groupIcon: item.imageUrl || item.name?.charAt(0) || "?", + }) + } + > + {/^(https?:|data:image)/.test(item.imageUrl) ? ( + + ) : ( + + )} + + {item.name} + + 0 + ? colors.success + : colors.error, + }, + ]} + > + {item.settlementStatus.netBalance > 0 + ? `You are owed ${formatCurrency( + item.settlementStatus.netBalance + )}` + : `You owe ${formatCurrency( + Math.abs(item.settlementStatus.netBalance) + )}`} + + + ))} + + ); - if (settlementStatus.netBalance > 0) { - return "#4CAF50"; // Green for being owed money - } else if (settlementStatus.netBalance < 0) { - return "#F44336"; // Red for owing money + const renderSettledGroup = ({ item }) => ( + + navigation.navigate("GroupDetails", { + groupId: item._id, + groupName: item.name, + }) } + > + {item.name} + + + ); - return "#4CAF50"; // Default green - }; + const renderSettledGroupsExpander = () => { + if (settledGroups.length === 0) return null; - const isImage = - item.imageUrl && /^(https?:|data:image)/.test(item.imageUrl); - const groupIcon = item.imageUrl || item.name?.charAt(0) || "?"; return ( - - navigation.navigate("GroupDetails", { - groupId: item._id, - groupName: item.name, - groupIcon, - }) - } - > - - isImage ? ( - - ) : ( - - ) - } - /> - - - {getSettlementStatusText()} - - - + + + Settled Groups + + + {isSettledExpanded && ( + {settledGroups.map((item) => renderSettledGroup({ item }))} + )} + ); }; @@ -208,23 +242,31 @@ const HomeScreen = ({ navigation }) => { value={newGroupName} onChangeText={setNewGroupName} style={styles.input} + theme={{ colors: { primary: colors.accent } }} /> - - - + + + navigation.navigate("JoinGroup", { onGroupJoined: fetchGroups }) } @@ -233,19 +275,35 @@ const HomeScreen = ({ navigation }) => { {isLoading ? ( - + + + + + + + + + + + + ) : ( item._id} + data={activeGroupRows} + renderItem={renderGroupRow} + keyExtractor={(item, index) => `row-${index}`} contentContainerStyle={styles.list} ListEmptyComponent={ - - No groups found. Create or join one! - + !isLoading && ( + + + No active groups. Create or join one! + + + ) } + ListFooterComponent={renderSettledGroupsExpander} onRefresh={fetchGroups} refreshing={isLoading} /> @@ -254,42 +312,112 @@ const HomeScreen = ({ navigation }) => { ); }; +const GroupCardSkeleton = () => ( + + + + + +); + const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, + }, + row: { + flexDirection: "row", + justifyContent: "space-between", }, loaderContainer: { flex: 1, - justifyContent: "center", - alignItems: "center", + padding: spacing.md, }, list: { - padding: 16, + padding: spacing.md, }, card: { - marginBottom: 16, + flex: 1, + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + margin: spacing.sm, + alignItems: "center", + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + avatar: { + marginBottom: spacing.md, + backgroundColor: colors.primary, + }, + groupName: { + ...typography.h3, + color: colors.text, + textAlign: "center", + marginBottom: spacing.xs, }, settlementStatus: { - fontWeight: "500", - marginTop: 4, + ...typography.body, + textAlign: "center", + }, + settledCard: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: colors.white, + borderRadius: spacing.sm, + padding: spacing.md, + marginBottom: spacing.sm, + }, + settledGroupName: { + ...typography.body, + color: colors.text, + }, + expanderHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: spacing.md, + marginTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.secondary, + }, + expanderTitle: { + ...typography.h3, + color: colors.textSecondary, + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + marginTop: 100, + width: "100%", }, emptyText: { - textAlign: "center", - marginTop: 20, + ...typography.body, + color: colors.textSecondary, }, modalContainer: { - backgroundColor: "white", - padding: 20, - margin: 20, - borderRadius: 8, + backgroundColor: colors.white, + padding: spacing.lg, + margin: spacing.lg, + borderRadius: spacing.sm, }, modalTitle: { - fontSize: 20, - marginBottom: 20, + ...typography.h2, + color: colors.text, textAlign: "center", + marginBottom: spacing.lg, }, input: { - marginBottom: 20, + marginBottom: spacing.lg, + backgroundColor: colors.secondary, + }, + createButton: { + backgroundColor: colors.primary, }, }); diff --git a/frontend/screens/JoinGroupScreen.js b/frontend/screens/JoinGroupScreen.js index 153e4eac..49386077 100644 --- a/frontend/screens/JoinGroupScreen.js +++ b/frontend/screens/JoinGroupScreen.js @@ -1,8 +1,9 @@ import { useContext, useState } from "react"; -import { Alert, StyleSheet, View } from "react-native"; -import { Appbar, Button, TextInput, Title } from "react-native-paper"; +import { Alert, StyleSheet, View, Text } from "react-native"; +import { Appbar, Button, TextInput } from "react-native-paper"; import { joinGroup } from "../api/groups"; import { AuthContext } from "../context/AuthContext"; +import { colors, spacing, typography } from "../styles/theme"; const JoinGroupScreen = ({ navigation, route }) => { const { token } = useContext(AuthContext); @@ -19,7 +20,7 @@ const JoinGroupScreen = ({ navigation, route }) => { try { await joinGroup(joinCode); Alert.alert("Success", "Successfully joined the group."); - onGroupJoined(); // Call the callback to refresh the groups list + onGroupJoined(); navigation.goBack(); } catch (error) { console.error("Failed to join group:", error); @@ -34,18 +35,26 @@ const JoinGroupScreen = ({ navigation, route }) => { return ( - - navigation.goBack()} /> - + + navigation.goBack()} + color={colors.white} + /> + - Enter Group Code + Enter Group Code @@ -64,15 +74,30 @@ const JoinGroupScreen = ({ navigation, route }) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.secondary, }, content: { - padding: 16, + padding: spacing.lg, + }, + title: { + ...typography.h2, + color: colors.text, + marginBottom: spacing.lg, + textAlign: "center", }, input: { - marginBottom: 16, + marginBottom: spacing.lg, + backgroundColor: colors.white, }, button: { - marginTop: 8, + marginTop: spacing.md, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: "bold", }, }); diff --git a/frontend/screens/LoginScreen.js b/frontend/screens/LoginScreen.js index 076e9956..ce97b4bf 100644 --- a/frontend/screens/LoginScreen.js +++ b/frontend/screens/LoginScreen.js @@ -1,7 +1,8 @@ import React, { useState, useContext } from 'react'; -import { View, StyleSheet, Alert } from 'react-native'; -import { Button, Text, TextInput } from 'react-native-paper'; +import { View, StyleSheet, Alert, Text } from 'react-native'; +import { Button, TextInput } from 'react-native-paper'; import { AuthContext } from '../context/AuthContext'; +import { colors, spacing, typography } from '../styles/theme'; const LoginScreen = ({ navigation }) => { const [email, setEmail] = useState(''); @@ -24,7 +25,7 @@ const LoginScreen = ({ navigation }) => { return ( - Welcome Back! + Welcome Back! { style={styles.input} keyboardType="email-address" autoCapitalize="none" + theme={{ colors: { primary: colors.accent } }} /> { onChangeText={setPassword} style={styles.input} secureTextEntry + theme={{ colors: { primary: colors.accent } }} /> - @@ -60,17 +68,34 @@ const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', - padding: 16, + padding: spacing.lg, + backgroundColor: colors.secondary, }, title: { + ...typography.h1, + color: colors.primary, textAlign: 'center', - marginBottom: 24, + marginBottom: spacing.xl, }, input: { - marginBottom: 16, + marginBottom: spacing.md, + backgroundColor: colors.white, }, button: { - marginTop: 8, + marginTop: spacing.md, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: 'bold', + }, + signupButton: { + marginTop: spacing.md, + }, + signupButtonLabel: { + color: colors.primary, }, }); diff --git a/frontend/screens/SignupScreen.js b/frontend/screens/SignupScreen.js index 5594be60..b1e3feeb 100644 --- a/frontend/screens/SignupScreen.js +++ b/frontend/screens/SignupScreen.js @@ -1,7 +1,8 @@ import React, { useState, useContext } from 'react'; -import { View, StyleSheet, Alert } from 'react-native'; -import { Button, Text, TextInput } from 'react-native-paper'; +import { View, StyleSheet, Alert, Text } from 'react-native'; +import { Button, TextInput } from 'react-native-paper'; import { AuthContext } from '../context/AuthContext'; +import { colors, spacing, typography } from '../styles/theme'; const SignupScreen = ({ navigation }) => { const [name, setName] = useState(''); @@ -36,13 +37,14 @@ const SignupScreen = ({ navigation }) => { return ( - Create Account + Create Account { style={styles.input} keyboardType="email-address" autoCapitalize="none" + theme={{ colors: { primary: colors.accent } }} /> { onChangeText={setPassword} style={styles.input} secureTextEntry + theme={{ colors: { primary: colors.accent } }} /> { onChangeText={setConfirmPassword} style={styles.input} secureTextEntry + theme={{ colors: { primary: colors.accent } }} /> - @@ -86,17 +97,34 @@ const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', - padding: 16, + padding: spacing.lg, + backgroundColor: colors.secondary, }, title: { + ...typography.h1, + color: colors.primary, textAlign: 'center', - marginBottom: 24, + marginBottom: spacing.xl, }, input: { - marginBottom: 16, + marginBottom: spacing.md, + backgroundColor: colors.white, }, button: { - marginTop: 8, + marginTop: spacing.md, + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + }, + buttonLabel: { + ...typography.body, + color: colors.white, + fontWeight: 'bold', + }, + loginButton: { + marginTop: spacing.md, + }, + loginButtonLabel: { + color: colors.primary, }, }); diff --git a/frontend/styles/theme.js b/frontend/styles/theme.js new file mode 100644 index 00000000..28d28c3b --- /dev/null +++ b/frontend/styles/theme.js @@ -0,0 +1,42 @@ +export const colors = { + primary: "#1E3A8A", + secondary: "#F3F4F6", + accent: "#3B82F6", + text: "#111827", + textSecondary: "#6B7280", + success: "#10B981", + error: "#EF4444", + white: "#FFFFFF", + black: "#000000", +}; + +export const typography = { + h1: { + fontSize: 32, + fontWeight: "bold", + }, + h2: { + fontSize: 24, + fontWeight: "bold", + }, + h3: { + fontSize: 20, + fontWeight: "bold", + }, + body: { + fontSize: 16, + fontWeight: "normal", + }, + caption: { + fontSize: 12, + fontWeight: "normal", + }, +}; + +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, +};