Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions src/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/* eslint-disable react-native/no-inline-styles */
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import React, { Component } from 'react';
Expand All @@ -13,6 +12,7 @@ import { appConfig } from './constants';
import { Session } from './data/auth/Session';
import Location from './data/location/Location';
import * as NavigationService from './NavigationService';
import { migrate } from './components/ProfileStorage';
import { getSessionAction } from './redux/actions/main';
import { RootState } from './redux/reducers';
import AdjustStock from './screens/AdjustStock';
Expand Down Expand Up @@ -47,6 +47,7 @@ import PutawayDetails from './screens/PutawayDetails';
import PutawayItem from './screens/PutawayItem';
import PutawayItemDetail from './screens/PutawayItemDetail';
import PutawayList from './screens/PutawayList';
import ProfilesScreen from './screens/Profiles';
import Scan from './screens/Scan';
import Settings from './screens/Settings';
import ShipItemDetails from './screens/ShipItemDetails';
Expand Down Expand Up @@ -121,16 +122,6 @@ class Main extends Component<Props, State> {
};
}

UNSAFE_componentWillMount() {
AsyncStorage.getItem('API_URL').then((value) => {
if (!value) {
NavigationService.navigate('Settings');
} else {
ApiClient.setBaseUrl(value);
}
});
}

