diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 000000000..c0cd3d971
Binary files /dev/null and b/.DS_Store differ
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..d87c1ae87
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+# macOS
+.DS_Store
+
+# Node / Expo
+node_modules/
+*.log
+
+# Expo temp home used during installs
+app/.tmp-home/
+backend/.tmp-home/
+
+# Environment files
+app/.env
+.env
+
+# Backend build artifacts and database
+backend/data/*.db
+backend/data/*.sqlite
+backend/.cache/
+
+# Expo / Metro caches
+.expo/
+.expo-shared/
+app/node_modules/.cache/
+
+
+.DS_Store
+.DS_Store
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 000000000..f8c6c2e83
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,43 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+# Native
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+app-example
+
+# generated native folders
+/ios
+/android
diff --git a/app/.vscode/extensions.json b/app/.vscode/extensions.json
new file mode 100644
index 000000000..b7ed83779
--- /dev/null
+++ b/app/.vscode/extensions.json
@@ -0,0 +1 @@
+{ "recommendations": ["expo.vscode-expo-tools"] }
diff --git a/app/.vscode/settings.json b/app/.vscode/settings.json
new file mode 100644
index 000000000..e2798e426
--- /dev/null
+++ b/app/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "editor.codeActionsOnSave": {
+ "source.fixAll": "explicit",
+ "source.organizeImports": "explicit",
+ "source.sortMembers": "explicit"
+ }
+}
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 000000000..48dd63ff3
--- /dev/null
+++ b/app/README.md
@@ -0,0 +1,50 @@
+# Welcome to your Expo app đ
+
+This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
+
+## Get started
+
+1. Install dependencies
+
+ ```bash
+ npm install
+ ```
+
+2. Start the app
+
+ ```bash
+ npx expo start
+ ```
+
+In the output, you'll find options to open the app in a
+
+- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
+- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
+- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
+- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
+
+You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
+
+## Get a fresh project
+
+When you're ready, run:
+
+```bash
+npm run reset-project
+```
+
+This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
+
+## Learn more
+
+To learn more about developing your project with Expo, look at the following resources:
+
+- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
+- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
+
+## Join the community
+
+Join our community of developers creating universal apps.
+
+- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
+- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
diff --git a/app/app.json b/app/app.json
new file mode 100644
index 000000000..a5a2d61fa
--- /dev/null
+++ b/app/app.json
@@ -0,0 +1,53 @@
+{
+ "expo": {
+ "name": "GreenUp",
+ "slug": "greenup",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/icon.png",
+ "scheme": "greenup",
+ "extra": {
+ "supabaseUrl": "https://oldyeaylxdnvfrzocobu.supabase.co",
+ "supabaseAnonKey": "sb_publishable_YC7cKRCzkXMqhL8tOh1vxg_1--ZPWVZ"
+ },
+ "userInterfaceStyle": "automatic",
+ "newArchEnabled": true,
+ "ios": {
+ "supportsTablet": true
+ },
+ "android": {
+ "adaptiveIcon": {
+ "backgroundColor": "#E6F4FE",
+ "foregroundImage": "./assets/images/android-icon-foreground.png",
+ "backgroundImage": "./assets/images/android-icon-background.png",
+ "monochromeImage": "./assets/images/android-icon-monochrome.png"
+ },
+ "edgeToEdgeEnabled": true,
+ "predictiveBackGestureEnabled": false
+ },
+ "web": {
+ "output": "static",
+ "favicon": "./assets/images/favicon.png"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-splash-screen",
+ {
+ "image": "./assets/images/splash-icon.png",
+ "imageWidth": 200,
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff",
+ "dark": {
+ "backgroundColor": "#000000"
+ }
+ }
+ ],
+ "expo-secure-store"
+ ],
+ "experiments": {
+ "typedRoutes": true,
+ "reactCompiler": true
+ }
+ }
+}
diff --git a/app/app/(tabs)/_layout.tsx b/app/app/(tabs)/_layout.tsx
new file mode 100644
index 000000000..c7629b5b8
--- /dev/null
+++ b/app/app/(tabs)/_layout.tsx
@@ -0,0 +1,154 @@
+import { Tabs, Link } from 'expo-router';
+import React from 'react';
+import { StyleSheet, View, TouchableOpacity, Image } from 'react-native';
+import { BlurView } from 'expo-blur';
+
+import { HapticTab } from '@/components/haptic-tab';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors } from '@/constants/theme';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+
+export default function TabLayout() {
+ const colorScheme = useColorScheme();
+
+ return (
+ (
+ <>
+
+
+ >
+ ),
+ }}>
+ (
+
+
+
+
+
+ ),
+ tabBarIcon: ({ color }) => (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+
+ );
+}
diff --git a/app/app/(tabs)/acount/_layout.tsx b/app/app/(tabs)/acount/_layout.tsx
new file mode 100644
index 000000000..4e9bb2d4b
--- /dev/null
+++ b/app/app/(tabs)/acount/_layout.tsx
@@ -0,0 +1,32 @@
+import { Stack, router } from 'expo-router';
+import React, { useEffect } from 'react';
+import { supabase } from '@/src/lib/supabase';
+import { isLoggedIn } from '@/lib/session';
+
+export default function AccountStackLayout() {
+ useEffect(() => {
+ (async () => {
+ const { data: sess } = await supabase.auth.getSession();
+ if (!sess.session) {
+ const ok = await isLoggedIn();
+ if (!ok) {
+ router.replace('/login');
+ }
+ }
+ })();
+ }, []);
+
+ return (
+
+ );
+}
+
+
diff --git a/app/app/(tabs)/acount/friend/[id].tsx b/app/app/(tabs)/acount/friend/[id].tsx
new file mode 100644
index 000000000..3ec79370d
--- /dev/null
+++ b/app/app/(tabs)/acount/friend/[id].tsx
@@ -0,0 +1,237 @@
+import React, { useEffect, useState } from 'react';
+import { View, Text, StyleSheet } from 'react-native';
+import { Stack, useLocalSearchParams } from 'expo-router';
+import { getUserById } from '@/lib/users-store';
+import { supabase } from '@/src/lib/supabase';
+import { Image } from 'expo-image';
+import {
+ DEFAULT_PROFILE_BG,
+ PROFILE_BACKGROUNDS_PORTRAIT,
+ safeBackgroundKey,
+ type ProfileBackgroundKey,
+} from '@/src/constants/profileBackgrounds';
+import { useHeaderHeight } from '@react-navigation/elements';
+
+function weeksSince(dateIso: string): number {
+ const created = new Date(dateIso).getTime();
+ const now = Date.now();
+ const diffMs = Math.max(0, now - created);
+ return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 7));
+}
+
+function formatRankLabel(rank: number | null): string {
+ if (!rank || rank < 1 || !Number.isFinite(rank)) return 'â';
+ if (rank <= 10) return `${rank}a`;
+ if (rank <= 50) return 'Top 50';
+ if (rank <= 100) return 'Top 100';
+ const bucket = Math.ceil(rank / 100) * 100;
+ return `Top ${bucket}`;
+}
+
+export default function FriendProfileScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const user = typeof id === 'string' ? getUserById(id) : undefined;
+ const [relationLabel, setRelationLabel] = useState('');
+ const [bgKey, setBgKey] = useState(DEFAULT_PROFILE_BG);
+ const headerHeight = useHeaderHeight();
+ const extraTopSpacing = 24;
+ const [channelRef, setChannelRef] = useState | null>(null);
+ const [rank, setRank] = useState(null);
+ const [rankLabel, setRankLabel] = useState('â');
+
+ async function refreshWorldRank(forUserId: string) {
+ try {
+ // Naive global ranking: sum all user_actions per user and compute index
+ const { data, error } = await supabase
+ .from('user_actions')
+ .select('user_id, co2_saved_kg');
+ if (error) {
+ setRank(null);
+ setRankLabel('â');
+ return;
+ }
+ const totals = new Map();
+ for (const row of (data as any[]) ?? []) {
+ const uid = String((row as any).user_id ?? '');
+ const val = Number((row as any).co2_saved_kg ?? 0) || 0;
+ totals.set(uid, (totals.get(uid) ?? 0) + val);
+ }
+ const sorted = [...totals.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([uid]) => uid);
+ const idx = sorted.findIndex((uid) => uid === forUserId);
+ const position = idx >= 0 ? idx + 1 : null;
+ setRank(position);
+ setRankLabel(formatRankLabel(position));
+ } catch {
+ setRank(null);
+ setRankLabel('â');
+ }
+ }
+
+ useEffect(() => {
+ (async () => {
+ if (typeof id !== 'string') return;
+ // Load friend's background key
+ try {
+ const { data: prof } = await supabase
+ .from('profiles')
+ .select('background_key')
+ .eq('id', id)
+ .single();
+ const key: string | null | undefined = (prof as any)?.background_key;
+ setBgKey(safeBackgroundKey(key));
+ } catch {
+ setBgKey(DEFAULT_PROFILE_BG);
+ }
+ // Realtime for background/profile changes
+ try {
+ const ch = supabase
+ .channel('realtime:friend_profile:' + id)
+ .on(
+ 'postgres_changes',
+ { event: '*', schema: 'public', table: 'profiles', filter: `id=eq.${id}` },
+ (payload: any) => {
+ const key = (payload?.new as any)?.background_key ?? (payload?.old as any)?.background_key ?? null;
+ setBgKey(safeBackgroundKey(key));
+ }
+ )
+ .subscribe();
+ setChannelRef(ch);
+ } catch {
+ // ignore
+ }
+ // Compute and subscribe to world rank updates
+ await refreshWorldRank(id);
+ try {
+ const rankChannel = supabase
+ .channel('realtime:user_actions_global_rank:' + id)
+ .on(
+ 'postgres_changes',
+ { event: 'INSERT', schema: 'public', table: 'user_actions' },
+ () => { void refreshWorldRank(id); }
+ )
+ .subscribe();
+ // Merge by just keeping one ref; cleanup will remove any active channel refs below
+ setChannelRef(rankChannel);
+ } catch {
+ // ignore
+ }
+ const { data: me } = await supabase.auth.getUser();
+ const myId = me?.user?.id;
+ if (!myId) return;
+ const { data: rels } = await supabase
+ .from('friend_requests')
+ .select('from_user_id, to_user_id, status, created_at')
+ .or(`and(from_user_id.eq.${myId},to_user_id.eq.${id}),and(from_user_id.eq.${id},to_user_id.eq.${myId})`)
+ .order('created_at', { ascending: false })
+ .limit(1);
+ const r = (rels ?? [])[0] as { from_user_id: string; to_user_id: string; status: string } | undefined;
+ if (r?.status === 'accepted') {
+ setRelationLabel(' âą VĂ€n');
+ } else if (r?.status === 'pending' && r.from_user_id === myId) {
+ setRelationLabel(' ⹠FörfrÄgan skickad');
+ } else {
+ setRelationLabel('');
+ }
+ })();
+ return () => {
+ if (channelRef) supabase.removeChannel(channelRef);
+ };
+ }, [id]);
+
+ if (!user) {
+ return (
+
+
+ Kunde inte hitta profilen.
+
+ );
+ }
+
+ return (
+
+ {/* Full-screen background image (top-aligned cover) */}
+
+
+
+
+
+
+
+
+ {user.name.charAt(0)}
+
+ {user.name}
+ @{user.username}{relationLabel}
+
+
+
+
+ {rankLabel}
+ Rank
+
+
+ {weeksSince(user.createdAt)}
+ Tid i appen (v)
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#a7c7a3', paddingHorizontal: 16 },
+ missing: { color: '#1f1f1f' },
+ bgContainer: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ bgImage: {
+ width: '100%',
+ height: '100%',
+ },
+ bgOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: '#a7c7a3',
+ opacity: 0.2,
+ },
+ profileHeader: { alignItems: 'center', marginBottom: 54 },
+ avatar: {
+ width: 72,
+ height: 72,
+ borderRadius: 36,
+ backgroundColor: '#2f7147',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 8,
+ },
+ avatarText: { color: '#fff', fontWeight: '700', fontSize: 28 },
+ name: { fontSize: 18, fontWeight: '700', color: '#1f1f1f' },
+ username: { color: '#2a2a2a' },
+ statsRow: { flexDirection: 'row', gap: 12, marginBottom: 16 },
+ statBox: {
+ flex: 1,
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 16,
+ alignItems: 'center',
+ },
+ statNumber: { fontWeight: '700', fontSize: 18, color: '#1f1f1f' },
+ statLabel: { color: '#2a2a2a', marginTop: 4 },
+});
+
+
diff --git a/app/app/(tabs)/acount/friends.tsx b/app/app/(tabs)/acount/friends.tsx
new file mode 100644
index 000000000..65c4cf643
--- /dev/null
+++ b/app/app/(tabs)/acount/friends.tsx
@@ -0,0 +1,161 @@
+import React, { useEffect, useState } from 'react';
+import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
+import { Link, Stack } from 'expo-router';
+import { type User } from '@/lib/users-store';
+import { supabase } from '@/src/lib/supabase';
+
+type FriendWithTotal = User & { totalCo2: number };
+
+export default function FriendsScreen() {
+ const [friends, setFriends] = useState([]);
+
+ useEffect(() => {
+ let channel: ReturnType | null = null;
+ let actionsChannel: ReturnType | null = null;
+ (async () => {
+ const { data: me } = await supabase.auth.getUser();
+ const myId = me?.user?.id;
+ if (!myId) return;
+ async function load() {
+ // HÀmta accepterade relationer dÀr jag Àr med
+ const { data: rels, error } = await supabase
+ .from('friend_requests')
+ .select('from_user_id, to_user_id, status')
+ .eq('status', 'accepted')
+ .or(`from_user_id.eq.${myId},to_user_id.eq.${myId}`);
+ if (error) return;
+ const otherIds = Array.from(
+ new Set(
+ (rels ?? []).map((r: any) => (r.from_user_id === myId ? r.to_user_id : r.from_user_id))
+ )
+ );
+ const { data: profs } = await supabase
+ .from('profiles')
+ .select('id, username, full_name, first_name, last_name, email')
+ .in('id', otherIds.length ? otherIds : ['00000000-0000-0000-0000-000000000000']);
+ // Sum CO2 totals for all friend ids in one query
+ const { data: actions } = await supabase
+ .from('user_actions')
+ .select('user_id, co2_saved_kg')
+ .in('user_id', otherIds.length ? otherIds : ['00000000-0000-0000-0000-000000000000']);
+ const totals = new Map();
+ for (const row of (actions as any[]) ?? []) {
+ const uid = String((row as any).user_id ?? '');
+ const val = Number((row as any).co2_saved_kg ?? 0) || 0;
+ totals.set(uid, (totals.get(uid) ?? 0) + val);
+ }
+ const mapped: FriendWithTotal[] =
+ (profs ?? []).map((p: any) => {
+ const fullName =
+ p.full_name ||
+ [p.first_name, p.last_name].filter(Boolean).join(' ') ||
+ p.username ||
+ (p.email ?? 'user').split('@')[0];
+ const total = totals.get(p.id as string) ?? 0;
+ return {
+ id: p.id,
+ name: fullName,
+ username: p.username ?? (p.email ?? 'user').split('@')[0],
+ email: p.email ?? '',
+ createdAt: new Date().toISOString().slice(0, 10),
+ totalCo2: total,
+ } as FriendWithTotal;
+ }) ?? [];
+ // Sort by total saved CO2 desc
+ setFriends(mapped.sort((a, b) => b.totalCo2 - a.totalCo2));
+ }
+ await load();
+ // Realtime: uppdatera nÀr relationer Àndras
+ channel = supabase
+ .channel('realtime:friends_list:' + myId)
+ .on(
+ 'postgres_changes',
+ { event: '*', schema: 'public', table: 'friend_requests', filter: `to_user_id=eq.${myId}` },
+ load
+ )
+ .on(
+ 'postgres_changes',
+ { event: '*', schema: 'public', table: 'friend_requests', filter: `from_user_id=eq.${myId}` },
+ load
+ )
+ .subscribe();
+ // Realtime: uppdatera nÀr user_actions fÄr nya rader (nÄgon vÀn loggar en handling)
+ actionsChannel = supabase
+ .channel('realtime:friends_user_actions')
+ .on(
+ 'postgres_changes',
+ { event: 'INSERT', schema: 'public', table: 'user_actions' },
+ load
+ )
+ .subscribe();
+ })();
+ return () => {
+ if (channel) supabase.removeChannel(channel);
+ if (actionsChannel) supabase.removeChannel(actionsChannel);
+ };
+ }, []);
+
+ return (
+
+
+
+ {friends.length > 0 ? (
+
+ Du har {friends.length} {friends.length === 1 ? 'vÀn' : 'vÀnner'}
+
+ ) : (
+ Du har Ànnu inga vÀnner.
+ )}
+
+ f.id}
+ renderItem={({ item }) => (
+
+
+
+ {item.name.charAt(0)}
+
+
+ {item.name}
+ @{item.username} âą VĂ€n
+
+ {item.totalCo2.toFixed(1)} kg
+
+
+ )}
+ contentContainerStyle={styles.listContent}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#a7c7a3' },
+ subTitle: { color: '#1f1f1f' },
+ listContent: { padding: 16 },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 12,
+ marginBottom: 8,
+ },
+ avatar: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: '#2f7147',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: 10,
+ },
+ avatarText: { color: '#fff', fontWeight: '700' },
+ main: { flex: 1 },
+ name: { fontWeight: '700', color: '#1f1f1f' },
+ user: { color: '#2a2a2a', fontSize: 12 },
+ co2: { fontWeight: '700', color: '#1f1f1f' },
+});
+
+
diff --git a/app/app/(tabs)/acount/index.tsx b/app/app/(tabs)/acount/index.tsx
new file mode 100644
index 000000000..0d09250ec
--- /dev/null
+++ b/app/app/(tabs)/acount/index.tsx
@@ -0,0 +1,542 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
+import { Link, Stack, router } from 'expo-router';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { getCompetitions } from '@/lib/competitions-store';
+import { getCurrentUser, getFriends, subscribeUsers, setCurrentUser, type User } from '@/lib/users-store';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useHeaderHeight } from '@react-navigation/elements';
+import { isLoggedIn } from '@/lib/session';
+import { supabase } from '@/src/lib/supabase';
+import { Image } from 'expo-image';
+import {
+ DEFAULT_PROFILE_BG,
+ PROFILE_BACKGROUNDS_PORTRAIT,
+ safeBackgroundKey,
+ type ProfileBackgroundKey,
+} from '@/src/constants/profileBackgrounds';
+import { subscribeFriendRequests } from '@/lib/friend-requests-store';
+
+type FriendWithTotal = User & { totalCo2: number };
+
+function formatRankLabel(rank: number | null): string {
+ if (!rank || rank < 1 || !Number.isFinite(rank)) return 'â';
+ if (rank <= 10) return `${rank}a`;
+ if (rank <= 50) return 'Top 50';
+ if (rank <= 100) return 'Top 100';
+ const bucket = Math.ceil(rank / 100) * 100;
+ return `Top ${bucket}`;
+}
+
+function computeFriendTotals(): FriendWithTotal[] {
+ const comps = getCompetitions();
+ const friends = getFriends();
+ const totals: Record = {};
+ for (const c of comps) {
+ for (const p of c.participants) {
+ totals[p.id] = (totals[p.id] ?? 0) + p.co2ReducedKg;
+ }
+ }
+ return friends
+ .map((f) => ({ ...f, totalCo2: totals[f.id] ?? 0 }))
+ .sort((a, b) => b.totalCo2 - a.totalCo2);
+}
+
+function computeMyRank(myId: string): number {
+ const comps = getCompetitions();
+ const friends = getFriends();
+ const totals: Record = {};
+ for (const c of comps) {
+ for (const p of c.participants) {
+ totals[p.id] = (totals[p.id] ?? 0) + p.co2ReducedKg;
+ }
+ }
+ const myTotal = totals[myId] ?? 0;
+ const leaderboard = [
+ ...friends.map((f) => ({ id: f.id, total: totals[f.id] ?? 0 })),
+ { id: myId, total: myTotal },
+ ].sort((a, b) => b.total - a.total);
+ const idx = leaderboard.findIndex((e) => e.id === myId);
+ return idx >= 0 ? idx + 1 : 1;
+}
+
+export default function AccountScreen() {
+ const insets = useSafeAreaInsets();
+ const headerHeight = useHeaderHeight();
+ const extraTopSpacing = 24; // push content a bit further down
+ const [me, setMe] = useState(getCurrentUser());
+ const [checkingAuth, setCheckingAuth] = useState(true);
+ const [friends, setFriends] = useState(computeFriendTotals());
+ const [friendCount, setFriendCount] = useState(0);
+ const [bgKey, setBgKey] = useState(DEFAULT_PROFILE_BG);
+ const [worldRank, setWorldRank] = useState(null);
+ const [worldRankLabel, setWorldRankLabel] = useState('â');
+
+ async function loadFriendTotalsFromSupabase() {
+ try {
+ const base = getFriends();
+ const ids = base.map((f) => f.id);
+ if (ids.length === 0) {
+ setFriends([]);
+ return;
+ }
+ const { data: actions } = await supabase
+ .from('user_actions')
+ .select('user_id, co2_saved_kg')
+ .in('user_id', ids);
+ const totals = new Map();
+ for (const row of (actions as any[]) ?? []) {
+ const uid = String((row as any).user_id ?? '');
+ const val = Number((row as any).co2_saved_kg ?? 0) || 0;
+ totals.set(uid, (totals.get(uid) ?? 0) + val);
+ }
+ const mapped: FriendWithTotal[] = base.map((f) => ({
+ ...f,
+ totalCo2: totals.get(f.id) ?? 0,
+ }));
+ setFriends(mapped.sort((a, b) => b.totalCo2 - a.totalCo2));
+ } catch {
+ // fallback to local competitions aggregate if Supabase call fails
+ setFriends(computeFriendTotals());
+ }
+ }
+
+ async function refreshWorldRank(forUserId: string) {
+ try {
+ const { data, error } = await supabase
+ .from('user_actions')
+ .select('user_id, co2_saved_kg');
+ if (error) {
+ setWorldRank(null);
+ setWorldRankLabel('â');
+ return;
+ }
+ const totals = new Map();
+ for (const row of (data as any[]) ?? []) {
+ const uid = String((row as any).user_id ?? '');
+ const val = Number((row as any).co2_saved_kg ?? 0) || 0;
+ totals.set(uid, (totals.get(uid) ?? 0) + val);
+ }
+ const sorted = [...totals.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([uid]) => uid);
+ const idx = sorted.findIndex((uid) => uid === forUserId);
+ const position = idx >= 0 ? idx + 1 : null;
+ setWorldRank(position);
+ setWorldRankLabel(formatRankLabel(position));
+ } catch {
+ setWorldRank(null);
+ setWorldRankLabel('â');
+ }
+ }
+
+ useEffect(() => {
+ (async () => {
+ // Kontrollera Supabase-session i första hand (mer robust Àn lokalt token)
+ setCheckingAuth(true);
+ const { data: sess } = await supabase.auth.getSession();
+ if (!sess.session) {
+ const ok = await isLoggedIn();
+ if (!ok) {
+ router.replace('/login');
+ return;
+ }
+ }
+ // HÀmta inloggad anvÀndare frÄn Supabase och uppdatera profilvisningen
+ const userRes = await supabase.auth.getUser();
+ const user = userRes.data.user;
+ if (user) {
+ // Ladda antal vÀnner frÄn Supabase (accepted relationer)
+ try {
+ const myId = user.id;
+ const { data: rels } = await supabase
+ .from('friend_requests')
+ .select('from_user_id, to_user_id')
+ .eq('status', 'accepted')
+ .or(`from_user_id.eq.${myId},to_user_id.eq.${myId}`);
+ const otherIds = Array.from(
+ new Set(
+ (rels ?? []).map((r: any) => (r.from_user_id === myId ? r.to_user_id : r.from_user_id))
+ )
+ );
+ setFriendCount(otherIds.length);
+ } catch {
+ // ignore
+ }
+ let username: string | undefined;
+ let fullName: string | undefined;
+ try {
+ const { data: profile } = await supabase
+ .from('profiles')
+ .select('username, full_name, first_name, last_name, background_key')
+ .eq('id', user.id)
+ .single();
+ username = (profile as any)?.username ?? undefined;
+ // Försök anvÀnda full_name, annars sÀtt ihop first/last om de finns
+ const pfFirst: string | undefined = (profile as any)?.first_name;
+ const pfLast: string | undefined = (profile as any)?.last_name;
+ fullName = (profile as any)?.full_name ?? ([pfFirst, pfLast].filter(Boolean).join(' ') || undefined);
+ const key: string | null | undefined = (profile as any)?.background_key;
+ setBgKey(safeBackgroundKey(key));
+ // Uppdatera lokalt state för instant UI (utan att vÀnta pÄ realtime)
+ updateCurrentUser({ backgroundKey: safeBackgroundKey(key) });
+ } catch {
+ // Ignorera fel vid lÀsning av profil
+ }
+ // Ladda global rank
+ await refreshWorldRank(user.id);
+ // Ladda vÀnners CO2-besparing
+ await loadFriendTotalsFromSupabase();
+ const meta = user.user_metadata ?? {};
+ const metaFirst = typeof meta.first_name === 'string' ? meta.first_name : '';
+ const metaLast = typeof meta.last_name === 'string' ? meta.last_name : '';
+ const fallbackName = [metaFirst, metaLast].filter(Boolean).join(' ');
+ const emailPrefix = (user.email ?? '').split('@')[0] ?? 'user';
+ const nextMe: User = {
+ id: user.id,
+ name: fullName || fallbackName || username || emailPrefix,
+ username: username || (typeof meta.username === 'string' ? meta.username : emailPrefix),
+ email: user.email ?? '',
+ avatarUrl: undefined,
+ createdAt: user.created_at ?? new Date().toISOString(),
+ friendsCount: me.friendsCount,
+ };
+ setMe(nextMe);
+ setCurrentUser(nextMe);
+ }
+ setCheckingAuth(false);
+ })();
+ }, []);
+
+ useEffect(() => {
+ // Initiera vÀnförfrÄgningar för att populera lokala vÀnlistan (users-store)
+ const unsubscribeFriendReq = subscribeFriendRequests(() => {
+ // users-store uppdateras internt via addFriend(); AccountScreen lyssnar redan via subscribeUsers
+ });
+
+ const unsub = subscribeUsers(() => {
+ setMe(getCurrentUser());
+ void loadFriendTotalsFromSupabase();
+ const k = getCurrentUser().backgroundKey;
+ if (k) setBgKey(k);
+ });
+ // Realtime för Àndrade relationer pÄverkar vÀnnantal
+ let channel: ReturnType | null = null;
+ let channelRank: ReturnType | null = null;
+ let channelFriendTotals: ReturnType | null = null;
+ (async () => {
+ try {
+ const { data: me } = await supabase.auth.getUser();
+ const myId = me?.user?.id;
+ if (!myId) return;
+ async function reloadCount() {
+ const { data: rels } = await supabase
+ .from('friend_requests')
+ .select('from_user_id, to_user_id')
+ .eq('status', 'accepted')
+ .or(`from_user_id.eq.${myId},to_user_id.eq.${myId}`);
+ const otherIds = Array.from(
+ new Set(
+ (rels ?? []).map((r: any) => (r.from_user_id === myId ? r.to_user_id : r.from_user_id))
+ )
+ );
+ setFriendCount(otherIds.length);
+ }
+ async function reloadRank() {
+ await refreshWorldRank(myId);
+ }
+ channel = supabase
+ .channel('realtime:friend_count:' + myId)
+ .on(
+ 'postgres_changes',
+ { event: '*', schema: 'public', table: 'friend_requests', filter: `to_user_id=eq.${myId}` },
+ reloadCount
+ )
+ .on(
+ 'postgres_changes',
+ { event: '*', schema: 'public', table: 'friend_requests', filter: `from_user_id=eq.${myId}` },
+ reloadCount
+ )
+ .on(
+ 'postgres_changes',
+ { event: '*', schema: 'public', table: 'profiles', filter: `id=eq.${myId}` },
+ (payload: any) => {
+ const key = (payload?.new as any)?.background_key ?? (payload?.old as any)?.background_key ?? null;
+ setBgKey(safeBackgroundKey(key));
+ }
+ )
+ .subscribe();
+ // Lyssna pÄ globala insÀttningar i user_actions för att uppdatera rank
+ channelRank = supabase
+ .channel('realtime:world_rank')
+ .on(
+ 'postgres_changes',
+ { event: 'INSERT', schema: 'public', table: 'user_actions' },
+ reloadRank
+ )
+ .subscribe();
+ // Lyssna pÄ user_actions för att uppdatera vÀnners totals
+ channelFriendTotals = supabase
+ .channel('realtime:friends_totals:' + myId)
+ .on(
+ 'postgres_changes',
+ { event: 'INSERT', schema: 'public', table: 'user_actions' },
+ () => { void loadFriendTotalsFromSupabase(); }
+ )
+ .subscribe();
+ } catch {
+ // ignore
+ }
+ })();
+ return () => {
+ unsub();
+ unsubscribeFriendReq();
+ if (channel) supabase.removeChannel(channel);
+ if (channelRank) supabase.removeChannel(channelRank);
+ if (channelFriendTotals) supabase.removeChannel(channelFriendTotals);
+ };
+ }, []);
+
+ const top3 = useMemo(() => friends.slice(0, 3), [friends]);
+
+ if (checkingAuth) {
+ return null;
+ }
+
+ return (
+
+ {/* Full-screen background image (top-aligned cover) */}
+
+
+
+
+ (
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+ {me.name.charAt(0)}
+
+ {me.name}
+ @{me.username}
+
+
+
+
+
+ {friendCount}
+ VĂ€nner
+
+
+
+ {worldRankLabel}
+ Rank
+
+
+
+
+ VĂ€nner (topp 3)
+
+
+ Visa alla
+
+
+
+
+ {top3.length === 0 ? (
+ Inga vÀnner Ànnu. LÀgg till en ny vÀn!
+ ) : (
+
+ {top3.map((item) => (
+
+
+
+ {item.name.charAt(0)}
+
+
+ {item.name}
+ @{item.username}
+
+ {item.totalCo2.toFixed(1)} kg
+
+
+ ))}
+
+ )}
+
+
+
+
+ Ny vÀn
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#a7c7a3',
+ paddingHorizontal: 16,
+ },
+ bgContainer: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ bgImage: {
+ width: '100%',
+ height: '100%',
+ },
+ bgOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: '#a7c7a3',
+ opacity: 0.2,
+ },
+ headerBtn: {
+ backgroundColor: '#2f7147',
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ profileHeader: {
+ alignItems: 'center',
+ marginBottom: 54,
+ },
+ avatar: {
+ width: 90,
+ height: 90,
+ borderRadius: 45,
+ backgroundColor: '#2f7147',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 8,
+ },
+ avatarText: {
+ color: '#fff',
+ fontWeight: '700',
+ fontSize: 32,
+ },
+ name: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#1f1f1f',
+ },
+ username: {
+ color: '#2a2a2a',
+ },
+ statsRow: {
+ flexDirection: 'row',
+ gap: 12,
+ marginBottom: 16,
+ },
+ statBox: {
+ flex: 1,
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 16,
+ alignItems: 'center',
+ },
+ statNumber: {
+ fontWeight: '700',
+ fontSize: 18,
+ color: '#1f1f1f',
+ },
+ statLabel: {
+ color: '#2a2a2a',
+ marginTop: 4,
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ sectionTitle: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ fontSize: 16,
+ },
+ emptyText: {
+ color: '#1f1f1f',
+ marginTop: 8,
+ marginBottom: 8,
+ },
+ linkText: {
+ color: '#1f1f1f',
+ textDecorationLine: 'underline',
+ },
+ listContent: {
+ marginTop: 8,
+ },
+ friendRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 12,
+ marginBottom: 8,
+ },
+ friendAvatar: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: '#2f7147',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: 10,
+ },
+ friendAvatarText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+ friendMain: {
+ flex: 1,
+ },
+ friendName: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ },
+ friendUser: {
+ color: '#2a2a2a',
+ fontSize: 12,
+ },
+ friendCo2: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ },
+ newFriendBtn: {
+ marginTop: 8,
+ backgroundColor: '#2f7147',
+ borderRadius: 12,
+ paddingVertical: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ gap: 8,
+ },
+ newFriendText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+});
+
+
diff --git a/app/app/(tabs)/acount/search.tsx b/app/app/(tabs)/acount/search.tsx
new file mode 100644
index 000000000..9cacc94ce
--- /dev/null
+++ b/app/app/(tabs)/acount/search.tsx
@@ -0,0 +1,310 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert } from 'react-native';
+import { Stack } from 'expo-router';
+import { isValidEmail, isValidUsername } from '@/lib/users-store';
+import { supabase } from '@/src/lib/supabase';
+import {
+ acceptFriendRequest,
+ declineFriendRequest,
+ getInboundPending,
+ subscribeFriendRequests,
+ addFriendRequest,
+ hasAnyRelation,
+ type FriendRequest,
+} from '@/lib/friend-requests-store';
+import { addFriend } from '@/lib/users-store';
+
+export default function SearchFriendScreen() {
+ const [query, setQuery] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [incoming, setIncoming] = useState([]);
+ const [senderById, setSenderById] = useState>({});
+ const [searching, setSearching] = useState(false);
+ const [found, setFound] = useState(null);
+
+ useEffect(() => {
+ let unsub: (() => void) | undefined;
+ (async () => {
+ const { data: me } = await supabase.auth.getUser();
+ const myId = me?.user?.id;
+ if (!myId) return;
+ setIncoming(getInboundPending(myId));
+ unsub = subscribeFriendRequests(() => {
+ setIncoming(getInboundPending(myId));
+ });
+ })();
+ return () => {
+ if (unsub) unsub();
+ };
+ }, []);
+
+ // Ladda avsÀndarens för- och efternamn + anvÀndarnamn för inkommande förfrÄgningar
+ useEffect(() => {
+ (async () => {
+ const ids = Array.from(new Set(incoming.map((r) => r.fromUserId)));
+ if (ids.length === 0) {
+ setSenderById({});
+ return;
+ }
+ const { data: profs } = await supabase
+ .from('profiles')
+ .select('id, username, full_name, first_name, last_name')
+ .in('id', ids);
+ const map: Record = {};
+ for (const p of (profs as any[]) ?? []) {
+ const fullName =
+ p.full_name ||
+ [p.first_name, p.last_name].filter(Boolean).join(' ') ||
+ p.username ||
+ 'AnvÀndare';
+ map[p.id] = { name: fullName, username: p.username ?? 'user' };
+ }
+ setSenderById(map);
+ })();
+ }, [incoming]);
+
+ const incomingDetailed = useMemo(
+ () =>
+ incoming.map((r) => ({
+ req: r,
+ name: senderById[r.fromUserId]?.name ?? 'AnvÀndare',
+ username: senderById[r.fromUserId]?.username ?? 'user',
+ })),
+ [incoming, senderById]
+ );
+
+ const onAdd = () => {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ Alert.alert('Ange eâpost eller anvĂ€ndarnamn');
+ return;
+ }
+ let username = '';
+ if (isValidEmail(trimmed)) {
+ username = trimmed.split('@')[0].toLowerCase();
+ } else if (isValidUsername(trimmed)) {
+ username = trimmed.replace(/^@/, '').toLowerCase();
+ }
+ if (!username) {
+ Alert.alert('Ogiltig input', 'Skriv en giltig eâpost eller ett anvĂ€ndarnamn.');
+ return;
+ }
+ setSearching(true);
+ setFound(null);
+ (async () => {
+ try {
+ // slÄ upp anvÀndare via username
+ const { data: prof, error } = await supabase
+ .from('profiles')
+ .select('id, username, full_name, first_name, last_name, email')
+ .ilike('username', username)
+ .single();
+ if (error || !prof) {
+ Alert.alert('Hittade inte anvÀndare', 'Kontrollera anvÀndarnamnet.');
+ setFound(null);
+ return;
+ }
+ const fullName =
+ (prof as any)?.full_name ||
+ ([ (prof as any)?.first_name, (prof as any)?.last_name ].filter(Boolean).join(' ')) ||
+ (prof as any)?.username ||
+ ((prof as any)?.email ?? 'user').split('@')[0];
+ setFound({
+ id: (prof as any).id,
+ name: fullName,
+ username: (prof as any).username ?? ((prof as any)?.email ?? 'user').split('@')[0],
+ });
+ } finally {
+ setSearching(false);
+ }
+ })();
+ };
+
+ const onSendToFound = async () => {
+ if (!found) return;
+ setSubmitting(true);
+ try {
+ const { data: me } = await supabase.auth.getUser();
+ const myId = me?.user?.id;
+ if (!myId) {
+ Alert.alert('Logga in', 'Du mÄste vara inloggad för att skicka en förfrÄgan.');
+ return;
+ }
+ if (found.id === myId) {
+ Alert.alert('Ogiltigt', 'Du kan inte skicka en förfrÄgan till dig sjÀlv.');
+ return;
+ }
+ if (hasAnyRelation(myId, found.id)) {
+ Alert.alert('Redan skickad', 'Det finns redan en aktiv förfrÄgan mellan er.');
+ return;
+ }
+ await addFriendRequest({ toUserId: found.id });
+ Alert.alert('VÀnförfrÄgan skickad', `@${found.username} har fÄtt en förfrÄgan.`);
+ setFound(null);
+ setQuery('');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Sök pĂ„ anvĂ€ndarnamn eller eâpost
+
+
+ {searching ? 'Söker...' : 'Sök'}
+
+
+ {found && (
+
+ {found.name}
+ @{found.username}
+
+ Skicka vÀnförfrÄgan
+
+
+ )}
+ {incomingDetailed.length > 0 && (
+
+ VÀnförfrÄgningar
+ {incomingDetailed.map(({ req, name, username }) => (
+
+
+ {name}
+ @{username}
+
+ {
+ acceptFriendRequest(req.id);
+ // lÀgg till avsÀndaren som vÀn lokalt (hÀmta profil för namn)
+ try {
+ const { data: prof } = await supabase
+ .from('profiles')
+ .select('id, username, full_name, first_name, last_name, email')
+ .eq('id', req.fromUserId)
+ .single();
+ const fullName =
+ (prof as any)?.full_name ||
+ ([ (prof as any)?.first_name, (prof as any)?.last_name ].filter(Boolean).join(' ')) ||
+ username;
+ addFriend({
+ id: req.fromUserId,
+ name: fullName,
+ username: (prof as any)?.username ?? username,
+ email: (prof as any)?.email ?? '',
+ createdAt: new Date().toISOString().slice(0, 10),
+ });
+ Alert.alert('VÀnner', `Du och @${(prof as any)?.username ?? username} Àr nu vÀnner.`);
+ } catch {
+ // fallback utan profil
+ addFriend({
+ id: req.fromUserId,
+ name,
+ username,
+ email: '',
+ createdAt: new Date().toISOString().slice(0, 10),
+ });
+ Alert.alert('VÀnner', `Du och @${username} Àr nu vÀnner.`);
+ }
+ }}
+ >
+ Acceptera
+
+ {
+ declineFriendRequest(req.id);
+ }}
+ >
+ Neka
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#a7c7a3', padding: 16 },
+ card: {
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 16,
+ },
+ label: { fontWeight: '700', color: '#1f1f1f', marginBottom: 8 },
+ input: {
+ backgroundColor: '#ffffff',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderWidth: 1,
+ borderColor: '#e1e1e1',
+ },
+ button: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 12,
+ borderRadius: 10,
+ alignItems: 'center',
+ marginTop: 16,
+ },
+ disabled: { opacity: 0.6 },
+ buttonText: { color: '#fff', fontWeight: '700' },
+ sectionTitle: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ marginBottom: 8,
+ fontSize: 16,
+ },
+ requestRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ backgroundColor: 'transparent',
+ paddingVertical: 8,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: '#e6e6e6',
+ },
+ requestName: { fontWeight: '700', color: '#1f1f1f' },
+ requestUser: { color: '#6f6f6f', fontSize: 12 },
+ resultName: { fontWeight: '700', color: '#1f1f1f' },
+ resultUser: { color: '#6f6f6f', fontSize: 12, marginTop: 2 },
+ acceptBtn: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ borderRadius: 10,
+ },
+ acceptText: { color: '#fff', fontWeight: '700' },
+ declineBtn: {
+ borderWidth: 1.5,
+ borderColor: '#b83a3a',
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ borderRadius: 10,
+ backgroundColor: 'transparent',
+ },
+ declineText: { color: '#b83a3a', fontWeight: '700' },
+});
+
diff --git a/app/app/(tabs)/acount/settings.tsx b/app/app/(tabs)/acount/settings.tsx
new file mode 100644
index 000000000..39bed0824
--- /dev/null
+++ b/app/app/(tabs)/acount/settings.tsx
@@ -0,0 +1,176 @@
+import React, { useEffect, useState } from 'react';
+import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert } from 'react-native';
+import { Stack, useRouter } from 'expo-router';
+import { getCurrentUser, getFriends, isValidUsername, updateCurrentUser } from '@/lib/users-store';
+import { clearToken } from '@/lib/session';
+import { supabase } from '@/src/lib/supabase';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
+import BackgroundPickerCarousel from '@/src/components/profile/BackgroundPickerCarousel';
+import { DEFAULT_PROFILE_BG, safeBackgroundKey, type ProfileBackgroundKey } from '@/src/constants/profileBackgrounds';
+
+export default function SettingsScreen() {
+ const router = useRouter();
+ const me = getCurrentUser();
+ const [name, setName] = useState(me.name);
+ const [username, setUsername] = useState(me.username);
+ const [saving, setSaving] = useState(false);
+ const [bgKey, setBgKey] = useState(DEFAULT_PROFILE_BG);
+ const insets = useSafeAreaInsets();
+ const tabBarHeight = useBottomTabBarHeight();
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const { data: userRes } = await supabase.auth.getUser();
+ const myId = userRes.user?.id;
+ if (!myId) return;
+ const { data: prof } = await supabase
+ .from('profiles')
+ .select('background_key')
+ .eq('id', myId)
+ .single();
+ setBgKey(safeBackgroundKey((prof as any)?.background_key));
+ } catch {
+ setBgKey(DEFAULT_PROFILE_BG);
+ }
+ })();
+ }, []);
+
+ const onSave = async () => {
+ const trimmedName = name.trim();
+ const trimmedUser = username.trim();
+ const normalizedUser = trimmedUser.toLowerCase();
+ if (!trimmedName) {
+ Alert.alert('Namn krÀvs');
+ return;
+ }
+ if (!isValidUsername(normalizedUser)) {
+ Alert.alert('Ogiltigt anvÀndarnamn', 'Minst 3 tecken, a-ö, siffror, _ eller .');
+ return;
+ }
+ setSaving(true);
+ try {
+ // Optimistisk uppdatering lokalt sÄ profilfliken byter bakgrund direkt
+ updateCurrentUser({ name: trimmedName, username: normalizedUser, backgroundKey: bgKey });
+ // Kontrollera unikt anvÀndarnamn (case-insensitivt) globalt, exkludera mig sjÀlv
+ const { data: userRes } = await supabase.auth.getUser();
+ const myId = userRes.user?.id;
+ if (normalizedUser !== me.username.toLowerCase()) {
+ const { data: taken } = await supabase
+ .from('profiles')
+ .select('id')
+ .ilike('username', normalizedUser)
+ .limit(1);
+ const conflict = (taken ?? []).some((r) => r.id !== myId);
+ if (conflict) {
+ Alert.alert('Upptaget anvÀndarnamn', 'VÀlj ett annat unikt anvÀndarnamn.');
+ return;
+ }
+ }
+ // Uppdatera i Supabase-profil
+ if (myId) {
+ await supabase
+ .from('profiles')
+ .update({ username: normalizedUser, full_name: trimmedName, background_key: bgKey })
+ .eq('id', myId);
+ }
+ // Lokalt Àr redan uppdaterat optimistiskt ovan
+ Alert.alert('Sparat');
+ router.back();
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ Namn
+
+
+ AnvÀndarnamn
+
+
+ Profiltema
+ setBgKey(k)} />
+
+
+ Spara
+
+
+ Profilbildsuppdatering kan lÀggas till senare.
+
+ {
+ await supabase.auth.signOut();
+ await clearToken();
+ router.replace('/login');
+ }}
+ style={[styles.logoutBtn, { marginBottom: 4 + tabBarHeight }]}
+ >
+ Logga ut
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#a7c7a3',
+ padding: 16,
+ },
+ card: {
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 16,
+ },
+ label: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ marginTop: 8,
+ marginBottom: 6,
+ },
+ input: {
+ backgroundColor: '#ffffff',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderWidth: 1,
+ borderColor: '#e1e1e1',
+ },
+ button: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 12,
+ borderRadius: 10,
+ alignItems: 'center',
+ marginTop: 16,
+ },
+ disabled: { opacity: 0.6 },
+ buttonText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+ hint: {
+ marginTop: 12,
+ color: '#1f1f1f',
+ fontSize: 12,
+ },
+ logoutBtn: {
+ backgroundColor: '#b83a3a',
+ paddingVertical: 14,
+ borderRadius: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 16,
+ },
+ logoutText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+});
+
+
diff --git a/app/app/(tabs)/create.tsx b/app/app/(tabs)/create.tsx
new file mode 100644
index 000000000..750194077
--- /dev/null
+++ b/app/app/(tabs)/create.tsx
@@ -0,0 +1,472 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { View, Text, StyleSheet, TouchableOpacity, ScrollView, ImageBackground, Pressable, Image, RefreshControl, Alert, TextInput, Keyboard } from 'react-native';
+import { BlurView } from 'expo-blur';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { MISSION_IMAGES, safeMissionImageKey } from '@/src/constants/missionImages';
+import { fetchMissions, fetchTodayCounts, logUserAction, logUserActionWithCo2, type Mission } from '@/src/services/missions';
+
+export default function CreateScreen() {
+ const insets = useSafeAreaInsets();
+ const [missions, setMissions] = useState([]);
+ const [selectedMission, setSelectedMission] = useState(null);
+ const [refreshing, setRefreshing] = useState(false);
+ const [expanded, setExpanded] = useState>({
+ transport: false,
+ mat: false,
+ 'Ätervinning': false,
+ konsumtion: false,
+ hem: false,
+ });
+ const [countsMap, setCountsMap] = useState>({});
+ const [quantityValue, setQuantityValue] = useState('');
+ const [quantityError, setQuantityError] = useState('');
+
+ const CATEGORIES = useMemo(
+ () => [
+ { key: 'transport', label: 'Transport' },
+ { key: 'mat', label: 'Mat' },
+ { key: 'Ă„tervinning', label: 'Ă
tervinning' },
+ { key: 'konsumtion', label: 'Konsumtion' },
+ { key: 'hem', label: 'Hem' },
+ ] as const,
+ []
+ );
+
+ const groupedByCategory = useMemo(() => {
+ const groups: Record = {};
+ for (const c of CATEGORIES) {
+ groups[c.key] = [];
+ }
+ // Helper: normalize incoming DB category to one of our static keys
+ const sanitize = (v: string) =>
+ v
+ .trim()
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, ''); // strip diacritics
+ const normalizeKey = (raw: string): typeof CATEGORIES[number]['key'] | null => {
+ const s = sanitize(raw);
+ // direct matches to our keys
+ if (s === 'transport') return 'transport';
+ if (s === 'mat' || s === 'food' || s === 'kost') return 'mat';
+ if (s === 'atervinning' || s === 'recycling') return 'Ätervinning';
+ if (s === 'konsumtion' || s === 'consumption' || s === 'shopping' || s === 'inkop' || s === 'inköp')
+ return 'konsumtion';
+ if (s === 'hem' || s === 'home' || s === 'household' || s === 'bostad') return 'hem';
+ // attempt exact diacritic form (if DB happens to use it)
+ if (raw === 'Ätervinning') return 'Ätervinning';
+ // unknown -> null (we will optionally fall back)
+ return null;
+ };
+ for (const m of missions) {
+ const key = normalizeKey(m.category) ?? 'konsumtion'; // fallback to a visible category
+ groups[key].push(m);
+ }
+ return groups;
+ }, [missions, CATEGORIES]);
+
+ const openConfirm = (mission: Mission) => {
+ setSelectedMission(mission);
+ setQuantityValue('');
+ setQuantityError('');
+ };
+
+ const confirmDo = async () => {
+ if (!selectedMission) return;
+ const current = countsMap[selectedMission.id] ?? 0;
+ const max = Math.max(0, selectedMission.max_per_day ?? 0);
+ if (current >= max) {
+ Alert.alert('GrÀns nÄdd', 'Du har nÄtt max för idag.');
+ setSelectedMission(null);
+ return;
+ }
+ try {
+ if (selectedMission.quantity_mode === 1) {
+ const raw = (quantityValue ?? '').trim().replace(',', '.');
+ const val = Number.parseFloat(raw);
+ if (!Number.isFinite(val) || val <= 0) {
+ setQuantityError('Ange ett tal större Àn 0.');
+ return;
+ }
+ if (val > 1000) {
+ setQuantityError('MaxvÀrde Àr 1000.');
+ return;
+ }
+ const multiplier = Number(selectedMission.quantity_multiplier ?? 0);
+ if (!(multiplier > 0)) {
+ Alert.alert('Fel', 'Den hÀr uppgiften saknar giltig multiplier.');
+ return;
+ }
+ const co2 = Math.round(val * multiplier * 100) / 100; // 2 decimaler
+ await logUserActionWithCo2(selectedMission, co2);
+ } else {
+ await logUserAction(selectedMission);
+ }
+ setCountsMap((prev) => ({ ...prev, [selectedMission.id]: (prev[selectedMission.id] ?? 0) + 1 }));
+ } catch (e: any) {
+ Alert.alert('Fel', e?.message ?? 'Kunde inte logga handlingen.');
+ }
+ setSelectedMission(null);
+ };
+
+ const closeConfirm = () => {
+ setSelectedMission(null);
+ };
+
+ const toggle = (id: string) => {
+ setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
+ };
+
+ const loadData = async () => {
+ try {
+ const list = await fetchMissions();
+ setMissions(list);
+ const ids = list.map((m) => m.id);
+ const counts = await fetchTodayCounts(ids);
+ setCountsMap(counts);
+ } catch (e: any) {
+ // Surface a helpful error so it's clear why categories are empty
+ Alert.alert('Fel vid hÀmtning', e?.message ?? 'Kunde inte hÀmta uppdrag. Kontrollera anslutning och behörigheter.');
+ setMissions([]);
+ setCountsMap({});
+ }
+ };
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const onRefresh = async () => {
+ setRefreshing(true);
+ await loadData();
+ setRefreshing(false);
+ };
+
+ return (
+
+
+ }
+ >
+ {CATEGORIES.map((cat) => (
+
+ toggle(cat.key)}
+ style={styles.sectionHeader}
+ accessibilityLabel={`VĂ€xla ${cat.label}`}
+ >
+ {cat.label}
+
+
+ {expanded[cat.key] &&
+ (groupedByCategory[cat.key]?.length
+ ? groupedByCategory[cat.key].map((m) => {
+ const imgKey = safeMissionImageKey(m.image_key);
+ const count = countsMap[m.id] ?? 0;
+ const max = Math.max(1, m.max_per_day ?? 1);
+ const percent = Math.min(100, Math.round((count / max) * 100));
+ const disabled = count >= max;
+ return (
+
+
+
+
+
+ {m.title}
+ Idag: {count} / {max}
+
+ {m.description ? {m.description} : null}
+
+
+
+ openConfirm(m)}
+ style={[styles.doBtn, disabled && styles.doBtnDisabled]}
+ disabled={disabled}
+ accessibilityLabel={`Utför ${m.title}`}
+ >
+ {disabled ? 'Max utförd idag' : 'Gör'}
+
+
+ );
+ })
+ : (
+
+ Inga uppdrag Ànnu.
+
+ ))}
+
+ ))}
+
+
+ {selectedMission && (
+
+
+ {/* Tap outside should only dismiss the keyboard, not close the mission modal */}
+ Keyboard.dismiss()} />
+
+
+ Keyboard.dismiss()}>
+ {selectedMission.title}
+
+
+
+
+ {selectedMission.description ??
+ 'Att panta sparar energi och minskar behovet av nya rÄvaror. FortsÀtt bidra!'}
+
+
+ {selectedMission.quantity_mode === 1 ? (
+
+
+ Ange mÀngd ({selectedMission.quantity_unit ?? ''})
+
+ {
+ setQuantityValue(t);
+ if (quantityError) setQuantityError('');
+ }}
+ placeholder={`Ange vÀrde i ${selectedMission.quantity_unit ?? ''}`}
+ keyboardType="decimal-pad"
+ inputMode="decimal"
+ style={styles.modalInput}
+ />
+ {quantityError ? {quantityError} : null}
+ {Number(selectedMission.quantity_multiplier ?? 0) > 0 && (quantityValue || '').trim() ? (
+
+ â {(Math.round((Number((quantityValue || '').trim().replace(',', '.')) || 0) * Number(selectedMission.quantity_multiplier) * 100) / 100).toFixed(2)} kg COâe
+
+ ) : null}
+
+ ) : null}
+ Keyboard.dismiss()}>
+
+
+ Avbryt
+
+
+ {selectedMission.quantity_mode === 1 ? 'Spara' : 'Gör'}
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ },
+ content: {
+ paddingHorizontal: 16,
+ gap: 14,
+ },
+ section: {
+ gap: 8,
+ marginBottom: 8,
+ },
+ sectionHeader: {
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ paddingVertical: 12,
+ paddingHorizontal: 14,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ sectionTitle: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ fontSize: 16,
+ marginBottom: 0,
+ },
+ card: {
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 16,
+ gap: 10,
+ },
+ cardImage: {
+ width: '100%',
+ height: 160,
+ borderRadius: 10,
+ overflow: 'hidden',
+ backgroundColor: '#eaeaea',
+ },
+ cardImageImg: {
+ width: '100%',
+ height: '100%',
+ },
+ cardHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'baseline',
+ },
+ cardName: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ fontSize: 16,
+ },
+ cardCount: {
+ color: '#2a2a2a',
+ fontSize: 12,
+ },
+ cardDesc: {
+ color: '#2a2a2a',
+ },
+ emptyWrap: {
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 16,
+ alignItems: 'center',
+ },
+ emptyText: {
+ color: '#2a2a2a',
+ fontStyle: 'italic',
+ },
+ progressBar: {
+ height: 10,
+ backgroundColor: 'rgba(0,0,0,0.08)',
+ borderRadius: 6,
+ overflow: 'hidden',
+ },
+ progressFill: {
+ height: '100%',
+ backgroundColor: '#2f7147',
+ },
+ doBtn: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 10,
+ borderRadius: 10,
+ alignItems: 'center',
+ },
+ doBtnDisabled: {
+ opacity: 0.6,
+ },
+ doBtnText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+ modalWrap: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 10,
+ },
+ modalCenter: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 16,
+ },
+ modalCard: {
+ width: '92%',
+ maxWidth: 560,
+ backgroundColor: 'rgba(255,255,255,0.96)',
+ borderRadius: 16,
+ padding: 18,
+ },
+ modalTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#1f1f1f',
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+ modalIllustration: {
+ width: '100%',
+ height: 140,
+ borderRadius: 12,
+ marginBottom: 10,
+ overflow: 'hidden',
+ },
+ modalIllustrationImg: {
+ width: '100%',
+ height: '100%',
+ },
+ modalText: {
+ color: '#2a2a2a',
+ textAlign: 'center',
+ marginBottom: 12,
+ },
+ modalInput: {
+ backgroundColor: '#fff',
+ borderRadius: 10,
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: 'rgba(0,0,0,0.2)',
+ color: '#1f1f1f',
+ },
+ modalError: {
+ color: '#e53935',
+ textAlign: 'center',
+ },
+ modalHint: {
+ color: '#2a2a2a',
+ textAlign: 'center',
+ },
+ modalBtns: {
+ flexDirection: 'row',
+ gap: 10,
+ alignItems: 'center',
+ },
+ modalBtn: {
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 10,
+ },
+ modalCancel: {
+ backgroundColor: '#e53935',
+ minWidth: 96,
+ },
+ modalPrimary: {
+ backgroundColor: '#2f7147',
+ flex: 1,
+ },
+ modalBtnText: {
+ color: '#fff',
+ fontWeight: '700',
+ textAlign: 'center',
+ },
+ modalBtnCancelText: {
+ color: '#fff',
+ fontWeight: '700',
+ textAlign: 'center',
+ },
+});
+
+
diff --git a/app/app/(tabs)/index.tsx b/app/app/(tabs)/index.tsx
new file mode 100644
index 000000000..c55179320
--- /dev/null
+++ b/app/app/(tabs)/index.tsx
@@ -0,0 +1,164 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
+import { Link } from 'expo-router';
+import HomeBg from '@/assets/images/home-bg.svg';
+import { getCompetitions } from '@/lib/competitions-store';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { supabase } from '@/src/lib/supabase';
+import { fetchMyTotalCo2Saved, fetchAllUsersTotalCo2Saved, subscribeCo2TotalUpdated } from '@/src/services/missions';
+
+export default function HomeScreen() {
+ const insets = useSafeAreaInsets();
+ const [co2Saved, setCo2Saved] = useState(null);
+ const [allSaved, setAllSaved] = useState(null);
+ const { myTotal, allTotal } = useMemo(() => {
+ const comps = getCompetitions();
+ let my = 0;
+ const perUser: Record = {};
+ for (const c of comps) {
+ for (const p of c.participants) {
+ if (p.id === 'me') {
+ my += p.co2ReducedKg;
+ }
+ // Summera per unik anvÀndare (tar högsta vÀrde per anvÀndare över tÀvlingar)
+ perUser[p.id] = Math.max(perUser[p.id] ?? 0, p.co2ReducedKg);
+ }
+ }
+ const all = Object.values(perUser).reduce((sum, v) => sum + v, 0);
+ return { myTotal: my, allTotal: all };
+ }, []);
+
+ useEffect(() => {
+ let unsub: (() => void) | null = null;
+ let unsubLocal: (() => void) | null = null;
+ let channel: ReturnType | null = null;
+ let channelAll: ReturnType | null = null;
+ (async () => {
+ try {
+ // Initial load from Supabase aggregated total
+ const total = await fetchMyTotalCo2Saved();
+ setCo2Saved(total);
+ const all = await fetchAllUsersTotalCo2Saved();
+ setAllSaved(all);
+ } catch {
+ // ignore; will fall back to local calc
+ }
+ // Local pub/sub as a fallback to update immediately after logging an action
+ unsubLocal = subscribeCo2TotalUpdated(async () => {
+ const latest = await fetchMyTotalCo2Saved();
+ setCo2Saved(latest);
+ const all = await fetchAllUsersTotalCo2Saved();
+ setAllSaved(all);
+ });
+ // Subscribe to realtime inserts to update total immediately after missions are logged
+ const { data: userData } = await supabase.auth.getUser();
+ const userId = userData?.user?.id;
+ if (!userId) return;
+ channel = supabase
+ .channel('realtime:my_user_actions_total')
+ .on(
+ 'postgres_changes',
+ { event: 'INSERT', schema: 'public', table: 'user_actions', filter: `user_id=eq.${userId}` },
+ async () => {
+ const latest = await fetchMyTotalCo2Saved();
+ setCo2Saved(latest);
+ }
+ )
+ .subscribe();
+ // Optional: listen to all inserts to update global total
+ channelAll = supabase
+ .channel('realtime:all_user_actions_total')
+ .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_actions' }, async () => {
+ const all = await fetchAllUsersTotalCo2Saved();
+ setAllSaved(all);
+ })
+ .subscribe();
+ unsub = () => {
+ if (channel) supabase.removeChannel(channel);
+ if (channelAll) supabase.removeChannel(channelAll);
+ };
+ })();
+ return () => {
+ if (unsub) unsub();
+ if (unsubLocal) unsubLocal();
+ if (channel) supabase.removeChannel(channel);
+ if (channelAll) supabase.removeChannel(channelAll);
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+
+ Din minskning
+ {((co2Saved ?? myTotal)).toFixed(1)} kg COâe
+
+
+ Allas minskning
+ {((allSaved ?? allTotal)).toFixed(1)} kg COâe
+
+
+
+
+
+ Skapa
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ },
+ container: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ padding: 16,
+ },
+ headerBtn: {
+ backgroundColor: '#2f7147',
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ cards: {
+ gap: 12,
+ marginBottom: 24,
+ },
+ card: {
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 16,
+ },
+ cardLabel: {
+ color: '#2a2a2a',
+ marginBottom: 6,
+ fontWeight: '600',
+ },
+ cardValue: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#1f1f1f',
+ },
+ primaryBtn: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 14,
+ borderRadius: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ primaryBtnText: {
+ color: '#fff',
+ fontWeight: '700',
+ fontSize: 16,
+ },
+});
diff --git a/app/app/(tabs)/info.tsx b/app/app/(tabs)/info.tsx
new file mode 100644
index 000000000..fa98146e4
--- /dev/null
+++ b/app/app/(tabs)/info.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { View, Text, StyleSheet, ScrollView } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+export default function InfoScreen() {
+ const insets = useSafeAreaInsets();
+ return (
+
+
+
+ Varför Àr det viktigt att vara hÄllbar?
+
+ HÄllbarhet minskar resursförbrukning, utslÀpp och avfall, vilket förbÀttrar bÄde klimat och hÀlsa.
+ SmĂ„ förĂ€ndringar i vardagenâsom att resa smartare, Ă€ta mer vĂ€xtbaserat och minska energiförbrukningâhar
+ stor effekt nÀr mÄnga gör dem tillsammans.
+
+
+
+
+ Hur mycket vegetarisk mat hjÀlper klimatet?
+
+ VÀxtbaserad kost ger ofta betydligt lÀgre klimatpÄverkan Àn kött, sÀrskilt nötkött och lamm.
+ Om fler byter nÄgra mÄltider i veckan till vegetariskt kan utslÀppen minska markant över tid.
+
+
+
+
+ Enkla steg för att komma igÄng
+ ⹠Planera vegetariska mÄltider ett par gÄnger i veckan.
+ ⹠Cykla, gÄ eller Äk kollektivt dÀr det Àr möjligt.
+ ⹠SÀnk inomhustemperaturen och slÀck onödig belysning.
+ âą Ă
tervinn och minska engÄngsartiklar.
+
+
+
+ Tillsammans gör vi skillnad
+
+ I GreenUp kan du se din egen minskning och hur alla anvÀndare tillsammans bidrar. Ju fler som deltar,
+ desto större blir effekten.
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#a7c7a3',
+ },
+ content: {
+ paddingHorizontal: 16,
+ gap: 14,
+ },
+ card: {
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 16,
+ gap: 8,
+ },
+ cardTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: '#1f1f1f',
+ },
+ paragraph: {
+ color: '#2a2a2a',
+ lineHeight: 20,
+ },
+ bullet: {
+ color: '#2a2a2a',
+ lineHeight: 20,
+ },
+});
+
+
diff --git a/app/app/(tabs)/leaderboard/_layout.tsx b/app/app/(tabs)/leaderboard/_layout.tsx
new file mode 100644
index 000000000..1c5667b64
--- /dev/null
+++ b/app/app/(tabs)/leaderboard/_layout.tsx
@@ -0,0 +1,19 @@
+import { Stack } from 'expo-router';
+import React from 'react';
+
+export default function LeaderboardStackLayout() {
+ return (
+
+ );
+}
+
+
diff --git a/app/app/(tabs)/leaderboard/competition/[id].tsx b/app/app/(tabs)/leaderboard/competition/[id].tsx
new file mode 100644
index 000000000..437205fb6
--- /dev/null
+++ b/app/app/(tabs)/leaderboard/competition/[id].tsx
@@ -0,0 +1,288 @@
+import React, { useEffect, useMemo, useState, useCallback } from 'react';
+import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
+import { Link, Stack, useLocalSearchParams } from 'expo-router';
+import { getCompetitionById, subscribe, type Competition } from '@/lib/competitions-store';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { supabase } from '@/src/lib/supabase';
+import { fetchUserCo2SavedSince, fetchUserTotalCo2Saved } from '@/src/services/missions';
+
+let currentUserId: string | null = null;
+async function ensureCurrentUserId() {
+ if (currentUserId) return currentUserId;
+ try {
+ const { data } = await supabase.auth.getUser();
+ currentUserId = data?.user?.id ?? null;
+ } catch {
+ currentUserId = null;
+ }
+ return currentUserId;
+}
+
+export default function CompetitionDetailScreen() {
+ const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
+ const [competition, setCompetition] = useState(() =>
+ id ? getCompetitionById(id) : undefined
+ );
+ const [refreshing, setRefreshing] = useState(false);
+ const [entries, setEntries] = useState>([]);
+ const insets = useSafeAreaInsets();
+ const tabBarHeight = useBottomTabBarHeight();
+
+ useEffect(() => {
+ const unsub = subscribe(() => {
+ if (typeof id === 'string') {
+ setCompetition(getCompetitionById(id));
+ void loadLeaderboard();
+ }
+ });
+ return unsub;
+ }, [id]);
+
+ async function loadLeaderboard() {
+ if (typeof id !== 'string') return;
+ try {
+ await ensureCurrentUserId();
+ // Fetch competition (for start date)
+ const { data: compRow } = await supabase
+ .from('competitions')
+ .select('id,name,start_date')
+ .eq('id', id)
+ .single();
+ const startDate: string | undefined = (compRow as any)?.start_date ?? competition?.startDate;
+ // Fetch participants from Supabase (authoritative)
+ const { data: parts } = await supabase
+ .from('competition_participants')
+ .select('user_id')
+ .eq('competition_id', id);
+ const userIds = ((parts as any[]) ?? []).map((p) => p.user_id as string);
+ if (userIds.length === 0) {
+ setEntries([]);
+ return;
+ }
+ // Fetch names from profiles
+ const { data: profs } = await supabase
+ .from('profiles')
+ .select('id, full_name, first_name, last_name, username, email')
+ .in('id', userIds);
+ const idToName: Record = {};
+ // Prefill from local participants if present
+ (competition?.participants ?? []).forEach((p) => {
+ if (p.name) {
+ idToName[p.id] = { name: p.name };
+ }
+ });
+ (profs ?? []).forEach((p: any) => {
+ const full =
+ p.full_name ||
+ [p.first_name, p.last_name].filter(Boolean).join(' ') ||
+ p.username ||
+ (p.email ?? 'user').split('@')[0];
+ const uname = p.username || (p.email ?? 'user').split('@')[0];
+ idToName[p.id as string] = { name: String(full), username: String(uname) };
+ });
+ // Compute CO2 for each
+ const values = await Promise.all(
+ userIds.map(async (uid) => {
+ const co2 = startDate
+ ? await fetchUserCo2SavedSince(uid, startDate)
+ : await fetchUserTotalCo2Saved(uid);
+ const entryName = idToName[uid]?.name ?? uid.slice(0, 6);
+ const entryUsername = idToName[uid]?.username;
+ return { id: uid, name: entryName, username: entryUsername, co2ReducedKg: co2 };
+ })
+ );
+ setEntries(values);
+ } catch {
+ // ignore
+ }
+ }
+
+ const onRefresh = useCallback(() => {
+ setRefreshing(true);
+ setTimeout(() => {
+ if (typeof id === 'string') {
+ setCompetition(getCompetitionById(id));
+ }
+ void loadLeaderboard();
+ setRefreshing(false);
+ }, 500);
+ }, [id]);
+
+ useEffect(() => {
+ void loadLeaderboard();
+ // Subscribe to participant changes and user_actions inserts to refresh
+ let channel: ReturnType | null = null;
+ try {
+ if (typeof id === 'string') {
+ channel = supabase
+ .channel('realtime:competition:' + id)
+ .on(
+ 'postgres_changes',
+ { event: '*', schema: 'public', table: 'competition_participants', filter: `competition_id=eq.${id}` },
+ () => { void loadLeaderboard(); }
+ )
+ .on(
+ 'postgres_changes',
+ { event: 'INSERT', schema: 'public', table: 'user_actions' },
+ () => { void loadLeaderboard(); }
+ )
+ .subscribe();
+ }
+ } catch {
+ // ignore
+ }
+ return () => {
+ if (channel) supabase.removeChannel(channel);
+ };
+ }, [id]);
+
+ const sorted = useMemo(() => {
+ return [...entries].sort((a, b) => b.co2ReducedKg - a.co2ReducedKg);
+ }, [entries]);
+
+ const renderItem = ({ item, index }: { item: NonNullable['participants'][number]; index: number }) => {
+ const isMe = item.id === currentUserId;
+ return (
+
+ {index + 1}
+
+
+ {item.name.charAt(0)}
+
+
+ {item.name}
+ {item.username ? @{item.username} : null}
+
+
+ {item.co2ReducedKg.toFixed(1)} kg COâe
+
+ );
+ };
+
+ return (
+
+
+ typeof id === 'string' ? (
+
+
+
+
+
+ ) : null,
+ }}
+ />
+ {sorted.length === 0 ? (
+
+ Inga deltagare Ànnu
+ Bjud in vÀnner för att komma igÄng.
+
+ ) : (
+ p.id}
+ renderItem={renderItem}
+ contentContainerStyle={[
+ styles.listContent,
+ {
+ paddingTop: insets.top + 56,
+ paddingBottom: 100 + insets.bottom + tabBarHeight,
+ },
+ ]}
+ refreshControl={}
+ />
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#a7c7a3',
+ },
+ listContent: {
+ paddingVertical: 12,
+ paddingBottom: 80,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 12,
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ marginHorizontal: 12,
+ marginBottom: 10,
+ borderRadius: 12,
+ },
+ meRow: {
+ borderWidth: 2,
+ borderColor: '#2f7147',
+ },
+ rank: {
+ width: 24,
+ textAlign: 'center',
+ fontWeight: '700',
+ color: '#1f1f1f',
+ },
+ userInfo: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ gap: 10,
+ },
+ avatar: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: '#2f7147',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ avatarText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+ name: {
+ fontWeight: '600',
+ color: '#1f1f1f',
+ },
+ meName: {
+ textDecorationLine: 'underline',
+ },
+ points: {
+ fontWeight: '600',
+ color: '#1f1f1f',
+ },
+ headerBtnIcon: {
+ backgroundColor: '#2f7147',
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ emptyWrap: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ emptyTitle: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ fontSize: 18,
+ marginBottom: 6,
+ },
+ emptyText: {
+ color: '#2a2a2a',
+ },
+});
+
+
diff --git a/app/app/(tabs)/leaderboard/competition/[id]/invite.tsx b/app/app/(tabs)/leaderboard/competition/[id]/invite.tsx
new file mode 100644
index 000000000..8cf255f54
--- /dev/null
+++ b/app/app/(tabs)/leaderboard/competition/[id]/invite.tsx
@@ -0,0 +1,345 @@
+import React, { useMemo, useState } from 'react';
+import { View, Text, StyleSheet, FlatList, TouchableOpacity, TextInput, Alert } from 'react-native';
+import { Stack, useLocalSearchParams } from 'expo-router';
+import { getFriends, isValidEmail, isValidUsername, subscribeUsers } from '@/lib/users-store';
+import { getPendingInvitesForCompetition, subscribeInvites, addPendingInvites, syncPendingInvitesForCompetition } from '@/lib/invites-store';
+import { subscribeFriendRequests } from '@/lib/friend-requests-store';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
+import { supabase } from '@/src/lib/supabase';
+import { createInvite as createDbInvite } from '@/src/services/invites';
+import { useFocusEffect } from '@react-navigation/native';
+
+type SelectableFriend = {
+ id: string;
+ name: string;
+ username: string;
+ email: string;
+};
+
+export default function InviteScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const [friends, setFriends] = useState(() => getFriends() as unknown as SelectableFriend[]);
+ const [selected, setSelected] = useState>({});
+ const [query, setQuery] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [pending, setPending] = useState(() => (typeof id === 'string' ? getPendingInvitesForCompetition(id) : []));
+ const insets = useSafeAreaInsets();
+ const tabBarHeight = useBottomTabBarHeight();
+
+ React.useEffect(() => {
+ const unsub = subscribeInvites(() => {
+ if (typeof id === 'string') {
+ setPending(getPendingInvitesForCompetition(id));
+ }
+ });
+ const unsubUsers = subscribeUsers(() => {
+ setFriends(getFriends() as unknown as SelectableFriend[]);
+ });
+ const unsubFriendReq = subscribeFriendRequests(() => {
+ // no-op; subscribe triggers store refresh which cascades via subscribeUsers
+ });
+ return unsub;
+ }, [id]);
+
+ // On focus, refresh pending invites for this competition from server,
+ // so that already-sent invites are reflected when re-entering.
+ useFocusEffect(
+ React.useCallback(() => {
+ if (typeof id === 'string') {
+ void syncPendingInvitesForCompetition(id);
+ }
+ }, [id])
+ );
+
+ // Build a set of friend userIds already invited to this competition (pending)
+ const invitedFriendIds = useMemo(() => {
+ const ids = new Set();
+ for (const inv of pending) {
+ if (inv.target.type === 'friend') {
+ ids.add((inv.target as { type: 'friend'; userId: string }).userId);
+ }
+ }
+ return ids;
+ }, [pending]);
+
+ const toggle = (userId: string) => {
+ setSelected((prev) => ({ ...prev, [userId]: !prev[userId] }));
+ };
+
+ const onSendSelected = async () => {
+ const friendIds = Object.keys(selected).filter((k) => selected[k]);
+ if (friendIds.length === 0 || typeof id !== 'string') {
+ Alert.alert('VÀlj minst en vÀn');
+ return;
+ }
+ setSubmitting(true);
+ try {
+ let ok = 0;
+ let fail = 0;
+ let firstError: string | null = null;
+ // Skip already invited friends just in case
+ const filtered = friendIds.filter((uid) => !invitedFriendIds.has(uid));
+ for (const userId of filtered) {
+ try {
+ await createDbInvite(id, userId);
+ ok++;
+ } catch (e: any) {
+ if (!firstError) firstError = e?.message ?? String(e);
+ fail++;
+ }
+ }
+ // Immediately reflect locally to prevent re-inviting right away
+ if (filtered.length > 0) {
+ addPendingInvites(id, filtered.map((uid) => ({ type: 'friend', userId: uid } as const)));
+ }
+ const base = `Skickade ${ok} inbjudning(ar)` + (fail ? `, misslyckades: ${fail}` : '');
+ if (fail && firstError) {
+ Alert.alert('Klar (med fel)', `${base}\n\nFel: ${firstError}`);
+ } else {
+ Alert.alert('Klar', base);
+ }
+ setSelected({});
+ } catch (e) {
+ Alert.alert('NÄgot gick fel', 'Försök igen senare.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const onInviteQuery = async () => {
+ if (typeof id !== 'string') return;
+ const trimmed = query.trim();
+ if (!trimmed) {
+ Alert.alert('Ange ett anvĂ€ndarnamn eller en eâpostadress');
+ return;
+ }
+ let userId: string | null = null;
+ if (isValidEmail(trimmed)) {
+ const { data } = await supabase.from('profiles').select('id').eq('email', trimmed).single();
+ userId = (data as any)?.id ?? null;
+ } else if (isValidUsername(trimmed)) {
+ const { data } = await supabase.from('profiles').select('id').eq('username', trimmed).single();
+ userId = (data as any)?.id ?? null;
+ }
+ if (!userId) {
+ Alert.alert('Hittade inte anvĂ€ndaren', 'Kontrollera stavning eller anvĂ€nd en giltig eâpost.');
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await createDbInvite(id, userId);
+ Alert.alert('Inbjudan skickad', 'Notis skickas till anvÀndaren.');
+ // Reflect locally
+ addPendingInvites(id, [{ type: 'friend', userId }]);
+ setQuery('');
+ } catch (e) {
+ Alert.alert('NÄgot gick fel', 'Försök igen senare.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const renderFriend = ({ item }: { item: SelectableFriend }) => {
+ const isAlreadyInvited = invitedFriendIds.has(item.id);
+ const checked = !isAlreadyInvited && !!selected[item.id];
+ return (
+ {
+ if (!isAlreadyInvited) toggle(item.id);
+ }}
+ disabled={isAlreadyInvited}
+ >
+
+
+
+ {item.name}
+
+
+ @{item.username}
+
+
+
+ );
+ };
+
+ return (
+
+
+
+ VĂ€nlista
+ f.id}
+ renderItem={renderFriend}
+ contentContainerStyle={styles.listContent}
+ />
+
+ Skicka inbjudan
+
+
+
+
+ Sök och bjud in nya
+
+
+
+ Bjud in
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#a7c7a3',
+ padding: 16,
+ },
+ sectionTitle: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ marginBottom: 8,
+ },
+ listContent: {
+ backgroundColor: 'rgba(255,255,255,0.9)',
+ borderRadius: 12,
+ paddingVertical: 6,
+ marginBottom: 12,
+ },
+ friendRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ },
+ checkbox: {
+ width: 18,
+ height: 18,
+ borderRadius: 4,
+ borderWidth: 2,
+ borderColor: '#2f7147',
+ marginRight: 10,
+ backgroundColor: 'transparent',
+ },
+ checkboxChecked: {
+ backgroundColor: '#2f7147',
+ },
+ checkboxDisabled: {
+ borderColor: '#bdbdbd',
+ backgroundColor: '#e9e9e9',
+ },
+ friendMain: {
+ flex: 1,
+ },
+ friendName: {
+ fontWeight: '600',
+ color: '#1f1f1f',
+ },
+ friendNameDisabled: {
+ color: '#b0b0b0',
+ },
+ friendMeta: {
+ color: '#2a2a2a',
+ fontSize: 12,
+ },
+ friendMetaDisabled: {
+ color: '#b0b0b0',
+ },
+ primaryBtn: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 12,
+ borderRadius: 10,
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ primaryBtnText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+ divider: {
+ height: 1,
+ backgroundColor: 'rgba(0,0,0,0.1)',
+ marginVertical: 12,
+ },
+ searchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ searchInput: {
+ flex: 1,
+ backgroundColor: '#ffffff',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderWidth: 1,
+ borderColor: '#e1e1e1',
+ },
+ secondaryBtn: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 10,
+ paddingHorizontal: 14,
+ borderRadius: 10,
+ },
+ secondaryBtnText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+ btnDisabled: {
+ opacity: 0.6,
+ },
+ pendingRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: 8,
+ },
+ pendingText: {
+ color: '#1f1f1f',
+ },
+ acceptBtn: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 6,
+ paddingHorizontal: 10,
+ borderRadius: 8,
+ },
+ acceptBtnText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+});
+
+
diff --git a/app/app/(tabs)/leaderboard/create.tsx b/app/app/(tabs)/leaderboard/create.tsx
new file mode 100644
index 000000000..a964bbf12
--- /dev/null
+++ b/app/app/(tabs)/leaderboard/create.tsx
@@ -0,0 +1,311 @@
+import React, { useState } from 'react';
+import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert, Platform, TouchableWithoutFeedback } from 'react-native';
+import { Stack, useRouter } from 'expo-router';
+import { createCompetition } from '@/lib/competitions-store';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { supabase } from '@/src/lib/supabase';
+import DateTimePicker, { AndroidEvent } from '@react-native-community/datetimepicker';
+
+export default function CreateCompetitionScreen() {
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [startDate, setStartDate] = useState(null);
+ const [endDate, setEndDate] = useState(null);
+ const [showPicker, setShowPicker] = useState(false);
+ const [pickerMode, setPickerMode] = useState<'start' | 'end'>('start');
+
+ function formatYmd(d: Date | null): string {
+ if (!d) return '';
+ const yyyy = d.getFullYear();
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+ }
+
+ function startOfToday(): Date {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ return d;
+ }
+ function dateOnly(d: Date): Date {
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
+ }
+ function yesterday(): Date {
+ const d = startOfToday();
+ d.setDate(d.getDate() - 1);
+ return d;
+ }
+ function tomorrow(): Date {
+ const d = startOfToday();
+ d.setDate(d.getDate() + 1);
+ return d;
+ }
+
+ function openPicker(mode: 'start' | 'end') {
+ setPickerMode(mode);
+ setShowPicker(true);
+ }
+
+ function onChangeDate(event: AndroidEvent | any, selected?: Date) {
+ // Android: dialog-based picker; close when dismissed or set
+ if (Platform.OS === 'android') {
+ if (event?.type === 'dismissed' || event?.type === 'set') {
+ setShowPicker(false);
+ }
+ }
+ if (!selected) return;
+ const today = startOfToday();
+ const chosen = dateOnly(selected);
+ if (pickerMode === 'start') {
+ // Only allow dates strictly before today
+ if (chosen >= today) {
+ Alert.alert('Ogiltigt startdatum', 'Startdatum mÄste vara före dagens datum.');
+ return;
+ }
+ setStartDate(chosen);
+ if (endDate && chosen && dateOnly(endDate) < chosen) {
+ setEndDate(null);
+ }
+ } else {
+ // Only allow dates strictly after today, and not before startDate if set
+ if (chosen <= today) {
+ Alert.alert('Ogiltigt slutdatum', 'Slutdatum mÄste vara efter dagens datum.');
+ return;
+ }
+ if (startDate && chosen < dateOnly(startDate)) {
+ Alert.alert('Ogiltigt slutdatum', 'Slutdatum kan inte vara före startdatum.');
+ return;
+ }
+ setEndDate(chosen);
+ }
+ }
+
+ const onCreate = async () => {
+ if (!name.trim()) {
+ Alert.alert('Namn krÀvs', 'Ange ett namn för tÀvlingen.');
+ return;
+ }
+ if (startDate && endDate && endDate < startDate) {
+ Alert.alert('Ogiltiga datum', 'Slutdatum kan inte vara före startdatum.');
+ return;
+ }
+ try {
+ // Precheck: must be logged in (auth.uid present)
+ const { data: userData } = await supabase.auth.getUser();
+ if (!userData?.user?.id) {
+ Alert.alert('Inte inloggad', 'Logga in för att skapa en tÀvling.');
+ return;
+ }
+ const comp = await createCompetition({
+ name: name.trim(),
+ description: description.trim() || undefined,
+ startDate: startDate ? formatYmd(startDate) : undefined,
+ endDate: endDate ? formatYmd(endDate) : undefined,
+ });
+ Alert.alert('TÀvling skapad', 'InbjudningslÀnk har kopierats.');
+ router.replace({
+ pathname: '/(tabs)/leaderboard/competition/[id]',
+ params: { id: comp.id, name: comp.name },
+ });
+ } catch (e: any) {
+ Alert.alert(
+ 'Kunde inte skapa tÀvling',
+ e?.message || 'Kontrollera att du Àr inloggad och försök igen.'
+ );
+ }
+ };
+
+ return (
+
+
+
+ Namn
+
+
+ Beskrivning (valfritt)
+
+
+
+ Startdatum (valfritt)
+ {startDate ? (
+ { setStartDate(null); }}>
+ Ă
ngra
+
+ ) : null}
+
+ openPicker('start')}>
+
+ {startDate ? formatYmd(startDate) : 'VĂ€lj startdatum'}
+
+
+
+
+ Slutdatum (valfritt)
+ {endDate ? (
+ setEndDate(null)}>
+ Ă
ngra
+
+ ) : null}
+
+ openPicker('end')}>
+
+ {endDate ? formatYmd(endDate) : 'VĂ€lj slutdatum'}
+
+
+
+
+ Skapa privat tÀvling
+
+
+ TÀvlingar Àr privata. Bjud in med lÀnk eller anvÀndarnamn/e-post.
+
+ {showPicker && (
+ Platform.OS === 'ios' ? (
+ setShowPicker(false)}>
+
+ { /* consume */ }}>
+
+ tomorrow() ? dateOnly(startDate) : tomorrow()))
+ }
+ mode="date"
+ display="spinner"
+ onChange={onChangeDate}
+ minimumDate={
+ pickerMode === 'end'
+ ? (startDate && dateOnly(startDate) > tomorrow() ? dateOnly(startDate) : tomorrow())
+ : undefined
+ }
+ maximumDate={
+ pickerMode === 'start' ? yesterday() : undefined
+ }
+ />
+
+
+
+
+ ) : (
+ tomorrow() ? dateOnly(startDate) : tomorrow()))
+ }
+ mode="date"
+ display="default"
+ onChange={onChangeDate}
+ minimumDate={
+ pickerMode === 'end'
+ ? (startDate && dateOnly(startDate) > tomorrow() ? dateOnly(startDate) : tomorrow())
+ : undefined
+ }
+ maximumDate={
+ pickerMode === 'start' ? yesterday() : undefined
+ }
+ />
+ )
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#a7c7a3',
+ paddingHorizontal: 16,
+ },
+ card: {
+ backgroundColor: 'rgba(255,255,255,0.9)',
+ borderRadius: 12,
+ padding: 16,
+ },
+ label: {
+ fontWeight: '600',
+ marginTop: 12,
+ marginBottom: 6,
+ color: '#1f1f1f',
+ },
+ input: {
+ backgroundColor: '#ffffff',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderWidth: 1,
+ borderColor: '#e1e1e1',
+ },
+ button: {
+ backgroundColor: '#2f7147',
+ paddingVertical: 12,
+ borderRadius: 10,
+ alignItems: 'center',
+ marginTop: 18,
+ },
+ buttonText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+ labelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginTop: 12,
+ marginBottom: 6,
+ },
+ dateBtn: {
+ backgroundColor: '#ffffff',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ borderWidth: 1,
+ borderColor: '#e1e1e1',
+ },
+ dateBtnText: {
+ color: '#1f1f1f',
+ fontWeight: '600',
+ },
+ undoText: {
+ color: '#2f7147',
+ fontWeight: '700',
+ },
+ pickerOverlay: {
+ ...StyleSheet.absoluteFillObject as any,
+ backgroundColor: 'rgba(0,0,0,0.2)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ pickerCard: {
+ width: '92%',
+ backgroundColor: '#fff',
+ paddingVertical: 12,
+ paddingHorizontal: 12,
+ borderRadius: 12,
+ shadowColor: '#000',
+ shadowOpacity: 0.15,
+ shadowRadius: 10,
+ shadowOffset: { width: 0, height: 4 },
+ elevation: 4,
+ },
+ tip: {
+ marginTop: 12,
+ fontSize: 12,
+ color: '#1f1f1f',
+ },
+});
+
+
diff --git a/app/app/(tabs)/leaderboard/index.tsx b/app/app/(tabs)/leaderboard/index.tsx
new file mode 100644
index 000000000..aa57e7659
--- /dev/null
+++ b/app/app/(tabs)/leaderboard/index.tsx
@@ -0,0 +1,196 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
+import { Stack, Link } from 'expo-router';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { getCompetitions, subscribe, loadCompetitionsFromSupabase } from '@/lib/competitions-store';
+import type { Competition } from '@/lib/competitions-store';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
+import BgSvg from '@/assets/images/Bakggrundscomp1.2 (kopia).svg';
+
+export default function LeaderboardListScreen() {
+ const [competitions, setCompetitions] = useState(getCompetitions());
+ const [refreshing, setRefreshing] = useState(false);
+ const insets = useSafeAreaInsets();
+ const tabBarHeight = useBottomTabBarHeight();
+
+ useEffect(() => {
+ const unsub = subscribe(setCompetitions);
+ // initial load from Supabase
+ void loadCompetitionsFromSupabase();
+ return unsub;
+ }, []);
+
+ const onRefresh = useCallback(() => {
+ setRefreshing(true);
+ (async () => {
+ await loadCompetitionsFromSupabase();
+ setCompetitions(getCompetitions());
+ setRefreshing(false);
+ })();
+ }, []);
+
+ const renderItem = ({ item }: { item: Competition }) => {
+ return (
+
+
+
+
+ {item.name}
+ {item.description ? {item.description} : null}
+
+
+ {item.participants.length} deltagare
+ Uppd: {item.updatedAt}
+
+
+
+
+ );
+ };
+
+ return (
+
+
+ (
+
+
+
+
+
+ ),
+ }}
+ />
+ {competitions.length === 0 ? (
+
+ Inga tÀvlingar Ànnu
+ Skapa en privat tĂ€vling med â+â eller gĂ„ med via inbjudan.
+
+
+ Skapa tÀvling
+
+
+
+ ) : (
+ c.id}
+ renderItem={renderItem}
+ contentContainerStyle={[
+ styles.listContent,
+ {
+ paddingTop: insets.top + 56,
+ paddingBottom: 60 + insets.bottom + tabBarHeight,
+ },
+ ]}
+ refreshControl={}
+ />
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ },
+ listContent: {
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ paddingBottom: 120,
+ },
+ row: {
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ backgroundColor: 'rgba(255,255,255,0.85)',
+ marginHorizontal: 0,
+ marginBottom: 10,
+ borderRadius: 12,
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ rowTapArea: {
+ flex: 1,
+ },
+ rowMain: {
+ marginBottom: 6,
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#1f1f1f',
+ },
+ subtitle: {
+ marginTop: 2,
+ color: '#3a3a3a',
+ },
+ rowMeta: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ meta: {
+ fontSize: 12,
+ color: '#2a2a2a',
+ },
+ emptyWrap: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: 24,
+ },
+ emptyTitle: {
+ fontWeight: '700',
+ color: '#1f1f1f',
+ fontSize: 18,
+ marginBottom: 6,
+ },
+ emptyText: {
+ color: '#2a2a2a',
+ textAlign: 'center',
+ },
+ primaryBtn: {
+ marginTop: 12,
+ backgroundColor: '#2f7147',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 10,
+ },
+ primaryBtnText: {
+ color: '#fff',
+ fontWeight: '700',
+ },
+ headerBtn: {
+ backgroundColor: '#2f7147',
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ headerGlassBtn: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ backgroundColor: 'rgba(47,113,71,0.15)',
+ borderWidth: 1,
+ borderColor: 'rgba(47,113,71,0.35)',
+ },
+ inviteSmallBtn: {
+ // removed
+ },
+});
+
+
diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx
new file mode 100644
index 000000000..3aeb0bd0e
--- /dev/null
+++ b/app/app/_layout.tsx
@@ -0,0 +1,41 @@
+import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
+import { Link, Stack } from 'expo-router';
+import { StatusBar } from 'expo-status-bar';
+import 'react-native-reanimated';
+import { TouchableOpacity, Text } from 'react-native';
+
+import { useColorScheme } from '@/hooks/use-color-scheme';
+
+export const unstable_settings = {
+ anchor: '(tabs)',
+};
+
+export default function RootLayout() {
+ const colorScheme = useColorScheme();
+
+ return (
+
+
+
+ (
+
+
+ {'< Hem'}
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/app/login.tsx b/app/app/login.tsx
new file mode 100644
index 000000000..a01a37ed8
--- /dev/null
+++ b/app/app/login.tsx
@@ -0,0 +1,170 @@
+import React, { useState } from 'react';
+import { View, Text, TextInput, StyleSheet, TouchableOpacity, ActivityIndicator, Alert, BackHandler } from 'react-native';
+import { Stack, router } from 'expo-router';
+import { supabase } from '@/src/lib/supabase';
+import { setToken } from '@/lib/session';
+import { updateCurrentUser } from '@/lib/users-store';
+import { useFocusEffect } from '@react-navigation/native';
+
+export default function LoginScreen() {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ // Om anvÀndaren trycker Androids back-knapp pÄ login -> gÄ till Hem
+ useFocusEffect(
+ React.useCallback(() => {
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
+ router.replace('/');
+ return true;
+ });
+ return () => sub.remove();
+ }, [])
+ );
+
+ const onSubmit = async () => {
+ try {
+ setLoading(true);
+ const trimmedEmail = email.trim();
+ if (!trimmedEmail) {
+ Alert.alert('E-post krÀvs');
+ return;
+ }
+ if (!password) {
+ Alert.alert('Lösenord krÀvs');
+ return;
+ }
+
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email: trimmedEmail,
+ password,
+ });
+ if (error) {
+ Alert.alert('Fel', error.message);
+ return;
+ }
+ // Spara access token för appens "Àr inloggad"-logik
+ if (data.session?.access_token) {
+ await setToken(data.session.access_token);
+ }
+ // Uppdatera lokalt anvÀndarobjekt för att visa namn/anvÀndarnamn pÄ profilsidan
+ const userRes = await supabase.auth.getUser();
+ const meta = userRes.data.user?.user_metadata ?? {};
+ const authUser = userRes.data.user;
+ // Autofix: sÀkerstÀll att profiles har ett username (unikt, lowercase)
+ try {
+ if (authUser?.id) {
+ const meId = authUser.id;
+ const emailLower = (authUser.email ?? '').toLowerCase();
+ const baseMeta = typeof meta.username === 'string' ? meta.username.trim().toLowerCase() : '';
+ let candidate = baseMeta || (emailLower ? emailLower.split('@')[0] : '');
+ if (!candidate) candidate = 'user';
+ // LĂ€s befintlig profil
+ let hasUsername = false;
+ try {
+ const { data: prof } = await supabase.from('profiles').select('username').eq('id', meId).single();
+ hasUsername = !!(prof && (prof as any).username);
+ } catch {
+ // om single() felar, fortsÀtt och försök skriva
+ }
+ if (!hasUsername) {
+ // Kolla snabb konflikt (case-insensitivt)
+ const { data: taken } = await supabase
+ .from('profiles')
+ .select('id')
+ .ilike('username', candidate)
+ .limit(1);
+ let usernameToUse = candidate;
+ if ((taken ?? []).some((r) => r.id !== meId)) {
+ usernameToUse = `${candidate}_${Math.random().toString(36).slice(2, 6)}`;
+ }
+ await supabase
+ .from('profiles')
+ .upsert(
+ {
+ id: meId,
+ email: emailLower || null,
+ username: usernameToUse,
+ },
+ { onConflict: 'id' }
+ );
+ }
+ }
+ } catch {
+ // ignorera tyst; DB-constraint/trigger kan ocksÄ hantera
+ }
+ const first = typeof meta.first_name === 'string' ? meta.first_name : '';
+ const last = typeof meta.last_name === 'string' ? meta.last_name : '';
+ const name = [first, last].filter(Boolean).join(' ') || trimmedEmail.split('@')[0];
+ const uname = typeof meta.username === 'string' ? meta.username : trimmedEmail.split('@')[0];
+ updateCurrentUser({ name, username: uname });
+ // Navigera direkt till profilsidan
+ router.replace('/(tabs)/acount');
+ } catch (e: any) {
+ Alert.alert('Fel', e?.message ?? 'NÄgot gick fel');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ (
+ {
+ router.replace('/');
+ }}
+ style={{ paddingHorizontal: 8, paddingVertical: 6 }}
+ >
+ âč Hem
+
+ ),
+ }}
+ />
+
+ E-post
+
+ Lösenord
+
+
+ {loading ? : Logga in}
+
+
+
+ router.push('/register')} accessibilityLabel="Skapa konto">
+
+ Skapa konto
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#a7c7a3', padding: 16 },
+ card: { backgroundColor: 'rgba(255,255,255,0.92)', borderRadius: 12, padding: 16 },
+ label: { color: '#2a2a2a', marginTop: 8, marginBottom: 6, fontWeight: '600' },
+ input: { backgroundColor: '#fff', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, borderWidth: 1, borderColor: '#e5e5e5' },
+ primaryBtn: { marginTop: 16, backgroundColor: '#2f7147', paddingVertical: 14, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
+ primaryBtnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
+});
+
+
diff --git a/app/app/modal.tsx b/app/app/modal.tsx
new file mode 100644
index 000000000..6dfbc1ab9
--- /dev/null
+++ b/app/app/modal.tsx
@@ -0,0 +1,29 @@
+import { Link } from 'expo-router';
+import { StyleSheet } from 'react-native';
+
+import { ThemedText } from '@/components/themed-text';
+import { ThemedView } from '@/components/themed-view';
+
+export default function ModalScreen() {
+ return (
+
+ This is a modal
+
+ Go to home screen
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 20,
+ },
+ link: {
+ marginTop: 15,
+ paddingVertical: 15,
+ },
+});
diff --git a/app/app/notifications.tsx b/app/app/notifications.tsx
new file mode 100644
index 000000000..a8cf6f716
--- /dev/null
+++ b/app/app/notifications.tsx
@@ -0,0 +1,187 @@
+import React, { useEffect, useMemo, useState, useCallback } from 'react';
+import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, RefreshControl } from 'react-native';
+import { Stack } from 'expo-router';
+import { supabase } from '@/src/lib/supabase';
+import { fetchNotifications, markNotificationRead, subscribeToNotifications, type NotificationRow } from '@/src/services/notifications';
+import { acceptInvite as acceptDbInvite, declineInvite as declineDbInvite } from '@/src/services/invites';
+import { loadCompetitionsFromSupabase } from '@/lib/competitions-store';
+
+export default function NotificationsScreen() {
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [loggedIn, setLoggedIn] = useState(false);
+
+ useEffect(() => {
+ (async () => {
+ const { data } = await supabase.auth.getUser();
+ const ok = !!data?.user?.id;
+ setLoggedIn(ok);
+ if (!ok) {
+ setLoading(false);
+ return;
+ }
+ try {
+ const rows = await fetchNotifications();
+ setItems(rows);
+ } catch (e: any) {
+ Alert.alert('Kunde inte hÀmta notiser', e?.message ?? 'Försök igen senare.');
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, []);
+
+ useEffect(() => {
+ if (!loggedIn) return;
+ const unsub = subscribeToNotifications((row) => {
+ setItems((prev) => [row, ...prev]);
+ });
+ return () => { unsub(); };
+ }, [loggedIn]);
+
+ const onRefresh = useCallback(() => {
+ if (!loggedIn) return;
+ setRefreshing(true);
+ (async () => {
+ try {
+ const rows = await fetchNotifications();
+ setItems(rows);
+ } catch (e: any) {
+ Alert.alert('Kunde inte uppdatera notiser', e?.message ?? 'Försök igen senare.');
+ } finally {
+ setRefreshing(false);
+ }
+ })();
+ }, [loggedIn]);
+
+ return (
+
+
+ {!loggedIn ? (
+
+ Logga in för att se notiser.
+
+ ) : loading ? (
+
+ Laddar...
+
+ ) : items.length === 0 ? (
+
+ Inga notiser just nu.
+
+ ) : (
+ n.id}
+ contentContainerStyle={styles.listContent}
+ renderItem={({ item }) => {
+ const unread = !item.read_at;
+ const created = new Date(item.created_at).toLocaleString();
+ return (
+ {
+ if (item.type === 'competition_invite' && item.metadata && (item.metadata as any).invite_id) {
+ const inviteId = (item.metadata as any).invite_id as string;
+ const compId = (item.metadata as any).competition_id as string | undefined;
+ Alert.alert(
+ 'Inbjudan',
+ 'Vill du gÄ med i tÀvlingen?',
+ [
+ {
+ text: 'Avböj',
+ style: 'cancel',
+ onPress: async () => {
+ try {
+ await declineDbInvite(inviteId);
+ await markNotificationRead(item.id);
+ setItems((prev) => prev.map((n) => (n.id === item.id ? { ...n, read_at: new Date().toISOString() } : n)));
+ } catch (e: any) {
+ Alert.alert('Fel', e?.message ?? 'Kunde inte avböja.');
+ }
+ },
+ },
+ {
+ text: 'Acceptera',
+ style: 'default',
+ onPress: async () => {
+ try {
+ await acceptDbInvite(inviteId);
+ await markNotificationRead(item.id);
+ setItems((prev) => prev.map((n) => (n.id === item.id ? { ...n, read_at: new Date().toISOString() } : n)));
+ await loadCompetitionsFromSupabase();
+ Alert.alert('Klart', 'Du har gÄtt med i tÀvlingen.');
+ } catch (e: any) {
+ Alert.alert('Fel', e?.message ?? 'Kunde inte acceptera.');
+ }
+ },
+ },
+ ],
+ { cancelable: true }
+ );
+ } else {
+ // Standard: markera som lÀst
+ setItems((prev) => prev.map((n) => (n.id === item.id ? { ...n, read_at: n.read_at ?? new Date().toISOString() } : n)));
+ try {
+ await markNotificationRead(item.id);
+ } catch (e: any) {
+ Alert.alert('Kunde inte markera som lÀst', e?.message ?? 'Försök igen senare.');
+ }
+ }
+ }}
+ >
+
+
+ {item.type}
+
+
+
+ {item.title}
+ {item.body ? {item.body} : null}
+ {created}
+
+
+ );
+ }}
+ refreshControl={}
+ />
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#a7c7a3' },
+ listContent: { padding: 16 },
+ row: {
+ flexDirection: 'row',
+ gap: 10,
+ backgroundColor: 'rgba(255,255,255,0.92)',
+ borderRadius: 12,
+ padding: 12,
+ marginBottom: 8,
+ },
+ badge: {
+ backgroundColor: '#2f7147',
+ borderRadius: 8,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ alignSelf: 'flex-start',
+ },
+ badgeText: { color: '#fff', fontWeight: '700', fontSize: 12 },
+ main: { flex: 1 },
+ title: { fontWeight: '700', color: '#1f1f1f' },
+ unread: { textDecorationLine: 'underline' },
+ msg: { color: '#2a2a2a', marginTop: 2 },
+ date: { color: '#2a2a2a', marginTop: 4, fontSize: 12 },
+ actions: { flexDirection: 'row', gap: 8, marginTop: 8 },
+ actionBtn: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8 },
+ accept: { backgroundColor: '#2f7147' },
+ decline: { backgroundColor: '#b83a3a' },
+ actionText: { color: '#fff', fontWeight: '700' },
+ emptyWrap: { flex: 1, alignItems: 'center', justifyContent: 'center' },
+ emptyText: { color: '#1f1f1f' },
+});
+
+
diff --git a/app/app/register.tsx b/app/app/register.tsx
new file mode 100644
index 000000000..88febb00f
--- /dev/null
+++ b/app/app/register.tsx
@@ -0,0 +1,246 @@
+import React, { useState } from 'react';
+import { View, Text, TextInput, StyleSheet, TouchableOpacity, ActivityIndicator, Alert } from 'react-native';
+import { Stack, router } from 'expo-router';
+import { supabase } from '@/src/lib/supabase';
+import { setToken } from '@/lib/session';
+import { updateCurrentUser } from '@/lib/users-store';
+
+export default function RegisterScreen() {
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async () => {
+ try {
+ setLoading(true);
+ const trimmedFirst = firstName.trim();
+ const trimmedLast = lastName.trim();
+ const trimmedUser = username.trim();
+ const normalizedUser = trimmedUser.toLowerCase();
+ const trimmedEmail = email.trim();
+ const normalizedEmail = trimmedEmail.toLowerCase();
+ if (!trimmedFirst) {
+ Alert.alert('Förnamn krÀvs');
+ return;
+ }
+ if (!trimmedLast) {
+ Alert.alert('Efternamn krÀvs');
+ return;
+ }
+ if (!trimmedUser) {
+ Alert.alert('AnvÀndarnamn krÀvs');
+ return;
+ }
+ if (!/^[a-z0-9_.]{3,}$/.test(normalizedUser)) {
+ Alert.alert('Ogiltigt anvÀndarnamn', 'Minst 3 tecken. TillÄtna: a-ö, siffror, _ och .');
+ return;
+ }
+ if (!trimmedEmail) {
+ Alert.alert('E-post krÀvs');
+ return;
+ }
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
+ Alert.alert('Ogiltig e-postadress');
+ return;
+ }
+ if (!password) {
+ Alert.alert('Lösenord krÀvs');
+ return;
+ }
+ // Kontrollera unikt anvÀndarnamn (case-insensitivt) mot Supabase
+ try {
+ const { data: taken } = await supabase
+ .from('profiles')
+ .select('id')
+ .ilike('username', normalizedUser)
+ .limit(1);
+ if (taken && taken.length > 0) {
+ Alert.alert('Upptaget anvÀndarnamn', 'VÀlj ett annat unikt anvÀndarnamn.');
+ return;
+ }
+ } catch {
+ // Vid nÀtverksfel, var försiktig: avbryt istÀllet för att skapa dubletter
+ Alert.alert('Kunde inte verifiera anvÀndarnamnet', 'Försök igen om en stund.');
+ return;
+ }
+ // Förkontroll: unik e-post i profiles om kolumnen finns (ignorera om den saknas)
+ try {
+ const { data: emailRows } = await supabase
+ .from('profiles')
+ .select('id')
+ .ilike('email', normalizedEmail)
+ .limit(1);
+ if (emailRows && emailRows.length > 0) {
+ Alert.alert('Eâpost upptagen', 'Eâpostadressen anvĂ€nds redan. VĂ€lj en annan.');
+ return;
+ }
+ } catch {
+ // Ignorera â lita pĂ„ Auths unikhet för eâpost om kolumnen saknas
+ }
+ // Skapa anvÀndare i Supabase Auth. `profiles`-posten skapas automatiskt via DB-trigger efter signup.
+ const { data, error } = await supabase.auth.signUp({
+ email: normalizedEmail,
+ password,
+ options: {
+ data: {
+ username: normalizedUser,
+ first_name: trimmedFirst,
+ last_name: trimmedLast,
+ },
+ },
+ });
+ if (error) {
+ const msg = (error as any)?.message?.toString?.() ?? '';
+ if (msg.toLowerCase().includes('already registered') || msg.toLowerCase().includes('exists')) {
+ Alert.alert('Eâpost upptagen', 'Eâpostadressen anvĂ€nds redan. VĂ€lj en annan.');
+ } else {
+ Alert.alert('Fel', msg || 'Kunde inte skapa konto.');
+ }
+ return;
+ }
+ // Om e-postverifiering Àr avstÀngd kan session finnas direkt -> navigera till profil.
+ if (data.session) {
+ if (data.session.access_token) {
+ await setToken(data.session.access_token);
+ }
+ // Försök skapa/uppdatera profil med anvĂ€ndarnamn och eâpost direkt
+ try {
+ const { data: userRes } = await supabase.auth.getUser();
+ const authUser = userRes.user;
+ if (authUser?.id) {
+ await supabase
+ .from('profiles')
+ .upsert(
+ {
+ id: authUser.id,
+ username: normalizedUser,
+ first_name: trimmedFirst,
+ last_name: trimmedLast,
+ full_name: [trimmedFirst, trimmedLast].filter(Boolean).join(' ') || undefined,
+ email: normalizedEmail,
+ },
+ { onConflict: 'id' }
+ );
+ }
+ } catch {
+ // ignoreras; profilen kan fyllas vid nÀsta inloggning
+ }
+ updateCurrentUser({
+ name: [trimmedFirst, trimmedLast].filter(Boolean).join(' ') || normalizedUser,
+ username: normalizedUser,
+ });
+ router.replace('/(tabs)/acount');
+ return;
+ }
+ // För projekt dÀr verifiering Àr avstÀngd men ingen session returneras, försök logga in direkt.
+ const { data: signInData, error: signInError } = await supabase.auth.signInWithPassword({
+ email: normalizedEmail,
+ password,
+ });
+ if (!signInError) {
+ if (signInData.session?.access_token) {
+ await setToken(signInData.session.access_token);
+ }
+ // Upsert profil efter lyckad inloggning
+ try {
+ const { data: userRes } = await supabase.auth.getUser();
+ const authUser = userRes.user;
+ if (authUser?.id) {
+ await supabase
+ .from('profiles')
+ .upsert(
+ {
+ id: authUser.id,
+ username: normalizedUser,
+ first_name: trimmedFirst,
+ last_name: trimmedLast,
+ full_name: [trimmedFirst, trimmedLast].filter(Boolean).join(' ') || undefined,
+ email: normalizedEmail,
+ },
+ { onConflict: 'id' }
+ );
+ }
+ } catch {
+ // ignoreras
+ }
+ updateCurrentUser({
+ name: [trimmedFirst, trimmedLast].filter(Boolean).join(' ') || normalizedUser,
+ username: normalizedUser,
+ });
+ router.replace('/(tabs)/acount');
+ return;
+ }
+ // Om verifiering krĂ€vs fĂ„r anvĂ€ndaren mail â be dem logga in efter att ha verifierat.
+ Alert.alert('Verifiering skickad', 'Kolla din e-post för att bekrÀfta kontot. Logga in efter verifiering.');
+ router.replace('/login');
+ } catch (e: any) {
+ Alert.alert('Fel', e?.message ?? 'NÄgot gick fel');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Förnamn
+
+ Efternamn
+
+ AnvÀndarnamn
+
+ E-post
+
+ Lösenord
+
+
+ {loading ? : Skapa konto}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#a7c7a3', padding: 16 },
+ card: { backgroundColor: 'rgba(255,255,255,0.92)', borderRadius: 12, padding: 16 },
+ label: { color: '#2a2a2a', marginTop: 8, marginBottom: 6, fontWeight: '600' },
+ input: { backgroundColor: '#fff', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, borderWidth: 1, borderColor: '#e5e5e5' },
+ primaryBtn: { marginTop: 16, backgroundColor: '#2f7147', paddingVertical: 14, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
+ primaryBtnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
+});
+
+
diff --git a/app/assets/images/Bakggrundscomp1.2 (kopia).svg b/app/assets/images/Bakggrundscomp1.2 (kopia).svg
new file mode 100644
index 000000000..059ceab1b
--- /dev/null
+++ b/app/assets/images/Bakggrundscomp1.2 (kopia).svg
@@ -0,0 +1,332 @@
+
+
\ No newline at end of file
diff --git a/app/assets/images/Hundtram (kopia).png b/app/assets/images/Hundtram (kopia).png
new file mode 100644
index 000000000..75753b360
Binary files /dev/null and b/app/assets/images/Hundtram (kopia).png differ
diff --git a/app/assets/images/Pantbild.png b/app/assets/images/Pantbild.png
new file mode 100644
index 000000000..776253a27
Binary files /dev/null and b/app/assets/images/Pantbild.png differ
diff --git a/app/assets/images/Vandring.png b/app/assets/images/Vandring.png
new file mode 100644
index 000000000..9d076652a
Binary files /dev/null and b/app/assets/images/Vandring.png differ
diff --git a/app/assets/images/android-icon-background.png b/app/assets/images/android-icon-background.png
new file mode 100644
index 000000000..5ffefc5bb
Binary files /dev/null and b/app/assets/images/android-icon-background.png differ
diff --git a/app/assets/images/android-icon-foreground.png b/app/assets/images/android-icon-foreground.png
new file mode 100644
index 000000000..3a9e5016d
Binary files /dev/null and b/app/assets/images/android-icon-foreground.png differ
diff --git a/app/assets/images/android-icon-monochrome.png b/app/assets/images/android-icon-monochrome.png
new file mode 100644
index 000000000..77484ebdb
Binary files /dev/null and b/app/assets/images/android-icon-monochrome.png differ
diff --git a/app/assets/images/favicon.png b/app/assets/images/favicon.png
new file mode 100644
index 000000000..408bd7466
Binary files /dev/null and b/app/assets/images/favicon.png differ
diff --git a/app/assets/images/home-bg.svg b/app/assets/images/home-bg.svg
new file mode 100644
index 000000000..f10c5c2fc
--- /dev/null
+++ b/app/assets/images/home-bg.svg
@@ -0,0 +1,221 @@
+
+