|
1 | 1 | import streamlit as st |
2 | 2 | import requests |
| 3 | +from datetime import datetime |
| 4 | +import json |
| 5 | +import time |
| 6 | +from collections import defaultdict |
3 | 7 |
|
4 | 8 | # Base URL for API |
5 | 9 | API_URL = "https://splitwiser-production.up.railway.app" |
6 | 10 |
|
| 11 | +# Helper function to retry API calls |
| 12 | +def make_api_request(method, url, headers=None, json_data=None, max_retries=3, retry_delay=1): |
| 13 | + retries = 0 |
| 14 | + while retries < max_retries: |
| 15 | + try: |
| 16 | + if method.lower() == 'get': |
| 17 | + response = requests.get(url, headers=headers) |
| 18 | + elif method.lower() == 'post': |
| 19 | + response = requests.post(url, headers=headers, json=json_data) |
| 20 | + else: |
| 21 | + raise ValueError(f"Unsupported method: {method}") |
| 22 | + |
| 23 | + return response |
| 24 | + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: |
| 25 | + retries += 1 |
| 26 | + if retries < max_retries: |
| 27 | + if st.session_state.debug_mode: |
| 28 | + st.sidebar.warning(f"API request failed, retrying ({retries}/{max_retries}): {e}") |
| 29 | + time.sleep(retry_delay) |
| 30 | + else: |
| 31 | + raise |
| 32 | + |
| 33 | + return None |
| 34 | + |
7 | 35 | st.title("Friends") |
8 | 36 |
|
| 37 | +# Debug mode toggle |
| 38 | +if "debug_mode" not in st.session_state: |
| 39 | + st.session_state.debug_mode = False |
| 40 | + |
| 41 | +# Connection status and refresh button in sidebar |
| 42 | +with st.sidebar: |
| 43 | + st.session_state.debug_mode = st.checkbox("Debug Mode", value=st.session_state.debug_mode) |
| 44 | + |
| 45 | + # Connection status check |
| 46 | + try: |
| 47 | + status_response = requests.get(f"{API_URL}/health", timeout=3) |
| 48 | + if status_response.status_code == 200: |
| 49 | + st.success("API Connection: Online") |
| 50 | + else: |
| 51 | + st.error(f"API Connection: Error (Status {status_response.status_code})") |
| 52 | + except Exception as e: |
| 53 | + st.error(f"API Connection: Offline") |
| 54 | + if st.session_state.debug_mode: |
| 55 | + st.write(f"Error: {str(e)}") |
| 56 | + |
| 57 | +# Refresh button |
| 58 | +refresh_col1, refresh_col2 = st.columns([8, 1]) |
| 59 | +with refresh_col2: |
| 60 | + if st.button("🔄"): |
| 61 | + st.rerun() |
| 62 | + |
9 | 63 | # Check if user is logged in |
10 | 64 | if "access_token" not in st.session_state or not st.session_state.access_token: |
11 | 65 | st.warning("Please log in first") |
12 | 66 | st.stop() |
13 | 67 |
|
14 | | -st.info("Friends feature is coming soon!") |
| 68 | +# Function to fetch user's groups |
| 69 | +def fetch_user_groups(): |
| 70 | + try: |
| 71 | + headers = {"Authorization": f"Bearer {st.session_state.access_token}"} |
| 72 | + response = make_api_request( |
| 73 | + 'get', |
| 74 | + f"{API_URL}/groups", |
| 75 | + headers=headers |
| 76 | + ) |
| 77 | + |
| 78 | + if st.session_state.debug_mode: |
| 79 | + st.sidebar.subheader("Debug: /groups response") |
| 80 | + st.sidebar.write(f"Status Code: {response.status_code}") |
| 81 | + try: |
| 82 | + st.sidebar.json(response.json()) |
| 83 | + except: |
| 84 | + st.sidebar.write("Failed to parse response as JSON") |
| 85 | + st.sidebar.text(response.text) |
| 86 | + |
| 87 | + if response.status_code == 200: |
| 88 | + data = response.json() |
| 89 | + if 'groups' in data: |
| 90 | + return data['groups'] |
| 91 | + else: |
| 92 | + st.error(f"Unexpected response format: 'groups' key not found in response") |
| 93 | + return [] |
| 94 | + else: |
| 95 | + st.error(f"Failed to fetch groups: {response.status_code}") |
| 96 | + return [] |
| 97 | + except Exception as e: |
| 98 | + st.error(f"Error fetching groups: {str(e)}") |
| 99 | + return [] |
| 100 | + |
| 101 | +# Function to fetch group members |
| 102 | +def fetch_group_members(group_id): |
| 103 | + try: |
| 104 | + headers = {"Authorization": f"Bearer {st.session_state.access_token}"} |
| 105 | + response = make_api_request( |
| 106 | + 'get', |
| 107 | + f"{API_URL}/groups/{group_id}/members", |
| 108 | + headers=headers |
| 109 | + ) |
| 110 | + |
| 111 | + if response.status_code == 200: |
| 112 | + return response.json() |
| 113 | + else: |
| 114 | + return [] |
| 115 | + except Exception as e: |
| 116 | + if st.session_state.debug_mode: |
| 117 | + st.error(f"Error fetching members for group {group_id}: {str(e)}") |
| 118 | + return [] |
| 119 | + |
| 120 | +# Function to fetch group expenses |
| 121 | +def fetch_group_expenses(group_id): |
| 122 | + try: |
| 123 | + headers = {"Authorization": f"Bearer {st.session_state.access_token}"} |
| 124 | + response = make_api_request( |
| 125 | + 'get', |
| 126 | + f"{API_URL}/groups/{group_id}/expenses", |
| 127 | + headers=headers |
| 128 | + ) |
| 129 | + |
| 130 | + if response.status_code == 200: |
| 131 | + data = response.json() |
| 132 | + if 'expenses' in data: |
| 133 | + return data['expenses'] |
| 134 | + else: |
| 135 | + return [] |
| 136 | + else: |
| 137 | + return [] |
| 138 | + except Exception as e: |
| 139 | + if st.session_state.debug_mode: |
| 140 | + st.error(f"Error fetching expenses for group {group_id}: {str(e)}") |
| 141 | + return [] |
| 142 | + |
| 143 | +# Function to calculate net balance between current user and a friend across all groups |
| 144 | +def calculate_friend_balance(current_user_id, friend_user_id, groups, all_members, all_expenses): |
| 145 | + net_balance = 0.0 |
| 146 | + shared_groups = [] |
| 147 | + |
| 148 | + for group in groups: |
| 149 | + group_id = group.get('_id') |
| 150 | + |
| 151 | + # Check if both users are in this group |
| 152 | + group_members = all_members.get(group_id, []) |
| 153 | + group_member_ids = [m.get('userId') for m in group_members] |
| 154 | + |
| 155 | + if current_user_id in group_member_ids and friend_user_id in group_member_ids: |
| 156 | + shared_groups.append(group.get('name')) |
| 157 | + |
| 158 | + # Get expenses for this group |
| 159 | + group_expenses = all_expenses.get(group_id, []) |
| 160 | + |
| 161 | + for expense in group_expenses: |
| 162 | + splits = expense.get('splits', []) |
| 163 | + payer_id = expense.get('createdBy') |
| 164 | + total_amount = expense.get('amount', 0) |
| 165 | + |
| 166 | + # Find splits for current user and friend |
| 167 | + current_user_split = next((s for s in splits if s.get('userId') == current_user_id), None) |
| 168 | + friend_split = next((s for s in splits if s.get('userId') == friend_user_id), None) |
| 169 | + |
| 170 | + if current_user_split or friend_split: |
| 171 | + current_user_owes = current_user_split.get('amount', 0) if current_user_split else 0 |
| 172 | + friend_owes = friend_split.get('amount', 0) if friend_split else 0 |
| 173 | + |
| 174 | + current_user_paid = total_amount if payer_id == current_user_id else 0 |
| 175 | + friend_paid = total_amount if payer_id == friend_user_id else 0 |
| 176 | + |
| 177 | + # Calculate net effect on balance between these two users |
| 178 | + # If current user paid for friend's share: positive balance (friend owes current user) |
| 179 | + # If friend paid for current user's share: negative balance (current user owes friend) |
| 180 | + |
| 181 | + if payer_id == current_user_id and friend_split: |
| 182 | + # Current user paid, friend owes |
| 183 | + net_balance += friend_owes |
| 184 | + |
| 185 | + if payer_id == friend_user_id and current_user_split: |
| 186 | + # Friend paid, current user owes |
| 187 | + net_balance -= current_user_owes |
| 188 | + |
| 189 | + return net_balance, shared_groups |
| 190 | + |
| 191 | +# Get current user ID |
| 192 | +current_user_id = st.session_state.get('user_id', None) |
| 193 | + |
| 194 | +if not current_user_id: |
| 195 | + st.error("Unable to get current user information. Please log in again.") |
| 196 | + st.stop() |
| 197 | + |
| 198 | +# Fetch all groups |
| 199 | +with st.spinner("Loading your groups and friends..."): |
| 200 | + groups = fetch_user_groups() |
| 201 | + |
| 202 | + if not groups: |
| 203 | + st.info("You haven't joined any groups yet. Join groups to see friends!") |
| 204 | + st.stop() |
| 205 | + |
| 206 | + # Fetch members and expenses for all groups |
| 207 | + all_members = {} |
| 208 | + all_expenses = {} |
| 209 | + |
| 210 | + for group in groups: |
| 211 | + group_id = group.get('_id') |
| 212 | + all_members[group_id] = fetch_group_members(group_id) |
| 213 | + all_expenses[group_id] = fetch_group_expenses(group_id) |
| 214 | + |
| 215 | +# Find all unique friends (users who share at least one group) |
| 216 | +friends_data = {} |
| 217 | +group_names_map = {g.get('_id'): g.get('name') for g in groups} |
| 218 | + |
| 219 | +for group in groups: |
| 220 | + group_id = group.get('_id') |
| 221 | + group_members = all_members.get(group_id, []) |
| 222 | + |
| 223 | + for member in group_members: |
| 224 | + user_id = member.get('userId') |
| 225 | + user_info = member.get('user', {}) |
| 226 | + user_name = user_info.get('name', 'Unknown User') |
| 227 | + |
| 228 | + # Skip current user |
| 229 | + if user_id == current_user_id: |
| 230 | + continue |
| 231 | + |
| 232 | + if user_id not in friends_data: |
| 233 | + friends_data[user_id] = { |
| 234 | + 'name': user_name, |
| 235 | + 'shared_groups': set(), |
| 236 | + 'balance': 0.0 |
| 237 | + } |
| 238 | + |
| 239 | + friends_data[user_id]['shared_groups'].add(group_names_map[group_id]) |
| 240 | + |
| 241 | +# Calculate balances for each friend |
| 242 | +for friend_id in friends_data: |
| 243 | + balance, shared_groups = calculate_friend_balance( |
| 244 | + current_user_id, |
| 245 | + friend_id, |
| 246 | + groups, |
| 247 | + all_members, |
| 248 | + all_expenses |
| 249 | + ) |
| 250 | + friends_data[friend_id]['balance'] = balance |
| 251 | + |
| 252 | +# Display friends |
| 253 | +if friends_data: |
| 254 | + st.subheader(f"Your Friends ({len(friends_data)})") |
| 255 | + st.caption("People you share groups with") |
| 256 | + |
| 257 | + # Sort friends by absolute balance (highest debts/credits first) |
| 258 | + sorted_friends = sorted( |
| 259 | + friends_data.items(), |
| 260 | + key=lambda x: abs(x[1]['balance']), |
| 261 | + reverse=True |
| 262 | + ) |
| 263 | + |
| 264 | + # Summary cards |
| 265 | + total_owed_to_you = sum(max(0, data['balance']) for data in friends_data.values()) |
| 266 | + total_you_owe = sum(abs(min(0, data['balance'])) for data in friends_data.values()) |
| 267 | + |
| 268 | + col1, col2, col3 = st.columns(3) |
| 269 | + with col1: |
| 270 | + st.metric("Friends", len(friends_data)) |
| 271 | + with col2: |
| 272 | + st.metric("You are owed", f"₹{total_owed_to_you:.2f}") |
| 273 | + with col3: |
| 274 | + st.metric("You owe", f"₹{total_you_owe:.2f}") |
| 275 | + |
| 276 | + st.divider() |
| 277 | + |
| 278 | + # Friends list |
| 279 | + for friend_id, friend_data in sorted_friends: |
| 280 | + with st.container(): |
| 281 | + col1, col2, col3 = st.columns([3, 1, 1]) |
| 282 | + |
| 283 | + with col1: |
| 284 | + st.subheader(friend_data['name']) |
| 285 | + shared_groups_text = ", ".join(sorted(friend_data['shared_groups'])) |
| 286 | + st.caption(f"Shared groups: {shared_groups_text}") |
| 287 | + |
| 288 | + with col2: |
| 289 | + balance = friend_data['balance'] |
| 290 | + if balance > 0: |
| 291 | + st.markdown(f":green[**₹{balance:.2f}**]") |
| 292 | + st.caption("owes you") |
| 293 | + elif balance < 0: |
| 294 | + st.markdown(f":red[**₹{abs(balance):.2f}**]") |
| 295 | + st.caption("you owe") |
| 296 | + else: |
| 297 | + st.markdown("**₹0.00**") |
| 298 | + st.caption("settled up") |
| 299 | + |
| 300 | + with col3: |
| 301 | + if st.button("View Details", key=f"friend_details_{friend_id}"): |
| 302 | + # Store friend details in session state for detailed view |
| 303 | + st.session_state.selected_friend = { |
| 304 | + 'id': friend_id, |
| 305 | + 'name': friend_data['name'], |
| 306 | + 'balance': balance, |
| 307 | + 'shared_groups': friend_data['shared_groups'] |
| 308 | + } |
| 309 | + st.rerun() |
| 310 | + |
| 311 | + st.divider() |
| 312 | + |
| 313 | +else: |
| 314 | + st.info("No friends found. Join groups with other people to see them here!") |
| 315 | + |
| 316 | +# Show detailed view if a friend is selected |
| 317 | +if 'selected_friend' in st.session_state: |
| 318 | + friend = st.session_state.selected_friend |
| 319 | + |
| 320 | + st.subheader(f"Details with {friend['name']}") |
| 321 | + |
| 322 | + # Clear selection button |
| 323 | + if st.button("← Back to Friends List"): |
| 324 | + del st.session_state.selected_friend |
| 325 | + st.rerun() |
| 326 | + |
| 327 | + # Show balance |
| 328 | + balance = friend['balance'] |
| 329 | + if balance > 0: |
| 330 | + st.success(f"{friend['name']} owes you ₹{balance:.2f}") |
| 331 | + elif balance < 0: |
| 332 | + st.error(f"You owe {friend['name']} ₹{abs(balance):.2f}") |
| 333 | + else: |
| 334 | + st.info(f"You and {friend['name']} are settled up!") |
| 335 | + |
| 336 | + # Show shared groups |
| 337 | + st.write("### Shared Groups") |
| 338 | + for group_name in sorted(friend['shared_groups']): |
| 339 | + st.write(f"• {group_name}") |
| 340 | + |
| 341 | + # TODO: Add detailed expense breakdown by group |
| 342 | + st.info("Detailed expense breakdown coming soon!") |
15 | 343 |
|
16 | | -# Placeholder for future functionality |
17 | | -st.subheader("Features to be added:") |
18 | | -st.markdown(""" |
19 | | -- Add friends |
20 | | -- View friend requests |
21 | | -- See shared expenses with friends |
22 | | -- Settle up with friends |
23 | | -""") |
| 344 | +# Debug information display |
| 345 | +if st.session_state.debug_mode: |
| 346 | + st.subheader("Debug Information") |
| 347 | + st.write("API URL:", API_URL) |
| 348 | + st.write("Current User ID:", current_user_id) |
| 349 | + st.write("Groups Count:", len(groups) if groups else 0) |
| 350 | + st.write("Friends Data:", friends_data if 'friends_data' in locals() else "Not loaded") |
0 commit comments