shouldComponentUpdate(nextProps: Props) {
return (
this.props.fullScreenLoadingIndicator.visible !== nextProps.fullScreenLoadingIndicator.visible ||
Expand Down Expand Up @@ -161,9 +152,22 @@ class Main extends Component<Props, State> {

componentDidMount() {
SplashScreen.hide();
AsyncStorage.setItem('launched', 'true');
}

onNavigatorReady = () => {
migrate()
.then((serverUrl) => {
if (serverUrl) {
ApiClient.setBaseUrl(serverUrl);
} else {
NavigationService.navigate('Profiles');
}
})
.catch(() => {
NavigationService.navigate('Profiles');
});
};

render() {
const { loggedIn } = this.props;
const initialRouteName = !loggedIn ? 'Login' : 'Choose Location';
Expand All @@ -174,7 +178,7 @@ class Main extends Component<Props, State> {
visible={this.props.fullScreenLoadingIndicator.visible}
message={this.props.fullScreenLoadingIndicator.message}
/>
<NavigationContainer ref={NavigationService.navigationRef}>
<NavigationContainer ref={NavigationService.navigationRef} onReady={this.onNavigatorReady}>
<Stack.Navigator
initialRouteName={initialRouteName}
screenOptions={({ route, navigation }) => ({
Expand Down Expand Up @@ -246,6 +250,7 @@ class Main extends Component<Props, State> {
options={{ title: 'Receive Detail' }}
/>
<Stack.Screen name="Settings" component={Settings} options={{ title: 'Settings' }} />
<Stack.Screen name="Profiles" component={ProfilesScreen} options={{ title: 'Server Profiles' }} />
<Stack.Screen name="OutboundStockList" component={OutboundStockList} options={{ title: 'Packing' }} />
<Stack.Screen
name="OutboundStockDetails"
Expand Down
63 changes: 63 additions & 0 deletions src/components/ProfileCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';

export default function ProfileCardSkeleton() {
return (
<View style={styles.card}>
<View style={styles.row}>
<View style={styles.inner}>
<View style={[styles.block, styles.title]} />
<View style={styles.row}>
<View style={[styles.block, styles.icon]} />
<View style={styles.inner}>
<View style={[styles.block, styles.label]} />
<View style={[styles.block, styles.url]} />
</View>
</View>
</View>
</View>
</View>
);
}

const styles = StyleSheet.create({
card: {
backgroundColor: '#f5f6f8',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e0e0e0',
paddingVertical: 10,
paddingHorizontal: 14,
marginBottom: 20
},
row: {
flexDirection: 'row',
alignItems: 'center'
},
inner: {
flex: 1
},
block: {
backgroundColor: '#e0e0e0',
borderRadius: 4
},
title: {
width: 90,
height: 10,
marginBottom: 8
},
icon: {
width: 22,
height: 22,
marginRight: 12
},
label: {
width: 120,
height: 14,
marginBottom: 6
},
url: {
width: 180,
height: 10
}
});
196 changes: 196 additions & 0 deletions src/components/ProfileStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import AsyncStorage from '@react-native-async-storage/async-storage';

import { Profile, ProfileStorageData } from '../types/profile';
import { createEventEmitter } from '../utils/EventEmitter';

function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}

const PROFILES_KEY = 'PROFILES';
const LEGACY_API_URL_KEY = 'API_URL';
const CURRENT_VERSION = 1;

const DEFAULT_SERVERS = [
{ label: 'Staging Server', serverUrl: 'https://stag.vtc.openboxes.com/openboxes/api' },
{ label: 'Test Server', serverUrl: 'https://vvg.openboxes.com/openboxes/api' }
];

const emitter = createEventEmitter();
export const subscribe = emitter.subscribe;

export function createStorage(profiles: Profile[] = [], activeProfileId?: string): ProfileStorageData {
return {
version: CURRENT_VERSION,
activeProfileId: activeProfileId ?? profiles[0]?.id ?? null,
profiles
};
}

function parseStorageData(raw: string | null): ProfileStorageData {
if (!raw) {
return createStorage();
}

try {
const parsed = JSON.parse(raw);

return {
version: parsed.version ?? CURRENT_VERSION,
activeProfileId: parsed.activeProfileId ?? null,
profiles: Array.isArray(parsed.profiles)
? parsed.profiles.map((p: any) => ({
id: p.id ?? generateId(),
label: p.label ?? 'Unknown',
serverUrl: p.serverUrl ?? '',
settings: p.settings ?? {}
}))
Comment thread
olewandowski1 marked this conversation as resolved.
: []
};
} catch {
return createStorage();
}
}

async function readStorage(): Promise<ProfileStorageData> {
const raw = await AsyncStorage.getItem(PROFILES_KEY);
return parseStorageData(raw);
}

async function writeStorage(data: ProfileStorageData): Promise<void> {
await AsyncStorage.setItem(PROFILES_KEY, JSON.stringify(data));
emitter.emit();
}

export function createProfile(label: string, serverUrl: string): Profile {
return {
id: generateId(),
label,
serverUrl: serverUrl.trim(),
settings: {}
};
}

function normalizeUrl(url: string): string {
return url.trim().toLowerCase().replace(/\/+$/, '');
}

export function validateUrl(url: string): string | null {
const trimmed = url.trim();
if (!trimmed) {
return 'URL is required';
}
if (!/^https?:\/\/.+/i.test(trimmed)) {
return 'URL must start with http:// or https://';
}
return null;
}

export function validateProfile(label: string, serverUrl: string): string | null {
if (!label.trim()) {
return 'Profile label is required';
}
return validateUrl(serverUrl);
}

/**
* Initializes profiles and returns the active server URL.
* 1. Profiles exist → use them as-is
* 2. No profiles, legacy API_URL exists → create Default (from legacy) + default servers
* 3. No profiles, no legacy → create default servers
*/
export async function migrate(): Promise<string | null> {
const data = await readStorage();

// Path 1: profiles already exist
if (data.profiles.length > 0) {
let active = data.profiles.find((p) => p.id === data.activeProfileId);

if (!active) {
active = data.profiles[0];
await writeStorage(createStorage(data.profiles, active.id));
}

return active.serverUrl;
}

// Path 2: legacy API_URL migration
const legacyUrl = await AsyncStorage.getItem(LEGACY_API_URL_KEY);

if (legacyUrl) {
const legacyProfile = createProfile('Default', legacyUrl);
const normalizedLegacy = normalizeUrl(legacyUrl);
const profiles = [legacyProfile];

for (const server of DEFAULT_SERVERS) {
if (normalizeUrl(server.serverUrl) !== normalizedLegacy) {
profiles.push(createProfile(server.label, server.serverUrl));
}
}

await writeStorage(createStorage(profiles, legacyProfile.id));
await AsyncStorage.removeItem(LEGACY_API_URL_KEY);
return legacyProfile.serverUrl;
}

// Path 3: fresh install
const profiles = DEFAULT_SERVERS.map((s) => createProfile(s.label, s.serverUrl));
await writeStorage(createStorage(profiles));

return profiles[0].serverUrl;
}

export async function getProfiles(): Promise<ProfileStorageData> {
return readStorage();
}

export async function getActiveProfile(): Promise<Profile | null> {
const data = await readStorage();
if (!data.activeProfileId) {
return data.profiles[0] ?? null;
}
return data.profiles.find((p) => p.id === data.activeProfileId) ?? data.profiles[0] ?? null;
}

export async function setActiveProfileId(id: string): Promise<void> {
const data = await readStorage();
const exists = data.profiles.some((p) => p.id === id);
if (exists) {
data.activeProfileId = id;
await writeStorage(data);
}
}

export async function saveProfile(profile: Profile): Promise<void> {
const data = await readStorage();
const index = data.profiles.findIndex((p) => p.id === profile.id);

if (index >= 0) {
data.profiles[index] = profile;
} else {
data.profiles.push(profile);
}

if (!data.activeProfileId) {
data.activeProfileId = profile.id;
}

await writeStorage(data);
}

export async function deleteProfile(id: string): Promise<boolean> {
const data = await readStorage();

if (data.profiles.length <= 1) {
return false;
}

data.profiles = data.profiles.filter((p) => p.id !== id);

if (data.activeProfileId === id) {
data.activeProfileId = data.profiles[0]?.id ?? null;
}

await writeStorage(data);
return true;
}
Loading