diff --git a/apps/mobile/.claude/settings.json b/apps/mobile/.claude/settings.json new file mode 100644 index 0000000..176e6a5 --- /dev/null +++ b/apps/mobile/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "expo@claude-plugins-official": true + } +} diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..eca5e49 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,3 @@ +# Leave unset in development: the app uses the same host as Metro (your LAN IP) on port 4000. +# Set for staging/production builds, e.g. https://api.musicshare.app +# EXPO_PUBLIC_API_URL= diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..4b00baf --- /dev/null +++ b/apps/mobile/.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 + +example + +# generated native folders +/ios +/android diff --git a/apps/mobile/.vscode/extensions.json b/apps/mobile/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/apps/mobile/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/apps/mobile/.vscode/settings.json b/apps/mobile/.vscode/settings.json new file mode 100644 index 0000000..e2798e4 --- /dev/null +++ b/apps/mobile/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md new file mode 100644 index 0000000..be05e77 --- /dev/null +++ b/apps/mobile/AGENTS.md @@ -0,0 +1,41 @@ +This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility. + +## Expo has changed — do not trust your training data + +Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API: + +1. Read the major version of the `expo` package in `package.json`. +2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v.0.0/` +3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory. + +## Commands + +Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present). + +```bash +npx expo install # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions +npx expo start # start the dev server +npx expo lint # lint +npx tsc --noEmit # typecheck +npx expo-doctor # diagnose dependency and config issues +npx expo install --fix # fix incompatible package versions +``` + +Run lint and typecheck before declaring any task done. + +## Navigation & Routing + +- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`. +- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`. +- Docs: https://docs.expo.dev/router/introduction.md + +## Building with EAS + +Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli ` in Bun projects, or `npx eas-cli@latest ` otherwise; substitute that for bare `eas` in docs examples. +Docs: https://docs.expo.dev/eas/index.md + +## Rules + +- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. +- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`. +- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..87e81df --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,40 @@ +{ + "expo": { + "name": "musicshare", + "slug": "musicshare", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "musicshare", + "userInterfaceStyle": "dark", + "ios": { + "icon": "./assets/expo.icon", + "bundleIdentifier": "tech.chrisedwards.musicshare" + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#0e0f13", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "backgroundImage": "./assets/images/android-icon-background.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "predictiveBackGestureEnabled": false, + "package": "tech.chrisedwards.musicshare" + }, + "plugins": [ + "expo-router", + "expo-secure-store", + [ + "expo-splash-screen", + { + "backgroundColor": "#0e0f13", + "image": "./assets/images/splash-icon.png", + "imageWidth": 76 + } + ] + ], + "experiments": { + "reactCompiler": true + } + } +} diff --git a/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg b/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg new file mode 100644 index 0000000..51d3676 --- /dev/null +++ b/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/assets/expo.icon/Assets/grid.png b/apps/mobile/assets/expo.icon/Assets/grid.png new file mode 100644 index 0000000..eefea24 Binary files /dev/null and b/apps/mobile/assets/expo.icon/Assets/grid.png differ diff --git a/apps/mobile/assets/expo.icon/icon.json b/apps/mobile/assets/expo.icon/icon.json new file mode 100644 index 0000000..7a2c33c --- /dev/null +++ b/apps/mobile/assets/expo.icon/icon.json @@ -0,0 +1,40 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "expo-symbol 2.svg", + "name" : "expo-symbol 2", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 1.1008400065293245e-05, + -16.046875 + ] + } + }, + { + "image-name" : "grid.png", + "name" : "grid" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/apps/mobile/assets/images/android-icon-background.png b/apps/mobile/assets/images/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/apps/mobile/assets/images/android-icon-background.png differ diff --git a/apps/mobile/assets/images/android-icon-foreground.png b/apps/mobile/assets/images/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/apps/mobile/assets/images/android-icon-foreground.png differ diff --git a/apps/mobile/assets/images/android-icon-monochrome.png b/apps/mobile/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/apps/mobile/assets/images/android-icon-monochrome.png differ diff --git a/apps/mobile/assets/images/icon.png b/apps/mobile/assets/images/icon.png new file mode 100644 index 0000000..67c777a Binary files /dev/null and b/apps/mobile/assets/images/icon.png differ diff --git a/apps/mobile/assets/images/splash-icon.png b/apps/mobile/assets/images/splash-icon.png new file mode 100644 index 0000000..6b1642a Binary files /dev/null and b/apps/mobile/assets/images/splash-icon.png differ diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js new file mode 100644 index 0000000..7b32e25 --- /dev/null +++ b/apps/mobile/eslint.config.js @@ -0,0 +1,20 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require('eslint-config-expo/flat'); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ['dist/*'], + }, + { + settings: { + // In the monorepo, eslint-plugin-import is hoisted to the root and can't find the resolver by + // its short name ("typescript"), so reference it by path and point it at this app's tsconfig. + 'import/resolver': { + node: true, + [require.resolve('eslint-import-resolver-typescript')]: { project: __dirname + '/tsconfig.json' }, + }, + }, + }, +]); diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..e815d6c --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,49 @@ +{ + "name": "@musicshare/mobile", + "main": "expo-router/entry", + "version": "1.0.0", + "dependencies": { + "@expo/ui": "~57.0.21", + "@musicshare/shared": "*", + "expo": "~57.0.26", + "expo-constants": "~57.0.20", + "expo-device": "~57.0.2", + "expo-font": "~57.0.4", + "expo-glass-effect": "~57.0.4", + "expo-image": "~57.0.5", + "expo-linking": "~57.0.11", + "expo-router": "~57.0.24", + "expo-secure-store": "~57.0.4", + "expo-splash-screen": "~57.0.9", + "expo-status-bar": "~57.0.1", + "expo-symbols": "~57.0.3", + "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-web": "~0.21.0", + "react-native-worklets": "0.10.1", + "swr": "^2.5.1" + }, + "devDependencies": { + "@types/react": "~19.2.2", + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^9.0.0", + "eslint-config-expo": "~57.0.2", + "eslint-import-resolver-typescript": "^3.10.1", + "typescript": "~6.0.3" + }, + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "typecheck": "tsc --noEmit", + "lint": "expo lint" + }, + "private": true +} diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..89cea62 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -0,0 +1,29 @@ +import { NativeTabs } from 'expo-router/unstable-native-tabs'; +import { colors } from '@/lib/theme'; + +export default function TabsLayout() { + return ( + + + Feed + + + + Friends + + + + Profile + + + + Settings + + + + ); +} diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx new file mode 100644 index 0000000..a4c9d0b --- /dev/null +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -0,0 +1,92 @@ +import { router } from 'expo-router'; +import { useState } from 'react'; +import { FlatList, RefreshControl, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import useSWR from 'swr'; +import { sortFeed, type FeedItemDTO } from '@musicshare/shared'; +import { NowPlayingCard } from '@/components/NowPlayingCard'; +import { Body, Button, Card, Title } from '@/components/ui'; +import { api } from '@/lib/api'; +import { showError } from '@/lib/errors'; +import { useRealtime } from '@/lib/useRealtime'; +import { useSession } from '@/lib/session'; +import { colors, space } from '@/lib/theme'; + +export default function FeedScreen() { + const { me } = useSession(); + const { data: feed, mutate, isLoading } = useSWR('/feed'); + const [refreshing, setRefreshing] = useState(false); + + useRealtime(true, (msg) => { + if (msg.type !== 'now_playing' || msg.data.user.id === me?.id) return; + mutate( + (current = []) => { + const existing = current.find((n) => n.user.id === msg.data.user.id); + return sortFeed([{ ...msg.data, pinnedAt: existing?.pinnedAt ?? null }, ...current.filter((n) => n !== existing)]); + }, + { revalidate: false }, + ); + }); + + const togglePin = async (item: FeedItemDTO) => { + const pinnedAt = item.pinnedAt ? null : new Date().toISOString(); + const previous = feed; + mutate(sortFeed((feed ?? []).map((n) => (n.user.id === item.user.id ? { ...n, pinnedAt } : n))), { revalidate: false }); + try { + await (pinnedAt ? api.pin(item.user.username) : api.unpin(item.user.username)); + } catch (err) { + mutate(previous, { revalidate: false }); + showError(err, "Couldn't pin"); + } + }; + + const refresh = async () => { + setRefreshing(true); + await mutate().catch(() => {}); + setRefreshing(false); + }; + + return ( + + item.user.id} + contentContainerStyle={styles.list} + refreshControl={} + ListHeaderComponent={ + + Friends listening + {!me?.preferredService && ( + + Pick your streaming service so songs open in the right app. +