small fix, don't worry about it
@@ -0,0 +1,41 @@
|
||||
# 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
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
@@ -0,0 +1,95 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ShareIntent, useShareIntent } from 'expo-share-intent';
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, SafeAreaView, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { AppProvider, useAppContext } from './src/context/AppContext';
|
||||
import { colors } from './src/components/UI';
|
||||
import { AuthScreen } from './src/screens/AuthScreen';
|
||||
import { ConnectionSetupScreen } from './src/screens/ConnectionSetupScreen';
|
||||
import { WebAppScreen } from './src/screens/WebAppScreen';
|
||||
|
||||
interface ShareIntentState {
|
||||
hasShareIntent: boolean;
|
||||
shareIntent: ShareIntent;
|
||||
resetShareIntent: (clearNativeModule?: boolean) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function AppRouter({ shareIntentState }: { shareIntentState: ShareIntentState }) {
|
||||
const { ready, instanceUrl, token, user, logout } = useAppContext();
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<SafeAreaView style={styles.bootContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.bootText}>Loading Trackeep Mobile...</Text>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!instanceUrl) {
|
||||
return <ConnectionSetupScreen />;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <AuthScreen />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<SafeAreaView style={styles.bootContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.bootText}>Loading user session...</Text>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WebAppScreen
|
||||
instanceUrl={instanceUrl}
|
||||
token={token}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
shareIntentState={shareIntentState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { hasShareIntent, shareIntent, resetShareIntent, error } = useShareIntent({
|
||||
resetOnBackground: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<View style={styles.appRoot}>
|
||||
<StatusBar style="dark" />
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<AppRouter shareIntentState={{ hasShareIntent, shareIntent, resetShareIntent, error }} />
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
appRoot: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
bootContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background,
|
||||
gap: 10,
|
||||
},
|
||||
bootText: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
# Trackeep Mobile (React Native)
|
||||
|
||||
Cross-platform mobile shell for Trackeep using React Native + Expo.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Same UI as web**: after login, the app renders your Trackeep web app (`/app`) inside a native WebView.
|
||||
- **One backend, one data plane**: mobile uses your self-hosted Trackeep instance URL and the same API/auth as web.
|
||||
- **Mobile-native extras**:
|
||||
- incoming phone share intent (links, YouTube URLs, text, files)
|
||||
- quick share panel to save bookmarks immediately
|
||||
- shared files upload directly to Trackeep Files
|
||||
|
||||
## Implemented Features
|
||||
|
||||
- Instance setup and validation (`/health`, `/api/version`)
|
||||
- Native login/register (`/api/v1/auth/...`) with secure token storage
|
||||
- Token bootstrap into WebView localStorage so web UI works immediately
|
||||
- Native/Web bridge for auth + route state sync
|
||||
- Android hardware back support for in-web navigation
|
||||
- External links open in system browser while Trackeep pages stay in-app
|
||||
- Mobile route shortcuts (Dashboard/Bookmarks/Tasks/Notes/Files/YouTube/Time)
|
||||
- Incoming share flow:
|
||||
- Save shared links/YouTube as bookmarks (`POST /api/v1/bookmarks`)
|
||||
- Smart link metadata enrichment (`POST /api/v1/bookmarks/metadata`)
|
||||
- YouTube-aware action: `Save + Open YouTube`
|
||||
- Upload shared files (`POST /api/v1/files/upload`)
|
||||
- Broader Android share MIME support (`text/plain`, `text/uri-list`, `text/*`, `image/*`, `video/*`, `application/*`)
|
||||
- Trackeep-branded mobile icon/splash copied from main project assets
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm 10+
|
||||
- Reachable Trackeep backend
|
||||
|
||||
## Run
|
||||
|
||||
From repository root:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm --workspace mobile run typecheck
|
||||
npm --workspace mobile run start
|
||||
```
|
||||
|
||||
## Important: Share Intent Requires Dev Client
|
||||
|
||||
Incoming share from other apps requires native code (`expo-share-intent`), so **Expo Go is not enough**.
|
||||
Use a dev client or production build:
|
||||
|
||||
```bash
|
||||
npm --workspace mobile run prebuild:clean
|
||||
npm --workspace mobile run android:run
|
||||
# or
|
||||
npm --workspace mobile run ios:run
|
||||
|
||||
npm --workspace mobile run start:dev-client
|
||||
```
|
||||
|
||||
`patch-package` is already configured at repository root to apply the required `xcode@3.0.1` patch during install.
|
||||
|
||||
## Share Examples
|
||||
|
||||
- Share a YouTube link from YouTube app → Trackeep Mobile appears in share sheet → Save to bookmarks.
|
||||
- Share a webpage URL from browser → Save to bookmarks.
|
||||
- Share an image/file from gallery/files app → Upload to Trackeep Files.
|
||||
|
||||
## Instance URL examples
|
||||
|
||||
- Production: `https://trackeep.example.com`
|
||||
- Android emulator local backend: `http://10.0.2.2:8080`
|
||||
- iOS simulator local backend: `http://localhost:8080`
|
||||
|
||||
## Notes
|
||||
|
||||
- Switching instance URL clears session intentionally (instance-isolated auth).
|
||||
- If the share extension is not detected in Expo Go, this is expected; use dev client or release build.
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Trackeep",
|
||||
"slug": "trackeep-mobile",
|
||||
"version": "1.0.0",
|
||||
"scheme": "trackeepmobile",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/trackeep-icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/trackeep-splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.trackeep.mobile"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"package": "com.trackeep.mobile",
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-share-intent",
|
||||
{
|
||||
"iosShareExtensionName": "Trackeep",
|
||||
"iosActivationRules": {
|
||||
"NSExtensionActivationSupportsWebURLWithMaxCount": 1,
|
||||
"NSExtensionActivationSupportsWebPageWithMaxCount": 1,
|
||||
"NSExtensionActivationSupportsText": true,
|
||||
"NSExtensionActivationSupportsImageWithMaxCount": 1,
|
||||
"NSExtensionActivationSupportsMovieWithMaxCount": 1,
|
||||
"NSExtensionActivationSupportsFileWithMaxCount": 1
|
||||
},
|
||||
"androidIntentFilters": [
|
||||
"text/plain",
|
||||
"text/uri-list",
|
||||
"text/*",
|
||||
"image/*",
|
||||
"video/*",
|
||||
"application/*"
|
||||
],
|
||||
"androidMultiIntentFilters": [
|
||||
"text/plain",
|
||||
"text/*",
|
||||
"image/*",
|
||||
"video/*",
|
||||
"*/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "trackeep-mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"start:dev-client": "expo start --dev-client",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android:run": "expo run:android",
|
||||
"ios:run": "expo run:ios",
|
||||
"prebuild:clean": "expo prebuild --clean",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"expo": "~55.0.5",
|
||||
"expo-document-picker": "~14.0.7",
|
||||
"expo-linking": "~55.0.7",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-share-intent": "~6.0.0",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-webview": "~13.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
export const colors = {
|
||||
background: '#F4F7FB',
|
||||
panel: '#FFFFFF',
|
||||
border: '#D6DEE8',
|
||||
text: '#17212B',
|
||||
muted: '#5D6B79',
|
||||
primary: '#0E7490',
|
||||
primaryDark: '#155E75',
|
||||
danger: '#B91C1C',
|
||||
success: '#047857',
|
||||
};
|
||||
|
||||
export function ScreenShell({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<ScrollView style={styles.screen} contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
|
||||
{children}
|
||||
<View style={styles.bottomSpace} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionCard({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
|
||||
return <View style={[styles.card, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
export function Label({ children }: { children: React.ReactNode }) {
|
||||
return <Text style={styles.label}>{children}</Text>;
|
||||
}
|
||||
|
||||
export function Input(props: TextInputProps) {
|
||||
return <TextInput placeholderTextColor={colors.muted} style={[styles.input, props.multiline ? styles.textarea : undefined]} {...props} />;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
label,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
disabled,
|
||||
loading,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={isDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
variant === 'primary' ? styles.buttonPrimary : undefined,
|
||||
variant === 'secondary' ? styles.buttonSecondary : undefined,
|
||||
variant === 'danger' ? styles.buttonDanger : undefined,
|
||||
pressed && !isDisabled ? styles.buttonPressed : undefined,
|
||||
isDisabled ? styles.buttonDisabled : undefined,
|
||||
]}
|
||||
>
|
||||
{loading ? <ActivityIndicator color={variant === 'secondary' ? colors.text : '#FFFFFF'} /> : <Text style={[styles.buttonText, variant === 'secondary' ? styles.buttonTextSecondary : undefined]}>{label}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorText({ message }: { message?: string | null }) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Text style={styles.error}>{message}</Text>;
|
||||
}
|
||||
|
||||
export const uiStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
muted: {
|
||||
color: colors.muted,
|
||||
fontSize: 13,
|
||||
},
|
||||
chip: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#E6F6FB',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
},
|
||||
chipText: {
|
||||
color: colors.primaryDark,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.muted,
|
||||
marginTop: -4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.panel,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: 14,
|
||||
gap: 10,
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
input: {
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: colors.text,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
minHeight: 42,
|
||||
},
|
||||
textarea: {
|
||||
minHeight: 96,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
button: {
|
||||
minHeight: 40,
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
buttonPrimary: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
buttonSecondary: {
|
||||
backgroundColor: '#EDF2F7',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
buttonDanger: {
|
||||
backgroundColor: colors.danger,
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
},
|
||||
buttonTextSecondary: {
|
||||
color: colors.text,
|
||||
},
|
||||
error: {
|
||||
color: colors.danger,
|
||||
fontWeight: '600',
|
||||
},
|
||||
bottomSpace: {
|
||||
height: 36,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { trackeepApi } from '../lib/api';
|
||||
import { normalizeInstanceUrl } from '../lib/url';
|
||||
import { User } from '../types';
|
||||
|
||||
const INSTANCE_URL_KEY = 'trackeep_mobile_instance_url';
|
||||
const USER_KEY = 'trackeep_mobile_user';
|
||||
const TOKEN_KEY = 'trackeep_mobile_token';
|
||||
|
||||
interface RegisterPayload {
|
||||
email: string;
|
||||
username: string;
|
||||
fullName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface AppContextValue {
|
||||
ready: boolean;
|
||||
busy: boolean;
|
||||
instanceUrl: string | null;
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
setInstanceUrl: (url: string) => Promise<void>;
|
||||
clearInstance: () => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (payload: RegisterPayload) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextValue | undefined>(undefined);
|
||||
|
||||
async function saveToken(token: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
async function loadToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function clearToken(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [instanceUrl, setInstanceUrlState] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
const [[, storedInstance], [, storedUser]] = await AsyncStorage.multiGet([INSTANCE_URL_KEY, USER_KEY]);
|
||||
const storedToken = await loadToken();
|
||||
|
||||
let parsedUser: User | null = null;
|
||||
if (storedUser) {
|
||||
try {
|
||||
parsedUser = JSON.parse(storedUser) as User;
|
||||
} catch {
|
||||
parsedUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedInstance: string | null = null;
|
||||
if (storedInstance) {
|
||||
try {
|
||||
normalizedInstance = normalizeInstanceUrl(storedInstance);
|
||||
} catch {
|
||||
normalizedInstance = null;
|
||||
await AsyncStorage.removeItem(INSTANCE_URL_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedInstance && storedToken) {
|
||||
try {
|
||||
const freshUser = await trackeepApi.auth.me(normalizedInstance, storedToken);
|
||||
setInstanceUrlState(normalizedInstance);
|
||||
setToken(storedToken);
|
||||
setUser(freshUser);
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(freshUser));
|
||||
} catch {
|
||||
setInstanceUrlState(normalizedInstance);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
await clearToken();
|
||||
await AsyncStorage.removeItem(USER_KEY);
|
||||
}
|
||||
} else {
|
||||
setInstanceUrlState(normalizedInstance);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
} finally {
|
||||
setReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
void bootstrap();
|
||||
}, []);
|
||||
|
||||
const clearSession = async () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
await clearToken();
|
||||
await AsyncStorage.removeItem(USER_KEY);
|
||||
};
|
||||
|
||||
const setInstanceUrl = async (url: string) => {
|
||||
const normalized = normalizeInstanceUrl(url);
|
||||
const hasChanged = normalized !== instanceUrl;
|
||||
|
||||
setInstanceUrlState(normalized);
|
||||
await AsyncStorage.setItem(INSTANCE_URL_KEY, normalized);
|
||||
|
||||
if (hasChanged) {
|
||||
await clearSession();
|
||||
}
|
||||
};
|
||||
|
||||
const clearInstance = async () => {
|
||||
await clearSession();
|
||||
setInstanceUrlState(null);
|
||||
await AsyncStorage.removeItem(INSTANCE_URL_KEY);
|
||||
};
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
if (!instanceUrl) {
|
||||
throw new Error('Set your Trackeep instance URL first.');
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await trackeepApi.auth.login(instanceUrl, email, password);
|
||||
setToken(response.token);
|
||||
setUser(response.user);
|
||||
await saveToken(response.token);
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(response.user));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (payload: RegisterPayload) => {
|
||||
if (!instanceUrl) {
|
||||
throw new Error('Set your Trackeep instance URL first.');
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await trackeepApi.auth.register(instanceUrl, payload);
|
||||
setToken(response.token);
|
||||
setUser(response.user);
|
||||
await saveToken(response.token);
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(response.user));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const currentToken = token;
|
||||
const currentInstance = instanceUrl;
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
if (currentToken && currentInstance) {
|
||||
try {
|
||||
await trackeepApi.auth.logout(currentInstance, currentToken);
|
||||
} catch {
|
||||
// Ignore logout API failures so local session can always be cleared.
|
||||
}
|
||||
}
|
||||
await clearSession();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
if (!instanceUrl || !token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const freshUser = await trackeepApi.auth.me(instanceUrl, token);
|
||||
setUser(freshUser);
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(freshUser));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = useMemo<AppContextValue>(
|
||||
() => ({
|
||||
ready,
|
||||
busy,
|
||||
instanceUrl,
|
||||
token,
|
||||
user,
|
||||
setInstanceUrl,
|
||||
clearInstance,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
}),
|
||||
[ready, busy, instanceUrl, token, user],
|
||||
);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAppContext(): AppContextValue {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) {
|
||||
throw new Error('useAppContext must be used inside AppProvider.');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { DocumentPickerAsset } from 'expo-document-picker';
|
||||
|
||||
import {
|
||||
AuthResponse,
|
||||
Bookmark,
|
||||
FileItem,
|
||||
Note,
|
||||
Task,
|
||||
TimeEntry,
|
||||
UpdateCheckInfo,
|
||||
User,
|
||||
VersionInfo,
|
||||
} from '../types';
|
||||
import { getApiBaseUrl, normalizeInstanceUrl } from './url';
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
token?: string | null;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
return (await response.text()) as T;
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<string> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = (await response.json().catch(() => null)) as
|
||||
| { error?: string; message?: string }
|
||||
| null;
|
||||
|
||||
if (data?.error) {
|
||||
return data.error;
|
||||
}
|
||||
if (data?.message) {
|
||||
return data.message;
|
||||
}
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => '');
|
||||
return text || `Request failed with status ${response.status}`;
|
||||
}
|
||||
|
||||
async function request<T>(instanceUrl: string, endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const apiBaseUrl = getApiBaseUrl(instanceUrl);
|
||||
const { method = 'GET', token, body, headers = {} } = options;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
if (!isFormData && body !== undefined) {
|
||||
requestHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (token) {
|
||||
requestHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: body === undefined ? undefined : isFormData ? (body as BodyInit) : JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseError(response));
|
||||
}
|
||||
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
async function requestPublic<T>(absoluteUrl: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(absoluteUrl, init);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseError(response));
|
||||
}
|
||||
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export const trackeepApi = {
|
||||
async probeInstance(inputUrl: string): Promise<{ normalizedUrl: string; version?: string }> {
|
||||
const normalizedUrl = normalizeInstanceUrl(inputUrl);
|
||||
|
||||
await requestPublic<string>(`${normalizedUrl}/health`);
|
||||
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const versionInfo = await requestPublic<VersionInfo>(`${normalizedUrl}/api/version`);
|
||||
const candidate = versionInfo.version || versionInfo.app_version;
|
||||
if (typeof candidate === 'string') {
|
||||
version = candidate;
|
||||
}
|
||||
} catch {
|
||||
// Version endpoint is optional for initial connection setup.
|
||||
}
|
||||
|
||||
return { normalizedUrl, version };
|
||||
},
|
||||
|
||||
async checkForUpdates(instanceUrl: string): Promise<UpdateCheckInfo> {
|
||||
const normalizedUrl = normalizeInstanceUrl(instanceUrl);
|
||||
return requestPublic<UpdateCheckInfo>(`${normalizedUrl}/api/updates/check`);
|
||||
},
|
||||
|
||||
auth: {
|
||||
login(instanceUrl: string, email: string, password: string) {
|
||||
return request<AuthResponse>(instanceUrl, '/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
});
|
||||
},
|
||||
|
||||
register(instanceUrl: string, payload: { email: string; username: string; fullName: string; password: string }) {
|
||||
return request<AuthResponse>(instanceUrl, '/auth/register', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
|
||||
me(instanceUrl: string, token: string) {
|
||||
return request<User>(instanceUrl, '/auth/me', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async logout(instanceUrl: string, token: string): Promise<void> {
|
||||
await request(instanceUrl, '/auth/logout', {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
tasks: {
|
||||
list(instanceUrl: string, token: string) {
|
||||
return request<Task[]>(instanceUrl, '/tasks', { token });
|
||||
},
|
||||
|
||||
create(
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
payload: { title: string; description?: string; status?: string; priority?: string },
|
||||
) {
|
||||
return request<Task>(instanceUrl, '/tasks', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
|
||||
update(instanceUrl: string, token: string, id: number, payload: Partial<Task>) {
|
||||
return request<Task>(instanceUrl, `/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(instanceUrl: string, token: string, id: number): Promise<void> {
|
||||
await request(instanceUrl, `/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
notes: {
|
||||
list(instanceUrl: string, token: string) {
|
||||
return request<Note[]>(instanceUrl, '/notes', { token });
|
||||
},
|
||||
|
||||
create(
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
payload: { title: string; content?: string; description?: string; is_public?: boolean },
|
||||
) {
|
||||
return request<Note>(instanceUrl, '/notes', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
|
||||
update(instanceUrl: string, token: string, id: number, payload: Partial<Note>) {
|
||||
return request<Note>(instanceUrl, `/notes/${id}`, {
|
||||
method: 'PUT',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(instanceUrl: string, token: string, id: number): Promise<void> {
|
||||
await request(instanceUrl, `/notes/${id}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
files: {
|
||||
list(instanceUrl: string, token: string) {
|
||||
return request<FileItem[]>(instanceUrl, '/files', { token });
|
||||
},
|
||||
|
||||
async upload(
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
file: DocumentPickerAsset,
|
||||
description?: string,
|
||||
): Promise<FileItem> {
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
'file',
|
||||
{
|
||||
uri: file.uri,
|
||||
name: file.name || `upload-${Date.now()}`,
|
||||
type: file.mimeType || 'application/octet-stream',
|
||||
} as unknown as Blob,
|
||||
);
|
||||
|
||||
if (description?.trim()) {
|
||||
formData.append('description', description.trim());
|
||||
}
|
||||
|
||||
return request<FileItem>(instanceUrl, '/files/upload', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
|
||||
async uploadFromUri(
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
payload: { uri: string; name: string; mimeType?: string; description?: string },
|
||||
): Promise<FileItem> {
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
'file',
|
||||
{
|
||||
uri: payload.uri,
|
||||
name: payload.name || `shared-${Date.now()}`,
|
||||
type: payload.mimeType || 'application/octet-stream',
|
||||
} as unknown as Blob,
|
||||
);
|
||||
|
||||
if (payload.description?.trim()) {
|
||||
formData.append('description', payload.description.trim());
|
||||
}
|
||||
|
||||
return request<FileItem>(instanceUrl, '/files/upload', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(instanceUrl: string, token: string, id: number): Promise<void> {
|
||||
await request(instanceUrl, `/files/${id}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
getDownloadUrl(instanceUrl: string, token: string, id: number): string {
|
||||
const apiBaseUrl = getApiBaseUrl(instanceUrl);
|
||||
return `${apiBaseUrl}/files/${id}/download?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
},
|
||||
|
||||
bookmarks: {
|
||||
list(instanceUrl: string, token: string) {
|
||||
return request<Bookmark[]>(instanceUrl, '/bookmarks', { token });
|
||||
},
|
||||
|
||||
metadata(instanceUrl: string, token: string, url: string) {
|
||||
return request<{ title?: string; description?: string; image?: string; favicon?: string }>(
|
||||
instanceUrl,
|
||||
'/bookmarks/metadata',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { url },
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
create(
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
payload: {
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
is_read?: boolean;
|
||||
is_favorite?: boolean;
|
||||
},
|
||||
) {
|
||||
return request<Bookmark>(instanceUrl, '/bookmarks', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
timeEntries: {
|
||||
list(instanceUrl: string, token: string) {
|
||||
return request<{ time_entries: TimeEntry[] }>(instanceUrl, '/time-entries', { token });
|
||||
},
|
||||
|
||||
create(
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
payload: { description: string; billable?: boolean; source?: string; tags?: string[] },
|
||||
) {
|
||||
return request<{ time_entry: TimeEntry }>(instanceUrl, '/time-entries', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
},
|
||||
|
||||
stop(instanceUrl: string, token: string, id: number) {
|
||||
return request<{ time_entry: TimeEntry }>(instanceUrl, `/time-entries/${id}/stop`, {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(instanceUrl: string, token: string, id: number): Promise<void> {
|
||||
await request(instanceUrl, `/time-entries/${id}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Tag } from '../types';
|
||||
|
||||
export function formatDate(date: string | null | undefined): string {
|
||||
if (!date) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const parsed = new Date(date);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return date;
|
||||
}
|
||||
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const safeSeconds = Math.max(0, Math.floor(seconds));
|
||||
const hours = Math.floor(safeSeconds / 3600);
|
||||
const minutes = Math.floor((safeSeconds % 3600) / 60);
|
||||
const remaining = safeSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${remaining}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${remaining}s`;
|
||||
}
|
||||
return `${remaining}s`;
|
||||
}
|
||||
|
||||
export function formatFileSize(size: number): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = size / 1024;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function tagsToText(tags?: Array<Tag | string>): string {
|
||||
if (!tags || tags.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return tags
|
||||
.map((tag) => (typeof tag === 'string' ? tag : tag.name))
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ShareIntent } from 'expo-share-intent';
|
||||
|
||||
const URL_PATTERN = /(https?:\/\/[^\s]+)/gi;
|
||||
|
||||
export interface ShareDraft {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
source: 'share-intent' | 'manual';
|
||||
}
|
||||
|
||||
function firstUrlFromText(text: string | null | undefined): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = text.match(URL_PATTERN);
|
||||
if (!matches || matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function firstCandidateTitleFromText(text: string | null | undefined, url: string): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((line) => !line.includes(url) && !/https?:\/\//i.test(line));
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return lines[0];
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string | null | undefined): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function titleFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
|
||||
if (host.includes('youtube.com') || host.includes('youtu.be')) {
|
||||
return 'YouTube Video';
|
||||
}
|
||||
|
||||
return host.replace(/^www\./, '');
|
||||
} catch {
|
||||
return 'Shared Link';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildShareDraft(shareIntent: ShareIntent): ShareDraft | null {
|
||||
const url = normalizeUrl(shareIntent.webUrl) || normalizeUrl(firstUrlFromText(shareIntent.text));
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawTitle = shareIntent.meta?.title?.trim() || firstCandidateTitleFromText(shareIntent.text, url);
|
||||
const title = rawTitle && rawTitle.length > 0 ? rawTitle : titleFromUrl(url);
|
||||
|
||||
let description = '';
|
||||
if (shareIntent.text) {
|
||||
const cleaned = shareIntent.text.replace(url, '').trim();
|
||||
if (cleaned && cleaned.length > 0) {
|
||||
description = cleaned.slice(0, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
source: 'share-intent',
|
||||
};
|
||||
}
|
||||
|
||||
export function looksLikeYouTube(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
return host.includes('youtube.com') || host.includes('youtu.be');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|10\.0\.2\.2|10\.0\.3\.2|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/i;
|
||||
const API_SUFFIX_RE = /\/api\/v1\/?$/i;
|
||||
|
||||
function hasProtocol(value: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value);
|
||||
}
|
||||
|
||||
export function normalizeInstanceUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Instance URL is required.');
|
||||
}
|
||||
|
||||
const hostCandidate = trimmed.replace(/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//, '').split('/')[0].toLowerCase();
|
||||
const scheme = LOCAL_HOST_RE.test(hostCandidate) ? 'http' : 'https';
|
||||
const withScheme = hasProtocol(trimmed) ? trimmed : `${scheme}://${trimmed}`;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withScheme);
|
||||
} catch {
|
||||
throw new Error('Invalid instance URL format.');
|
||||
}
|
||||
|
||||
let path = parsed.pathname.replace(/\/+$/, '');
|
||||
path = path.replace(API_SUFFIX_RE, '');
|
||||
|
||||
const normalizedPath = path === '/' ? '' : path;
|
||||
return `${parsed.protocol}//${parsed.host}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(instanceUrl: string): string {
|
||||
const root = normalizeInstanceUrl(instanceUrl);
|
||||
return `${root}/api/v1`;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import {
|
||||
Button,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
ScreenShell,
|
||||
SectionCard,
|
||||
uiStyles,
|
||||
} from '../components/UI';
|
||||
|
||||
type AuthMode = 'login' | 'register';
|
||||
|
||||
export function AuthScreen() {
|
||||
const { instanceUrl, login, register, busy } = useAppContext();
|
||||
|
||||
const [mode, setMode] = useState<AuthMode>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const ctaLabel = useMemo(() => (mode === 'login' ? 'Sign In' : 'Create Account'), [mode]);
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(email.trim(), password);
|
||||
return;
|
||||
}
|
||||
|
||||
await register({
|
||||
email: email.trim(),
|
||||
username: username.trim(),
|
||||
fullName: fullName.trim(),
|
||||
password,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title={mode === 'login' ? 'Sign In' : 'Register'}
|
||||
subtitle={instanceUrl ? `Instance: ${instanceUrl}` : undefined}
|
||||
>
|
||||
<SectionCard>
|
||||
<View style={uiStyles.row}>
|
||||
<Button
|
||||
label="Login"
|
||||
onPress={() => setMode('login')}
|
||||
variant={mode === 'login' ? 'primary' : 'secondary'}
|
||||
/>
|
||||
<Button
|
||||
label="Register"
|
||||
onPress={() => setMode('register')}
|
||||
variant={mode === 'register' ? 'primary' : 'secondary'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
|
||||
{mode === 'register' ? (
|
||||
<>
|
||||
<Label>Username</Label>
|
||||
<Input autoCapitalize="none" autoCorrect={false} placeholder="yourname" value={username} onChangeText={setUsername} />
|
||||
|
||||
<Label>Full Name</Label>
|
||||
<Input placeholder="Your Name" value={fullName} onChangeText={setFullName} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Label>Password</Label>
|
||||
<Input secureTextEntry placeholder="••••••••" value={password} onChangeText={setPassword} />
|
||||
|
||||
<Button label={ctaLabel} onPress={submit} loading={busy} />
|
||||
<ErrorText message={error} />
|
||||
|
||||
{mode === 'register' ? (
|
||||
<Text style={uiStyles.muted}>
|
||||
Registration succeeds for first setup or when an admin allows user creation.
|
||||
</Text>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { trackeepApi } from '../lib/api';
|
||||
import {
|
||||
Button,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
ScreenShell,
|
||||
SectionCard,
|
||||
uiStyles,
|
||||
} from '../components/UI';
|
||||
|
||||
export function ConnectionSetupScreen() {
|
||||
const { setInstanceUrl } = useAppContext();
|
||||
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleTest = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await trackeepApi.probeInstance(inputUrl);
|
||||
const versionText = result.version ? ` | Version ${result.version}` : '';
|
||||
setMessage(`Connected: ${result.normalizedUrl}${versionText}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect to this instance.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await trackeepApi.probeInstance(inputUrl);
|
||||
await setInstanceUrl(result.normalizedUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save this instance URL.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Connect To Your Trackeep"
|
||||
subtitle="Point the mobile app at your self-hosted instance."
|
||||
>
|
||||
<SectionCard>
|
||||
<Label>Instance URL</Label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholder="https://trackeep.yourdomain.com"
|
||||
value={inputUrl}
|
||||
onChangeText={setInputUrl}
|
||||
/>
|
||||
<Text style={uiStyles.muted}>
|
||||
For local testing use `http://10.0.2.2:8080` on Android emulator or `http://localhost:8080` on iOS simulator.
|
||||
</Text>
|
||||
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Test Connection" variant="secondary" onPress={handleTest} loading={loading} />
|
||||
<Button label="Save & Continue" onPress={handleSave} loading={loading} />
|
||||
</View>
|
||||
|
||||
<ErrorText message={error} />
|
||||
{message ? <Text style={[uiStyles.muted, { color: '#047857', fontWeight: '600' }]}>{message}</Text> : null}
|
||||
</SectionCard>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Linking, Text, View } from 'react-native';
|
||||
|
||||
import { trackeepApi } from '../lib/api';
|
||||
import { formatDate, formatFileSize } from '../lib/format';
|
||||
import { FileItem } from '../types';
|
||||
import {
|
||||
Button,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
ScreenShell,
|
||||
SectionCard,
|
||||
colors,
|
||||
uiStyles,
|
||||
} from '../components/UI';
|
||||
|
||||
interface FilesScreenProps {
|
||||
instanceUrl: string;
|
||||
token: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function FilesScreen({ instanceUrl, token, isActive }: FilesScreenProps) {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [description, setDescription] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const list = await trackeepApi.files.list(instanceUrl, token);
|
||||
setFiles(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load files.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceUrl, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
void loadFiles();
|
||||
}
|
||||
}, [isActive, loadFiles]);
|
||||
|
||||
const pickAndUpload = async () => {
|
||||
setError(null);
|
||||
|
||||
const pickerResult = await DocumentPicker.getDocumentAsync({
|
||||
multiple: false,
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (pickerResult.canceled || pickerResult.assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await trackeepApi.files.upload(instanceUrl, token, pickerResult.assets[0], description);
|
||||
setDescription('');
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'File upload failed.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (id: number) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await trackeepApi.files.remove(instanceUrl, token, id);
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete file.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDownload = async (id: number) => {
|
||||
const url = trackeepApi.files.getDownloadUrl(instanceUrl, token, id);
|
||||
|
||||
try {
|
||||
const supported = await Linking.canOpenURL(url);
|
||||
if (!supported) {
|
||||
throw new Error('This device cannot open the download URL.');
|
||||
}
|
||||
await Linking.openURL(url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to open download URL.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Files" subtitle="Upload from your phone and manage file records on your server.">
|
||||
<SectionCard>
|
||||
<Label>Upload</Label>
|
||||
<Input
|
||||
placeholder="Optional description"
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
/>
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Refresh" variant="secondary" onPress={loadFiles} loading={loading} />
|
||||
<Button label="Pick & Upload" onPress={pickAndUpload} loading={saving} />
|
||||
</View>
|
||||
<ErrorText message={error} />
|
||||
</SectionCard>
|
||||
|
||||
{files.map((file) => (
|
||||
<SectionCard key={file.id}>
|
||||
<View style={uiStyles.splitRow}>
|
||||
<Text style={{ color: colors.text, fontWeight: '700', flex: 1 }}>{file.original_name}</Text>
|
||||
<View style={uiStyles.chip}>
|
||||
<Text style={uiStyles.chipText}>{file.file_type}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={uiStyles.muted}>Size: {formatFileSize(file.file_size)}</Text>
|
||||
<Text style={uiStyles.muted}>Uploaded: {formatDate(file.created_at)}</Text>
|
||||
{file.description ? <Text style={uiStyles.muted}>Description: {file.description}</Text> : null}
|
||||
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Download" variant="secondary" onPress={() => void openDownload(file.id)} />
|
||||
<Button
|
||||
label="Delete"
|
||||
variant="danger"
|
||||
onPress={() => {
|
||||
void deleteFile(file.id);
|
||||
}}
|
||||
loading={saving}
|
||||
/>
|
||||
</View>
|
||||
</SectionCard>
|
||||
))}
|
||||
|
||||
{!loading && files.length === 0 ? (
|
||||
<SectionCard>
|
||||
<Text style={uiStyles.muted}>No files uploaded yet.</Text>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../components/UI';
|
||||
import { FilesScreen } from './FilesScreen';
|
||||
import { NotesScreen } from './NotesScreen';
|
||||
import { SettingsScreen } from './SettingsScreen';
|
||||
import { TasksScreen } from './TasksScreen';
|
||||
import { TimeEntriesScreen } from './TimeEntriesScreen';
|
||||
|
||||
type TabKey = 'tasks' | 'notes' | 'files' | 'time' | 'settings';
|
||||
|
||||
const TABS: Array<{ key: TabKey; label: string }> = [
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'files', label: 'Files' },
|
||||
{ key: 'time', label: 'Time' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
interface MainTabsProps {
|
||||
instanceUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function MainTabs({ instanceUrl, token }: MainTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('tasks');
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case 'tasks':
|
||||
return <TasksScreen instanceUrl={instanceUrl} token={token} isActive />;
|
||||
case 'notes':
|
||||
return <NotesScreen instanceUrl={instanceUrl} token={token} isActive />;
|
||||
case 'files':
|
||||
return <FilesScreen instanceUrl={instanceUrl} token={token} isActive />;
|
||||
case 'time':
|
||||
return <TimeEntriesScreen instanceUrl={instanceUrl} token={token} isActive />;
|
||||
case 'settings':
|
||||
return <SettingsScreen isActive />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [activeTab, instanceUrl, token]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
<View style={styles.tabBar}>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.key === activeTab;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
onPress={() => setActiveTab(tab.key)}
|
||||
style={({ pressed }) => [
|
||||
styles.tabButton,
|
||||
isActive ? styles.tabButtonActive : undefined,
|
||||
pressed ? styles.tabPressed : undefined,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tabLabel, isActive ? styles.tabLabelActive : undefined]}>{tab.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
tabBar: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 6,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
minHeight: 44,
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
tabButtonActive: {
|
||||
backgroundColor: '#E6F6FB',
|
||||
},
|
||||
tabPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 12,
|
||||
color: colors.muted,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabLabelActive: {
|
||||
color: colors.primaryDark,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { trackeepApi } from '../lib/api';
|
||||
import { formatDate, tagsToText } from '../lib/format';
|
||||
import { Note } from '../types';
|
||||
import {
|
||||
Button,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
ScreenShell,
|
||||
SectionCard,
|
||||
colors,
|
||||
uiStyles,
|
||||
} from '../components/UI';
|
||||
|
||||
interface NotesScreenProps {
|
||||
instanceUrl: string;
|
||||
token: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function NotesScreen({ instanceUrl, token, isActive }: NotesScreenProps) {
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
|
||||
const loadNotes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const list = await trackeepApi.notes.list(instanceUrl, token);
|
||||
setNotes(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load notes.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceUrl, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
void loadNotes();
|
||||
}
|
||||
}, [isActive, loadNotes]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const saveNote = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) {
|
||||
setError('Note title is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await trackeepApi.notes.update(instanceUrl, token, editingId, {
|
||||
title: trimmedTitle,
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
await trackeepApi.notes.create(instanceUrl, token, {
|
||||
title: trimmedTitle,
|
||||
content,
|
||||
is_public: false,
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
await loadNotes();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save note.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const beginEdit = (note: Note) => {
|
||||
setEditingId(note.id);
|
||||
setTitle(note.title);
|
||||
setContent(note.content || '');
|
||||
};
|
||||
|
||||
const deleteNote = async (id: number) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await trackeepApi.notes.remove(instanceUrl, token, id);
|
||||
if (editingId === id) {
|
||||
resetForm();
|
||||
}
|
||||
await loadNotes();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete note.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Notes" subtitle="Write notes and keep them synced with your server.">
|
||||
<SectionCard>
|
||||
<Label>{editingId ? 'Edit Note' : 'New Note'}</Label>
|
||||
<Input placeholder="Note title" value={title} onChangeText={setTitle} />
|
||||
<Input multiline placeholder="Write your note..." value={content} onChangeText={setContent} />
|
||||
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Refresh" variant="secondary" onPress={loadNotes} loading={loading} />
|
||||
<Button label={editingId ? 'Save Changes' : 'Create Note'} onPress={saveNote} loading={saving} />
|
||||
{editingId ? <Button label="Cancel" variant="secondary" onPress={resetForm} /> : null}
|
||||
</View>
|
||||
|
||||
<ErrorText message={error} />
|
||||
</SectionCard>
|
||||
|
||||
{notes.map((note) => {
|
||||
const tags = tagsToText(note.tags);
|
||||
|
||||
return (
|
||||
<SectionCard key={note.id}>
|
||||
<View style={uiStyles.splitRow}>
|
||||
<Text style={{ fontWeight: '700', color: colors.text, flex: 1 }}>{note.title}</Text>
|
||||
{note.is_public ? (
|
||||
<View style={[uiStyles.chip, { backgroundColor: '#FEF3C7' }]}>
|
||||
<Text style={uiStyles.chipText}>Public</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{note.content ? <Text style={uiStyles.muted}>{note.content}</Text> : null}
|
||||
{tags ? <Text style={uiStyles.muted}>Tags: {tags}</Text> : null}
|
||||
<Text style={uiStyles.muted}>Updated: {formatDate(note.updated_at)}</Text>
|
||||
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Edit" variant="secondary" onPress={() => beginEdit(note)} />
|
||||
<Button
|
||||
label="Delete"
|
||||
variant="danger"
|
||||
onPress={() => {
|
||||
void deleteNote(note.id);
|
||||
}}
|
||||
loading={saving}
|
||||
/>
|
||||
</View>
|
||||
</SectionCard>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loading && notes.length === 0 ? (
|
||||
<SectionCard>
|
||||
<Text style={uiStyles.muted}>No notes found yet.</Text>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { trackeepApi } from '../lib/api';
|
||||
import {
|
||||
Button,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
ScreenShell,
|
||||
SectionCard,
|
||||
uiStyles,
|
||||
} from '../components/UI';
|
||||
|
||||
interface SettingsScreenProps {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function SettingsScreen({ isActive }: SettingsScreenProps) {
|
||||
const { instanceUrl, user, logout, setInstanceUrl, clearInstance, refreshUser, busy } = useAppContext();
|
||||
|
||||
const [instanceDraft, setInstanceDraft] = useState(instanceUrl ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setInstanceDraft(instanceUrl ?? '');
|
||||
}
|
||||
}, [instanceUrl, isActive]);
|
||||
|
||||
const saveInstance = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
|
||||
try {
|
||||
const probe = await trackeepApi.probeInstance(instanceDraft);
|
||||
await setInstanceUrl(probe.normalizedUrl);
|
||||
const versionText = probe.version ? ` (version ${probe.version})` : '';
|
||||
setInfo(`Instance updated to ${probe.normalizedUrl}${versionText}. Sign in again to continue.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update instance URL.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkUpdates = async () => {
|
||||
if (!instanceUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingUpdates(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
|
||||
try {
|
||||
const result = await trackeepApi.checkForUpdates(instanceUrl);
|
||||
const updateAvailable = result.update_available ? 'Yes' : 'No';
|
||||
const current = result.current_version || 'unknown';
|
||||
const latest = result.latest_version || 'unknown';
|
||||
const message = result.message ? ` | ${result.message}` : '';
|
||||
setInfo(`Update available: ${updateAvailable} | Current: ${current} | Latest: ${latest}${message}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to check updates.');
|
||||
} finally {
|
||||
setCheckingUpdates(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Settings" subtitle="Manage instance connection, session and server checks.">
|
||||
<SectionCard>
|
||||
<Label>Connected User</Label>
|
||||
<Text style={uiStyles.muted}>{user ? `${user.full_name || user.username} (${user.email})` : 'Not signed in'}</Text>
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Refresh Profile" variant="secondary" onPress={() => void refreshUser()} loading={busy} />
|
||||
<Button label="Logout" variant="danger" onPress={() => void logout()} loading={busy} />
|
||||
</View>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<Label>Trackeep Instance</Label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholder="https://trackeep.yourdomain.com"
|
||||
value={instanceDraft}
|
||||
onChangeText={setInstanceDraft}
|
||||
/>
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Save Instance" onPress={saveInstance} loading={saving} />
|
||||
<Button
|
||||
label="Disconnect"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
void clearInstance();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text style={uiStyles.muted}>
|
||||
Switching instance clears your current session to keep auth isolated per server.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<Label>Server Update Check</Label>
|
||||
<Text style={uiStyles.muted}>Checks `/api/updates/check` on your connected Trackeep instance.</Text>
|
||||
<Button
|
||||
label="Check For Updates"
|
||||
variant="secondary"
|
||||
onPress={checkUpdates}
|
||||
loading={checkingUpdates}
|
||||
disabled={!instanceUrl}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<ErrorText message={error} />
|
||||
{info ? <Text style={[uiStyles.muted, { color: '#047857', fontWeight: '600' }]}>{info}</Text> : null}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { trackeepApi } from '../lib/api';
|
||||
import { formatDate, tagsToText } from '../lib/format';
|
||||
import { Task } from '../types';
|
||||
import {
|
||||
Button,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
ScreenShell,
|
||||
SectionCard,
|
||||
colors,
|
||||
uiStyles,
|
||||
} from '../components/UI';
|
||||
|
||||
interface TasksScreenProps {
|
||||
instanceUrl: string;
|
||||
token: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function TasksScreen({ instanceUrl, token, isActive }: TasksScreenProps) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const list = await trackeepApi.tasks.list(instanceUrl, token);
|
||||
setTasks(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load tasks.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceUrl, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
void loadTasks();
|
||||
}
|
||||
}, [isActive, loadTasks]);
|
||||
|
||||
const createTask = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) {
|
||||
setError('Task title is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await trackeepApi.tasks.create(instanceUrl, token, {
|
||||
title: trimmedTitle,
|
||||
description: description.trim(),
|
||||
priority,
|
||||
status: 'pending',
|
||||
});
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create task.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskStatus = async (task: Task) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
|
||||
|
||||
try {
|
||||
await trackeepApi.tasks.update(instanceUrl, token, task.id, {
|
||||
status: nextStatus,
|
||||
progress: nextStatus === 'completed' ? 100 : 0,
|
||||
});
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update task.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (id: number) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await trackeepApi.tasks.remove(instanceUrl, token, id);
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete task.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Tasks" subtitle="Create, update and sync task state with your Trackeep backend.">
|
||||
<SectionCard>
|
||||
<Label>New Task</Label>
|
||||
<Input placeholder="Task title" value={title} onChangeText={setTitle} />
|
||||
<Input
|
||||
multiline
|
||||
placeholder="Task description"
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
/>
|
||||
|
||||
<Label>Priority</Label>
|
||||
<View style={uiStyles.row}>
|
||||
{(['low', 'medium', 'high'] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
label={value}
|
||||
variant={priority === value ? 'primary' : 'secondary'}
|
||||
onPress={() => setPriority(value)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Refresh" variant="secondary" onPress={loadTasks} loading={loading} />
|
||||
<Button label="Create Task" onPress={createTask} loading={saving} />
|
||||
</View>
|
||||
<ErrorText message={error} />
|
||||
</SectionCard>
|
||||
|
||||
{tasks.map((task) => {
|
||||
const tags = tagsToText(task.tags);
|
||||
const isCompleted = task.status === 'completed';
|
||||
|
||||
return (
|
||||
<SectionCard key={task.id}>
|
||||
<View style={uiStyles.splitRow}>
|
||||
<Text style={{ fontWeight: '700', color: colors.text, flex: 1 }}>{task.title}</Text>
|
||||
<View style={[uiStyles.chip, { backgroundColor: isCompleted ? '#DCFCE7' : '#E6F6FB' }]}>
|
||||
<Text style={uiStyles.chipText}>{task.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{task.description ? <Text style={uiStyles.muted}>{task.description}</Text> : null}
|
||||
<Text style={uiStyles.muted}>Priority: {task.priority}</Text>
|
||||
<Text style={uiStyles.muted}>Updated: {formatDate(task.updated_at)}</Text>
|
||||
{tags ? <Text style={uiStyles.muted}>Tags: {tags}</Text> : null}
|
||||
|
||||
<View style={uiStyles.row}>
|
||||
<Button
|
||||
label={isCompleted ? 'Mark Pending' : 'Mark Complete'}
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
void toggleTaskStatus(task);
|
||||
}}
|
||||
loading={saving}
|
||||
/>
|
||||
<Button
|
||||
label="Delete"
|
||||
variant="danger"
|
||||
onPress={() => {
|
||||
void deleteTask(task.id);
|
||||
}}
|
||||
loading={saving}
|
||||
/>
|
||||
</View>
|
||||
</SectionCard>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loading && tasks.length === 0 ? (
|
||||
<SectionCard>
|
||||
<Text style={uiStyles.muted}>No tasks found yet.</Text>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { trackeepApi } from '../lib/api';
|
||||
import { formatDate, formatDuration } from '../lib/format';
|
||||
import { TimeEntry } from '../types';
|
||||
import {
|
||||
Button,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
ScreenShell,
|
||||
SectionCard,
|
||||
colors,
|
||||
uiStyles,
|
||||
} from '../components/UI';
|
||||
|
||||
interface TimeEntriesScreenProps {
|
||||
instanceUrl: string;
|
||||
token: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function resolveDurationSeconds(entry: TimeEntry, nowMs: number): number {
|
||||
if (typeof entry.duration === 'number' && entry.duration >= 0) {
|
||||
return entry.duration;
|
||||
}
|
||||
|
||||
const startMs = new Date(entry.start_time).getTime();
|
||||
if (Number.isNaN(startMs)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (entry.is_running) {
|
||||
return Math.floor((nowMs - startMs) / 1000);
|
||||
}
|
||||
|
||||
if (entry.end_time) {
|
||||
const endMs = new Date(entry.end_time).getTime();
|
||||
if (!Number.isNaN(endMs)) {
|
||||
return Math.floor((endMs - startMs) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function TimeEntriesScreen({ instanceUrl, token, isActive }: TimeEntriesScreenProps) {
|
||||
const [entries, setEntries] = useState<TimeEntry[]>([]);
|
||||
const [description, setDescription] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
const hasRunningEntries = useMemo(() => entries.some((entry) => entry.is_running), [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRunningEntries) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [hasRunningEntries]);
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await trackeepApi.timeEntries.list(instanceUrl, token);
|
||||
setEntries(response.time_entries);
|
||||
setNowMs(Date.now());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load time entries.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceUrl, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
void loadEntries();
|
||||
}
|
||||
}, [isActive, loadEntries]);
|
||||
|
||||
const startEntry = async () => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
setError('Description is required to start tracking time.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await trackeepApi.timeEntries.create(instanceUrl, token, {
|
||||
description: trimmed,
|
||||
source: 'mobile',
|
||||
});
|
||||
setDescription('');
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start timer.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopEntry = async (id: number) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await trackeepApi.timeEntries.stop(instanceUrl, token, id);
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to stop timer.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEntry = async (id: number) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await trackeepApi.timeEntries.remove(instanceUrl, token, id);
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete entry.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Time Tracking" subtitle="Start and stop timers synced with Trackeep time entries.">
|
||||
<SectionCard>
|
||||
<Label>Start New Timer</Label>
|
||||
<Input placeholder="What are you working on?" value={description} onChangeText={setDescription} />
|
||||
<View style={uiStyles.row}>
|
||||
<Button label="Refresh" variant="secondary" onPress={loadEntries} loading={loading} />
|
||||
<Button label="Start Timer" onPress={startEntry} loading={saving} />
|
||||
</View>
|
||||
<ErrorText message={error} />
|
||||
</SectionCard>
|
||||
|
||||
{entries.map((entry) => {
|
||||
const duration = formatDuration(resolveDurationSeconds(entry, nowMs));
|
||||
|
||||
return (
|
||||
<SectionCard key={entry.id}>
|
||||
<View style={uiStyles.splitRow}>
|
||||
<Text style={{ color: colors.text, fontWeight: '700', flex: 1 }}>{entry.description || 'Untitled entry'}</Text>
|
||||
<View style={[uiStyles.chip, { backgroundColor: entry.is_running ? '#DCFCE7' : '#E6F6FB' }]}>
|
||||
<Text style={uiStyles.chipText}>{entry.is_running ? 'Running' : 'Stopped'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={uiStyles.muted}>Duration: {duration}</Text>
|
||||
<Text style={uiStyles.muted}>Started: {formatDate(entry.start_time)}</Text>
|
||||
{entry.end_time ? <Text style={uiStyles.muted}>Ended: {formatDate(entry.end_time)}</Text> : null}
|
||||
|
||||
<View style={uiStyles.row}>
|
||||
{entry.is_running ? (
|
||||
<Button
|
||||
label="Stop"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
void stopEntry(entry.id);
|
||||
}}
|
||||
loading={saving}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="Delete"
|
||||
variant="danger"
|
||||
onPress={() => {
|
||||
void deleteEntry(entry.id);
|
||||
}}
|
||||
loading={saving}
|
||||
/>
|
||||
</View>
|
||||
</SectionCard>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loading && entries.length === 0 ? (
|
||||
<SectionCard>
|
||||
<Text style={uiStyles.muted}>No time entries found yet.</Text>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
import { ShareIntent, ShareIntentFile } from 'expo-share-intent';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
BackHandler,
|
||||
Linking,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { WebView, WebViewMessageEvent, WebViewNavigation } from 'react-native-webview';
|
||||
|
||||
import { colors } from '../components/UI';
|
||||
import { trackeepApi } from '../lib/api';
|
||||
import { buildShareDraft, looksLikeYouTube, ShareDraft } from '../lib/share';
|
||||
import { User } from '../types';
|
||||
|
||||
interface ShareIntentState {
|
||||
hasShareIntent: boolean;
|
||||
shareIntent: ShareIntent;
|
||||
resetShareIntent: (clearNativeModule?: boolean) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface WebAppScreenProps {
|
||||
instanceUrl: string;
|
||||
token: string;
|
||||
user: User;
|
||||
onLogout: () => Promise<void>;
|
||||
shareIntentState: ShareIntentState;
|
||||
}
|
||||
|
||||
type BridgeMessage = {
|
||||
type: 'NAV_CHANGE' | 'AUTH_LOGOUT' | 'AUTH_TOKEN' | 'BOOTSTRAP_DONE';
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ROUTES: Array<{ label: string; path: string }> = [
|
||||
{ label: 'Dashboard', path: '/app' },
|
||||
{ label: 'Bookmarks', path: '/app/bookmarks' },
|
||||
{ label: 'Tasks', path: '/app/tasks' },
|
||||
{ label: 'Notes', path: '/app/notes' },
|
||||
{ label: 'Files', path: '/app/files' },
|
||||
{ label: 'YouTube', path: '/app/youtube' },
|
||||
{ label: 'Time', path: '/app/time-tracking' },
|
||||
];
|
||||
|
||||
function isInternalUrl(url: string, instanceUrl: string): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.startsWith('about:blank') || url.startsWith('data:') || url.startsWith('javascript:')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const target = new URL(url);
|
||||
const base = new URL(instanceUrl);
|
||||
return target.origin === base.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return '/app';
|
||||
}
|
||||
}
|
||||
|
||||
function toAbsolute(instanceUrl: string, path: string): string {
|
||||
return new URL(path, `${instanceUrl}/`).toString();
|
||||
}
|
||||
|
||||
function isGenericShareTitle(title: string, url: string): boolean {
|
||||
const normalized = title.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === 'shared link' || normalized === 'youtube video') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
||||
return normalized === host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueFiles(files: ShareIntentFile[]): ShareIntentFile[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ShareIntentFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const key = `${file.path}|${file.fileName}|${file.size || 0}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(file);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function WebAppScreen({ instanceUrl, token, user, onLogout, shareIntentState }: WebAppScreenProps) {
|
||||
const { hasShareIntent, shareIntent, resetShareIntent, error: shareIntentError } = shareIntentState;
|
||||
|
||||
const webRef = useRef<WebView>(null);
|
||||
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
const [webLoading, setWebLoading] = useState(true);
|
||||
const [webError, setWebError] = useState<string | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState('/app');
|
||||
|
||||
const [showManualShare, setShowManualShare] = useState(false);
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [manualUrl, setManualUrl] = useState('');
|
||||
const [manualDescription, setManualDescription] = useState('');
|
||||
|
||||
const [incomingDraft, setIncomingDraft] = useState<ShareDraft | null>(null);
|
||||
const [incomingFiles, setIncomingFiles] = useState<ShareIntentFile[]>([]);
|
||||
const [draftMetadataLoading, setDraftMetadataLoading] = useState(false);
|
||||
|
||||
const [shareBusy, setShareBusy] = useState(false);
|
||||
const [shareError, setShareError] = useState<string | null>(null);
|
||||
const [shareInfo, setShareInfo] = useState<string | null>(null);
|
||||
|
||||
const appUrl = toAbsolute(instanceUrl, '/app');
|
||||
const incomingIsYouTube = incomingDraft ? looksLikeYouTube(incomingDraft.url) : false;
|
||||
|
||||
const injectedBeforeLoad = useMemo(() => {
|
||||
const injectedUser = JSON.stringify(user);
|
||||
|
||||
return `
|
||||
(function() {
|
||||
try {
|
||||
localStorage.setItem('trackeep_token', ${JSON.stringify(token)});
|
||||
localStorage.setItem('token', ${JSON.stringify(token)});
|
||||
localStorage.setItem('trackeep_user', JSON.stringify(${injectedUser}));
|
||||
localStorage.setItem('user', JSON.stringify(${injectedUser}));
|
||||
window.__TRACKEEP_MOBILE__ = true;
|
||||
|
||||
if (!window.__TRACKEEP_BRIDGE__) {
|
||||
window.__TRACKEEP_BRIDGE__ = true;
|
||||
|
||||
var postBridge = function(type, payload) {
|
||||
try {
|
||||
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(
|
||||
JSON.stringify({ type: type, payload: payload || {} })
|
||||
);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
var notifyAuthState = function() {
|
||||
var activeToken = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
if (!activeToken) {
|
||||
postBridge('AUTH_LOGOUT', {});
|
||||
} else {
|
||||
postBridge('AUTH_TOKEN', {});
|
||||
}
|
||||
};
|
||||
|
||||
var notifyNav = function() {
|
||||
postBridge('NAV_CHANGE', {
|
||||
href: window.location.href,
|
||||
path: window.location.pathname + window.location.search + window.location.hash,
|
||||
});
|
||||
};
|
||||
|
||||
var originalSetItem = localStorage.setItem.bind(localStorage);
|
||||
var originalRemoveItem = localStorage.removeItem.bind(localStorage);
|
||||
|
||||
localStorage.setItem = function(key, value) {
|
||||
originalSetItem(key, value);
|
||||
if (key === 'trackeep_token' || key === 'token') {
|
||||
notifyAuthState();
|
||||
}
|
||||
};
|
||||
|
||||
localStorage.removeItem = function(key) {
|
||||
originalRemoveItem(key);
|
||||
if (key === 'trackeep_token' || key === 'token') {
|
||||
notifyAuthState();
|
||||
}
|
||||
};
|
||||
|
||||
var originalPushState = history.pushState.bind(history);
|
||||
history.pushState = function() {
|
||||
var result = originalPushState.apply(history, arguments);
|
||||
notifyNav();
|
||||
return result;
|
||||
};
|
||||
|
||||
var originalReplaceState = history.replaceState.bind(history);
|
||||
history.replaceState = function() {
|
||||
var result = originalReplaceState.apply(history, arguments);
|
||||
notifyNav();
|
||||
return result;
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', notifyNav);
|
||||
notifyNav();
|
||||
postBridge('BOOTSTRAP_DONE', { hasToken: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Mobile bootstrap failed', error);
|
||||
}
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
}, [token, user]);
|
||||
|
||||
const instanceLabel = useMemo(() => {
|
||||
try {
|
||||
return new URL(instanceUrl).host;
|
||||
} catch {
|
||||
return instanceUrl;
|
||||
}
|
||||
}, [instanceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setShareError(shareIntentError);
|
||||
}, [shareIntentError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasShareIntent) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShareInfo(null);
|
||||
setShareError(null);
|
||||
|
||||
const draft = buildShareDraft(shareIntent);
|
||||
const files = uniqueFiles(shareIntent.files || []);
|
||||
|
||||
if (draft) {
|
||||
setIncomingDraft(draft);
|
||||
setIncomingFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
setIncomingFiles(files);
|
||||
setIncomingDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setShareError('Shared content was received but no supported URL or file was detected.');
|
||||
resetShareIntent();
|
||||
}, [
|
||||
hasShareIntent,
|
||||
shareIntent.text,
|
||||
shareIntent.webUrl,
|
||||
shareIntent.files,
|
||||
shareIntent.meta,
|
||||
resetShareIntent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!incomingDraft?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDraftMetadataLoading(true);
|
||||
|
||||
void trackeepApi.bookmarks
|
||||
.metadata(instanceUrl, token, incomingDraft.url)
|
||||
.then((metadata) => {
|
||||
if (cancelled || !metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIncomingDraft((current) => {
|
||||
if (!current || current.url !== incomingDraft.url) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const betterTitle =
|
||||
metadata.title && metadata.title.trim().length > 0 && isGenericShareTitle(current.title, current.url)
|
||||
? metadata.title.trim()
|
||||
: current.title;
|
||||
|
||||
const betterDescription =
|
||||
metadata.description && metadata.description.trim().length > 0 && current.description.trim().length < 12
|
||||
? metadata.description.trim()
|
||||
: current.description;
|
||||
|
||||
return {
|
||||
...current,
|
||||
title: betterTitle,
|
||||
description: betterDescription,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// metadata enrichment is best-effort only
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setDraftMetadataLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [incomingDraft?.url, instanceUrl, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'android') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (canGoBack) {
|
||||
webRef.current?.goBack();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => sub.remove();
|
||||
}, [canGoBack]);
|
||||
|
||||
const clearIncomingShare = useCallback(() => {
|
||||
setIncomingDraft(null);
|
||||
setIncomingFiles([]);
|
||||
setDraftMetadataLoading(false);
|
||||
resetShareIntent();
|
||||
}, [resetShareIntent]);
|
||||
|
||||
const refreshWebView = useCallback(() => {
|
||||
webRef.current?.reload();
|
||||
}, []);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(path: string) => {
|
||||
const target = toAbsolute(instanceUrl, path);
|
||||
webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(target)}); true;`);
|
||||
setCurrentPath(path);
|
||||
},
|
||||
[instanceUrl],
|
||||
);
|
||||
|
||||
const openCurrentInBrowser = useCallback(async () => {
|
||||
const target = toAbsolute(instanceUrl, currentPath || '/app');
|
||||
await Linking.openURL(target);
|
||||
}, [instanceUrl, currentPath]);
|
||||
|
||||
const saveBookmark = async (draft: ShareDraft, routeAfterSave = '/app/bookmarks') => {
|
||||
setShareBusy(true);
|
||||
setShareError(null);
|
||||
setShareInfo(null);
|
||||
|
||||
try {
|
||||
await trackeepApi.bookmarks.create(instanceUrl, token, {
|
||||
title: draft.title.trim(),
|
||||
url: draft.url.trim(),
|
||||
description: draft.description.trim() || 'Shared from Trackeep Mobile',
|
||||
});
|
||||
|
||||
const isYoutube = looksLikeYouTube(draft.url);
|
||||
setShareInfo(isYoutube ? 'Saved YouTube link to Trackeep.' : 'Saved bookmark to Trackeep.');
|
||||
clearIncomingShare();
|
||||
navigateTo(routeAfterSave);
|
||||
} catch (error) {
|
||||
setShareError(error instanceof Error ? error.message : 'Failed to save shared bookmark.');
|
||||
} finally {
|
||||
setShareBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadIncomingFiles = async (files: ShareIntentFile[]) => {
|
||||
setShareBusy(true);
|
||||
setShareError(null);
|
||||
setShareInfo(null);
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
await trackeepApi.files.uploadFromUri(instanceUrl, token, {
|
||||
uri: file.path,
|
||||
name: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
description: 'Shared from mobile',
|
||||
});
|
||||
}
|
||||
|
||||
setShareInfo(
|
||||
files.length === 1
|
||||
? `Uploaded \"${files[0].fileName}\" to Trackeep Files.`
|
||||
: `Uploaded ${files.length} shared files to Trackeep Files.`,
|
||||
);
|
||||
clearIncomingShare();
|
||||
navigateTo('/app/files');
|
||||
} catch (error) {
|
||||
setShareError(error instanceof Error ? error.message : 'Failed to upload shared files.');
|
||||
} finally {
|
||||
setShareBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitManualShare = async () => {
|
||||
const draft: ShareDraft = {
|
||||
title: manualTitle.trim(),
|
||||
url: manualUrl.trim(),
|
||||
description: manualDescription.trim(),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
if (!draft.title || !draft.url) {
|
||||
setShareError('Title and URL are required for quick share.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(draft.url);
|
||||
} catch {
|
||||
setShareError('Quick share URL must be a valid absolute URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
await saveBookmark(draft, looksLikeYouTube(draft.url) ? '/app/youtube' : '/app/bookmarks');
|
||||
setManualTitle('');
|
||||
setManualUrl('');
|
||||
setManualDescription('');
|
||||
setShowManualShare(false);
|
||||
};
|
||||
|
||||
const onWebMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
const raw = event.nativeEvent.data;
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(raw) as BridgeMessage;
|
||||
|
||||
if (message.type === 'NAV_CHANGE') {
|
||||
const path = typeof message.payload?.path === 'string' ? message.payload.path : null;
|
||||
if (path) {
|
||||
setCurrentPath(path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'AUTH_LOGOUT') {
|
||||
void onLogout();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed bridge messages.
|
||||
}
|
||||
},
|
||||
[onLogout],
|
||||
);
|
||||
|
||||
const onShouldStartLoadWithRequest = useCallback(
|
||||
(request: { url: string }) => {
|
||||
if (isInternalUrl(request.url, instanceUrl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void Linking.openURL(request.url);
|
||||
return false;
|
||||
},
|
||||
[instanceUrl],
|
||||
);
|
||||
|
||||
const onNavigationStateChange = useCallback((state: WebViewNavigation) => {
|
||||
setCanGoBack(state.canGoBack);
|
||||
setCanGoForward(state.canGoForward);
|
||||
setCurrentPath(pathFromUrl(state.url));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.topBar}>
|
||||
<Text style={styles.instanceText}>Connected: {instanceLabel}</Text>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.routePills}>
|
||||
{ROUTES.map((route) => {
|
||||
const active = currentPath.startsWith(route.path);
|
||||
return (
|
||||
<Pressable
|
||||
key={route.path}
|
||||
style={[styles.routePill, active ? styles.routePillActive : undefined]}
|
||||
onPress={() => navigateTo(route.path)}
|
||||
>
|
||||
<Text style={[styles.routePillText, active ? styles.routePillTextActive : undefined]}>{route.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.topButtons}>
|
||||
<Pressable style={styles.topButton} onPress={() => setShowManualShare((prev) => !prev)}>
|
||||
<Text style={styles.topButtonText}>Quick Share</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.topButton} onPress={refreshWebView}>
|
||||
<Text style={styles.topButtonText}>Reload</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.topButton} onPress={() => void openCurrentInBrowser()}>
|
||||
<Text style={styles.topButtonText}>Browser</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.topButton, styles.topButtonDanger]} onPress={() => void onLogout()}>
|
||||
<Text style={[styles.topButtonText, styles.topButtonDangerText]}>Logout</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showManualShare ? (
|
||||
<View style={styles.sharePanel}>
|
||||
<Text style={styles.sharePanelTitle}>Quick Share To Trackeep</Text>
|
||||
<TextInput
|
||||
placeholder="Title"
|
||||
placeholderTextColor={colors.muted}
|
||||
style={styles.input}
|
||||
value={manualTitle}
|
||||
onChangeText={setManualTitle}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="https://example.com"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholderTextColor={colors.muted}
|
||||
style={styles.input}
|
||||
value={manualUrl}
|
||||
onChangeText={setManualUrl}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Description (optional)"
|
||||
placeholderTextColor={colors.muted}
|
||||
style={[styles.input, styles.textarea]}
|
||||
value={manualDescription}
|
||||
onChangeText={setManualDescription}
|
||||
multiline
|
||||
/>
|
||||
{looksLikeYouTube(manualUrl.trim()) ? (
|
||||
<Text style={styles.infoTextInline}>YouTube link detected. It will be saved and opened in YouTube section.</Text>
|
||||
) : null}
|
||||
<View style={styles.shareActions}>
|
||||
<Pressable style={styles.secondaryButton} onPress={() => setShowManualShare(false)}>
|
||||
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={() => void submitManualShare()} disabled={shareBusy}>
|
||||
<Text style={styles.primaryButtonText}>{shareBusy ? 'Saving...' : 'Save Bookmark'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{incomingDraft ? (
|
||||
<View style={styles.sharePanel}>
|
||||
<Text style={styles.sharePanelTitle}>Incoming Shared Link</Text>
|
||||
<Text style={styles.shareLine}>Title: {incomingDraft.title}</Text>
|
||||
<Text style={styles.shareLine}>URL: {incomingDraft.url}</Text>
|
||||
{incomingDraft.description ? <Text style={styles.shareLine}>Text: {incomingDraft.description}</Text> : null}
|
||||
{draftMetadataLoading ? <Text style={styles.infoTextInline}>Fetching page metadata...</Text> : null}
|
||||
<View style={styles.shareActions}>
|
||||
<Pressable style={styles.secondaryButton} onPress={clearIncomingShare} disabled={shareBusy}>
|
||||
<Text style={styles.secondaryButtonText}>Dismiss</Text>
|
||||
</Pressable>
|
||||
{incomingIsYouTube ? (
|
||||
<Pressable
|
||||
style={styles.primaryButton}
|
||||
onPress={() => void saveBookmark(incomingDraft, '/app/youtube')}
|
||||
disabled={shareBusy}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{shareBusy ? 'Saving...' : 'Save + Open YouTube'}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={[styles.primaryButton, incomingIsYouTube ? styles.primaryButtonAlt : undefined]}
|
||||
onPress={() => void saveBookmark(incomingDraft)}
|
||||
disabled={shareBusy}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{shareBusy ? 'Saving...' : 'Save To Bookmarks'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{incomingFiles.length > 0 ? (
|
||||
<View style={styles.sharePanel}>
|
||||
<Text style={styles.sharePanelTitle}>Incoming Shared Files ({incomingFiles.length})</Text>
|
||||
{incomingFiles.slice(0, 3).map((file) => (
|
||||
<Text key={`${file.path}-${file.fileName}`} style={styles.shareLine}>
|
||||
• {file.fileName}
|
||||
</Text>
|
||||
))}
|
||||
{incomingFiles.length > 3 ? <Text style={styles.shareLine}>...and {incomingFiles.length - 3} more</Text> : null}
|
||||
<View style={styles.shareActions}>
|
||||
<Pressable style={styles.secondaryButton} onPress={clearIncomingShare} disabled={shareBusy}>
|
||||
<Text style={styles.secondaryButtonText}>Dismiss</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.primaryButton}
|
||||
onPress={() => void uploadIncomingFiles(incomingFiles)}
|
||||
disabled={shareBusy}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{shareBusy ? 'Uploading...' : 'Upload To Files'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{shareError ? <Text style={styles.errorText}>{shareError}</Text> : null}
|
||||
{shareInfo ? <Text style={styles.infoText}>{shareInfo}</Text> : null}
|
||||
{webError ? <Text style={styles.errorText}>Web app failed to load: {webError}</Text> : null}
|
||||
|
||||
<View style={styles.webContainer}>
|
||||
<WebView
|
||||
ref={webRef}
|
||||
source={{ uri: appUrl }}
|
||||
injectedJavaScriptBeforeContentLoaded={injectedBeforeLoad}
|
||||
sharedCookiesEnabled
|
||||
thirdPartyCookiesEnabled
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
cacheEnabled
|
||||
setSupportMultipleWindows={false}
|
||||
onMessage={onWebMessage}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
onLoadStart={() => {
|
||||
setWebLoading(true);
|
||||
setWebError(null);
|
||||
}}
|
||||
onLoadEnd={() => setWebLoading(false)}
|
||||
onError={(event) => {
|
||||
setWebLoading(false);
|
||||
setWebError(event.nativeEvent.description || 'Unknown network error');
|
||||
}}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
/>
|
||||
{webLoading ? (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.navBar}>
|
||||
<Pressable
|
||||
style={[styles.navButton, !canGoBack ? styles.navButtonDisabled : undefined]}
|
||||
onPress={() => webRef.current?.goBack()}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
<Text style={styles.navButtonText}>Back</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.navButton, !canGoForward ? styles.navButtonDisabled : undefined]}
|
||||
onPress={() => webRef.current?.goForward()}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
<Text style={styles.navButtonText}>Forward</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.navButton} onPress={refreshWebView}>
|
||||
<Text style={styles.navButtonText}>Refresh</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
topBar: {
|
||||
borderBottomWidth: 1,
|
||||
borderColor: '#D8E1EC',
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 8,
|
||||
},
|
||||
instanceText: {
|
||||
color: colors.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
routePills: {
|
||||
gap: 8,
|
||||
paddingRight: 8,
|
||||
},
|
||||
routePill: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#D6DEE8',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
routePillActive: {
|
||||
backgroundColor: '#E6F6FB',
|
||||
borderColor: '#8adcf2',
|
||||
},
|
||||
routePillText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.muted,
|
||||
},
|
||||
routePillTextActive: {
|
||||
color: colors.primaryDark,
|
||||
},
|
||||
topButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
topButton: {
|
||||
backgroundColor: '#EDF2F7',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 7,
|
||||
},
|
||||
topButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
topButtonDanger: {
|
||||
backgroundColor: '#FEE2E2',
|
||||
},
|
||||
topButtonDangerText: {
|
||||
color: '#991B1B',
|
||||
},
|
||||
sharePanel: {
|
||||
marginHorizontal: 12,
|
||||
marginTop: 10,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#D6DEE8',
|
||||
backgroundColor: '#FFFFFF',
|
||||
gap: 8,
|
||||
},
|
||||
sharePanelTitle: {
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
},
|
||||
shareLine: {
|
||||
color: colors.muted,
|
||||
fontSize: 13,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#D6DEE8',
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: colors.text,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 9,
|
||||
},
|
||||
textarea: {
|
||||
minHeight: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
shareActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
secondaryButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#D6DEE8',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
fontSize: 12,
|
||||
},
|
||||
primaryButton: {
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
primaryButtonAlt: {
|
||||
backgroundColor: colors.primaryDark,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: '#B91C1C',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 8,
|
||||
},
|
||||
infoText: {
|
||||
color: '#047857',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 8,
|
||||
},
|
||||
infoTextInline: {
|
||||
color: '#047857',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
webContainer: {
|
||||
flex: 1,
|
||||
marginTop: 8,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
navBar: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
borderColor: '#D8E1EC',
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
gap: 8,
|
||||
},
|
||||
navButton: {
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#D6DEE8',
|
||||
backgroundColor: '#F8FAFC',
|
||||
minHeight: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
navButtonDisabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
navButtonText: {
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
full_name: string;
|
||||
role?: string;
|
||||
theme?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
favicon?: string;
|
||||
screenshot?: string;
|
||||
is_read?: boolean;
|
||||
is_favorite?: boolean;
|
||||
content?: string;
|
||||
author?: string;
|
||||
published_at?: string | null;
|
||||
tags?: Array<Tag | string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
progress?: number;
|
||||
due_date?: string | null;
|
||||
completed_at?: string | null;
|
||||
tags?: Array<Tag | string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
content?: string;
|
||||
description?: string;
|
||||
is_public: boolean;
|
||||
is_pinned?: boolean;
|
||||
content_type?: string;
|
||||
tags?: Array<Tag | string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type FileType = 'document' | 'image' | 'video' | 'audio' | 'archive' | 'other';
|
||||
|
||||
export interface FileItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
original_name: string;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
file_type: FileType;
|
||||
description?: string;
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: number;
|
||||
user_id: number;
|
||||
task_id?: number;
|
||||
bookmark_id?: number;
|
||||
note_id?: number;
|
||||
start_time: string;
|
||||
end_time?: string | null;
|
||||
duration?: number | null;
|
||||
description: string;
|
||||
billable: boolean;
|
||||
hourly_rate?: number | null;
|
||||
is_running: boolean;
|
||||
source: string;
|
||||
tags?: Array<Tag | string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TimeStats {
|
||||
total_time_seconds: number;
|
||||
total_entries: number;
|
||||
running_entries: number;
|
||||
billable_time_seconds: number;
|
||||
total_billable_amount: number;
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
version?: string;
|
||||
app_version?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UpdateCheckInfo {
|
||||
update_available?: boolean;
|
||||
current_version?: string;
|
||||
latest_version?: string;
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||