DONE: first init

This commit is contained in:
ISMAIL MASSERAN
2026-07-20 16:10:22 +08:00
commit 3189e3a1e3
272 changed files with 48853 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native
*.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
+12
View File
@@ -0,0 +1,12 @@
# Build instructions
```
cd mobile-app
```
```
npm install
```
```
npx expo start
```
### - open Expo Go application
### - scan QR code displayed in terminal
+40
View File
@@ -0,0 +1,40 @@
{
"expo": {
"name": "mobile-app",
"slug": "mobile-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"permissions": [
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE"
],
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.emacaga.mobileapp"
},
"web": {
"favicon": "./assets/favicon.png"
},
"extra": {
"eas": {
"projectId": "2a75751f-3867-4465-8c1d-6832a25c2b44"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+21
View File
@@ -0,0 +1,21 @@
{
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"preview2": {
"android": {
"gradleCommand": ":app:assembleRelease"
}
},
"preview3": {
"developmentClient": true
},
"preview4": {
"distribution": "internal"
},
"production": {}
}
}
+18023
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "mobile-app",
"version": "1.0.0",
"main": "./src/App.tsx",
"scripts": {
"ts:check": "tsc",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~3.1.3",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native-community/masked-view": "^0.1.11",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/native": "^6.1.17",
"@react-navigation/stack": "^6.3.29",
"@testing-library/react": "^14.2.2",
"@testing-library/react-native": "^12.4.4",
"buffer": "^6.0.3",
"expo": "~50.0.14",
"expo-file-system": "^16.0.9",
"expo-font": "^11.10.3",
"expo-sharing": "^11.10.0",
"expo-splash-screen": "^0.26.4",
"expo-status-bar": "~1.11.1",
"jest-fetch-mock": "^3.0.3",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.73.6",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.14.0",
"react-native-reanimated": "^3.6.2",
"react-native-safe-area-context": "^4.8.2",
"react-native-screens": "^3.29.0",
"react-native-web": "~0.19.6",
"react-navigation": "^5.0.0",
"rn-fetch-blob": "^0.12.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@testing-library/jest-dom": "^6.4.2",
"@types/jest": "^29.5.12",
"@types/react": "~18.2.45",
"jest": "^29.7.0",
"typescript": "^5.4.3"
},
"private": true
}
+10
View File
@@ -0,0 +1,10 @@
import { registerRootComponent } from "expo";
import AppNavigator from "./navigation/AppNavigator";
import AsyncStorage from "@react-native-async-storage/async-storage";
export default function App() {
AsyncStorage.clear();
return AppNavigator();
}
registerRootComponent(App);
@@ -0,0 +1,81 @@
import React, { useState, useEffect } from 'react';
import { View, Modal, Text, Button, StyleSheet, Dimensions } from 'react-native';
import { Colors } from '../constants/colors';
import { generateTicket } from '../services/fetchData';
import { getExpoToken } from '../utils/tokenUtils';
const windowWidth = Dimensions.get('window').width;
const windowHeight = Dimensions.get('window').height;
export default function ({ visible, onClose, number }: { visible: any, onClose: any, number: number}) {
const [modalVisible, setModalVisible] = useState(visible);
useEffect(() => {
setModalVisible(visible);
if (visible) {
const timer = setTimeout(() => {
setModalVisible(false);
onClose();
}, 5000);
return () => clearTimeout(timer);
}
}, [visible]);
if(number != 0 )
return (
<Modal
animationType="slide"
transparent={true}
visible={modalVisible}
onRequestClose={() => {
onClose();
}}
>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.message}>Vaš broj je:</Text>
<View style={styles.numberContainer}>
<Text style={styles.number}>{number}</Text>
</View>
<View style={styles.buttonContainer}>
<Button title="Dismiss" onPress={onClose} color={Colors.ACCENT} />
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)', // Semi-transparent background
},
modalContent: {
backgroundColor: 'white',
padding: windowWidth * 0.05, // Responsive padding
borderRadius: windowWidth * 0.05, // Responsive border radius
width: windowWidth * 0.8, // Responsive width
alignItems: 'center', // Center content horizontally
},
message: {
fontSize: windowWidth * 0.05, // Responsive font size
marginBottom: windowHeight * 0.02, // Responsive margin
},
numberContainer: {
marginBottom: windowHeight * 0.04, // Responsive margin
},
number: {
fontSize: windowWidth * 0.12, // Responsive font size
fontWeight: 'bold',
},
buttonContainer: {
marginTop: windowHeight * 0.02, // Responsive margin
alignSelf: 'flex-end', // Align button to the right
},
});
@@ -0,0 +1,16 @@
import { View } from "react-native";
import React from "react";
import LottieView from "lottie-react-native";
export default function LoadingAnimation() {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<LottieView
source={require("../../assets/animations/loading.json")}
autoPlay
loop
style={{ width: 200, height: 200 }}
/>
</View>
);
}
@@ -0,0 +1,58 @@
import React from "react";
import {
View,
Text,
StyleSheet,
SafeAreaView,
Dimensions,
Animated,
} from "react-native";
import { Colors } from "../constants/colors";
interface WelcomeMessageProps {
name: string;
welcome: string;
font: string;
}
export default function WelcomeMessage(params: WelcomeMessageProps) {
const screenWidth = Dimensions.get("window").width;
const fontSize = screenWidth * 0.09;
return (
<SafeAreaView style={styles.container}>
<Animated.Text style={[styles.title, { fontSize }]}>
{params.name}
</Animated.Text>
<View style={styles.borderContainer}>
<Text style={[styles.message, { fontFamily: params.font }]}>
{params.welcome}
</Text>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
alignItems: "center",
},
title: {
color: Colors.PRIMARY,
fontWeight: "bold",
marginBottom: 30,
},
borderContainer: {
borderWidth: 1,
padding: 10,
width: "90%",
alignItems: "center",
borderColor: Colors.ACCENT,
borderRadius: 10,
},
message: {
textAlign: "center",
fontSize: 40,
color: Colors.PRIMARY,
},
});
+3
View File
@@ -0,0 +1,3 @@
//export const SERVER_URL = 'http://localhost:8080';
//export const SERVER_URL = 'http://192.168.1.5:8080';
export const SERVER_URL = 'http://localhost:8080';
+13
View File
@@ -0,0 +1,13 @@
export const Assets = {
DEFAULT_LOGO: require("../../assets/images/default_logo.jpg"),
ARIAL: require("../../assets/fonts/arial.ttf"),
HELVETICA: require("../../assets/fonts/helvetica.ttf"),
TIMES_NEW_ROMAN: require("../../assets/fonts/times-new-roman.ttf"),
FUTURA: require("../../assets/fonts/futura.ttf"),
MONTSERRAT: require("../../assets/fonts/montserrat.ttf"),
ROCKWELL: require("../../assets/fonts/rockwell.ttf"),
VERDANA: require("../../assets/fonts/verdana.ttf"),
CALIBRI: require("../../assets/fonts/calibri.ttf"),
BODONI: require("../../assets/fonts/bodoni.ttf"),
COMIC_SANS_MS: require("../../assets/fonts/comic-sans-ms.ttf"),
};
+6
View File
@@ -0,0 +1,6 @@
export const Colors = {
PRIMARY : "#334257",
SECONDARY : "#476072",
ACCENT : "#548CA8",
BACKGROUND : "#EEEEEE",
}
+14
View File
@@ -0,0 +1,14 @@
export const Dialogs = {
BUTTON: {
OK: "OK",
CANCEL: "Cancel",
SUBMIT: "Submit",
},
ERROR: {
INVALID_CODE: "Invalid Code",
INVALID_CODE_DESC: "The code you entered is invalid. Please try again.",
},
PROMPT: {
ENTER_CODE: "Enter your company code here:",
},
};
+14
View File
@@ -0,0 +1,14 @@
export const Fonts = {
DEFAULT_FONT: 'Arial',
ARIAL : 'Arial',
COMIC_SANS_MS : 'Comic Sans MS',
HELVETICA : 'Helvetica',
TIMES_NEW_ROMAN : 'Times New Roman',
FUTURA : 'Futura',
MONTSERRAT : 'Montserrat',
ROCKWELL : 'Rockwell',
VERDANA : 'Verdana',
CALIBRI : 'Calibri',
BODONI: 'Bodoni',
}
+9
View File
@@ -0,0 +1,9 @@
export const Screens = {
WELCOME : "WelcomeScreen",
CODE_ENTER : "CodeEnterScreen",
BRANCH_PICK : "BranchPickScreen",
ASSIGNED_NUMBER:"AssignedNumberAlert",
TICKET_INFO:"TicketInfoScreen",
TICKET_LIST:"TicketListScreen",
BOTTOM_NAV:"BottomNavigator"
}
+67
View File
@@ -0,0 +1,67 @@
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import CodeEnterScreen from "../screens/CodeEnterScreen";
import WelcomeScreen from "../screens/WelcomeScreen";
import BranchPickScreen from "../screens/BranchPickScreen"
import TicketListScreen from '../screens/TicketListScreen';
import TicketInfoScreen from '../screens/TicketInfoScreen'
import { Screens } from "../constants/screens";
import AssignedNumberAlert from "../components/AssignedNumberAlert";
import { Ionicons } from '@expo/vector-icons';
const Stack = createStackNavigator();
const Tab = createBottomTabNavigator();
const BottomNavigator = ({ route }) => {
const { details, services, branchID } = route.params;
return (
<Tab.Navigator screenOptions={{headerShown: false }}>
<Tab.Screen
name="Home"
component={WelcomeScreen}
initialParams={{ details, services , branchID }}
options={{
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Moji tiketi"
component={TicketListScreen}
options={{
tabBarIcon: ({ color, size }) => (
<Ionicons name="list-outline" size={size} color={color} />
),
}}
/>
</Tab.Navigator>
);
};
const AppNavigator = () => {
return (
<NavigationContainer>
<Stack.Navigator
initialRouteName={Screens.CODE_ENTER}
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen
name={Screens.CODE_ENTER}
component={CodeEnterScreen}
/>
<Stack.Screen name={Screens.BRANCH_PICK} component={BranchPickScreen} />
<Stack.Screen name={Screens.WELCOME} component={WelcomeScreen} />
<Stack.Screen name={Screens.ASSIGNED_NUMBER} component={AssignedNumberAlert} />
<Stack.Screen name={Screens.TICKET_INFO} component={TicketInfoScreen} />
</Stack.Navigator>
</NavigationContainer>
);
};
export default AppNavigator;
+137
View File
@@ -0,0 +1,137 @@
import React, { useState } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Dimensions,
} from "react-native";
import { Screens } from "../constants/screens";
import { getBranchServices } from "../services/fetchData";
import { Colors } from "../constants/colors";
import { Fonts } from "../constants/fonts";
import { Ionicons } from "@expo/vector-icons";
import { useFonts } from "expo-font";
import { Assets } from "../constants/assets";
const windowWidth = Dimensions.get("window").width;
const windowHeight = Dimensions.get("window").height;
export default function BranchPickScreen({
route,
navigation,
}: {
route: any;
navigation: any;
}) {
const { branches } = route.params;
let [fontsLoaded] = useFonts({
[Fonts.ARIAL]: Assets.ARIAL,
[Fonts.TIMES_NEW_ROMAN]: Assets.TIMES_NEW_ROMAN,
[Fonts.VERDANA]: Assets.VERDANA,
[Fonts.HELVETICA]: Assets.HELVETICA,
[Fonts.MONTSERRAT]: Assets.MONTSERRAT,
[Fonts.CALIBRI]: Assets.CALIBRI,
[Fonts.FUTURA]: Assets.FUTURA,
[Fonts.BODONI]: Assets.BODONI,
[Fonts.ROCKWELL]: Assets.ROCKWELL,
[Fonts.COMIC_SANS_MS]: Assets.COMIC_SANS_MS,
});
const renderItem = ({ item }: { item: any }) => (
<TouchableOpacity
style={styles.branchItem}
onPress={() => handlePress(item.id)}
>
<Text style={styles.branchName}>{item.name}</Text>
<View style={styles.iconContainer}>
<Ionicons
name="chevron-forward-circle-outline"
size={windowWidth * 0.1}
color={Colors.ACCENT}
/>
</View>
</TouchableOpacity>
);
async function handlePress(id: any) {
getBranchServices(route.params.code, id)
.then(function (services) {
navigation.navigate(Screens.WELCOME, {
details: route.params.details,
services: services,
branchID: id,
});
})
.catch((error) => {
console.error("Error:", error);
});
}
if (!fontsLoaded) return <Text>Loading...</Text>;
return (
<View style={styles.container}>
<Text style={[styles.title]}>Poslovnice</Text>
<FlatList
data={branches}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.flatListContainer}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.BACKGROUND,
justifyContent: "center",
paddingTop: windowHeight * 0.1,
},
title: {
fontSize: windowWidth * 0.1,
fontWeight: "bold",
marginBottom: windowHeight * 0.03,
marginLeft: windowWidth * 0.05,
fontFamily: Fonts.ARIAL,
},
flatListContainer: {
flexGrow: 1,
paddingHorizontal: windowWidth * 0.05,
paddingBottom: windowHeight * 0.05,
},
branchItem: {
paddingHorizontal: windowWidth * 0.05,
paddingVertical: windowHeight * 0.06, // Adjust the padding to make the items taller
marginBottom: windowHeight * 0.02,
borderRadius: windowWidth * 0.05,
backgroundColor: "#fff",
shadowColor: Colors.ACCENT,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
branchContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
branchName: {
flex: 1,
fontSize: windowWidth * 0.08,
fontFamily: Fonts.ARIAL,
textAlign: "center",
},
icon: {
marginLeft: windowWidth * 0.02,
},
iconContainer: {
alignItems: "center",
marginTop: windowHeight * 0.02, // Adjust the margin to position the icon below the branch name
},
});
+127
View File
@@ -0,0 +1,127 @@
import React, { useState } from "react";
import {
SafeAreaView,
StyleSheet,
TextInput,
Pressable,
Text,
Alert,
ActivityIndicator,
View,
} from "react-native";
import { registerRootComponent } from "expo";
import { getCompanyBranches, getCompanyDetails } from "../services/fetchData";
import { Dialogs } from "../constants/dialogs";
import { Colors } from "../constants/colors";
import { Screens } from "../constants/screens";
import { Dimensions } from 'react-native';
const screenWidth = Dimensions.get('window').width;
const buttonWidth = screenWidth * 0.5;
export default function CodeEnterScreen({ navigation }: { navigation: any }) {
const [text, onChangeText] = React.useState("");
const [isLoading, setIsLoading] = useState(false);
function displayInvalidCodeAlert() {
Alert.alert(
Dialogs.ERROR.INVALID_CODE,
Dialogs.ERROR.INVALID_CODE_DESC,
[{ text: Dialogs.BUTTON.OK }],
{ cancelable: false }
);
}
async function handlePress() {
setIsLoading(true);
// await new Promise((resolve) => setTimeout(resolve, 2000));
const code = text;
getCompanyDetails(code)
.then(function (details) {
setIsLoading(false);
getCompanyBranches(code)
.then(function (branches){
navigation.navigate(Screens.BRANCH_PICK, {
details: details, branches: branches, code
});})
})
.catch((error) => {
console.error("Error:", error);
setIsLoading(false);
displayInvalidCodeAlert();
});
}
return (
<SafeAreaView style={styles.area}>
{isLoading && <ActivityIndicator style={styles.loader} size={75} />}
{isLoading && <View style={styles.loadingArea}></View>}
<TextInput
style={styles.input}
onChangeText={(text) => onChangeText(text)}
value={text}
placeholder={Dialogs.PROMPT.ENTER_CODE}
/>
<Pressable
style={({ pressed }) => [
styles.button,
pressed ? styles.buttonPressed : null,
]}
onPress={handlePress}
>
<Text style={styles.text}>{Dialogs.BUTTON.SUBMIT}</Text>
</Pressable>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
loadingArea: {
position: "absolute",
zIndex: 2,
height: "100%",
width: "100%",
backgroundColor: "rgba(255, 255, 255, 0.75)",
},
loader: {
position: "absolute",
zIndex: 100,
},
input: {
height: 40,
margin: 12,
borderWidth: 1,
padding: 10,
textAlign: "center",
width: 220,
},
area: {
justifyContent: "center",
alignItems: "center",
flex: 1,
},
button: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 12,
paddingHorizontal: 32,
borderRadius: 4,
elevation: 3,
width: buttonWidth, // Adjusted dynamically based on screen width
backgroundColor: Colors.ACCENT,
},
buttonPressed: {
backgroundColor: Colors.PRIMARY,
},
text: {
fontSize: 16,
lineHeight: 21,
fontWeight: "bold",
letterSpacing: 0.25,
color: "white",
},
});
registerRootComponent(({ navigation }) => <CodeEnterScreen navigation={navigation} />);
@@ -0,0 +1,64 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import { Fonts } from '../constants/fonts';
import { Colors } from '../constants/colors';
const windowWidth = Dimensions.get('window').width;
const windowHeight = Dimensions.get('window').height;
export default function TicketInfoScreen({ route }: {route: any}) {
const ticket = route.params.ticket
console.log(ticket)
useEffect(() => {
}, []);
return (
<View style={styles.container}>
<Text style={styles.title}>Ticket Details</Text>
{ticket ? (
<View style={styles.detailsContainer}>
<Text style={styles.detailText}>Naziv: {ticket.name}</Text>
<Text style={styles.detailText}>Generisano: {new Date(ticket.date).toLocaleString()}</Text>
<Text style={styles.detailText}>Podići na šalterima:</Text>
{ticket.stations.map((station: string, index: number) => (
<Text key={index} style={styles.stationText}>{"- "+ station}</Text>
))}
</View>
) : (
<Text>Loading...</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.BACKGROUND,
justifyContent: 'flex-start',
paddingHorizontal: windowWidth * 0.05,
paddingTop: windowHeight * 0.2,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: windowHeight * 0.03,
fontFamily: Fonts.ARIAL,
alignSelf: 'center',
},
detailsContainer: {
marginTop: windowHeight * 0.03,
},
detailText: {
fontSize: 20,
marginBottom: 10,
fontFamily: Fonts.ARIAL,
},
stationText: {
fontSize: 20,
marginBottom: 5,
fontFamily: Fonts.ARIAL,
marginLeft: 10,
}
});
+154
View File
@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
import { Screens } from '../constants/screens';
import { getTickets } from '../services/fetchData';
import { Colors } from '../constants/colors';
import { Fonts } from '../constants/fonts';
import { Ionicons } from '@expo/vector-icons';
import { getExpoToken } from '../utils/tokenUtils';
const windowWidth = Dimensions.get('window').width;
const windowHeight = Dimensions.get('window').height;
export default function TicketListScreen({ navigation }: { navigation: any }){
const [tickets, setTickets] = useState<any[]>([]);
useEffect(() => {
const fetchTickets = async () => {
try {
const fetchedTickets = await getTickets(getExpoToken());
setTickets(fetchedTickets);
} catch (error) {
console.error('Error fetching tickets:', error);
}
};
fetchTickets();
const intervalId = setInterval(fetchTickets, 10000);
return () => clearInterval(intervalId);
}, []);
const renderItem = ({ item }: { item: any }) => {
return (
<TouchableOpacity
style={styles.ticketItem}
onPress={() => handlePress(item.id)}
>
<Text style={styles.ticketName}>{item.name}</Text>
<View style={styles.stationContainer}>
<Text style={styles.stationTitle}>Šalteri</Text>
{item.stations.map((station: string, index: number) => (
<Text key={index} style={styles.stationList}>{station}</Text>
))}
</View>
<View style={styles.iconContainer}>
<Ionicons name="information-circle-outline" size={windowWidth * 0.1} color={Colors.ACCENT} />
</View>
</TouchableOpacity>
);
};
async function handlePress(id: any) {
let ticket = tickets.find(item => item.id == id)
navigation.navigate(Screens.TICKET_INFO, {
ticket:ticket
})
}
if (tickets.length === 0) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.PRIMARY} />
</View>
);
} else {
return (
<View style={styles.container}>
<Text style={styles.title}>Tiketi</Text>
<FlatList
data={tickets}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.flatListContainer}
/>
</View>
);
}
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.BACKGROUND,
justifyContent: 'center',
paddingTop: windowHeight * 0.1,
},
title: {
fontSize: windowWidth * 0.1,
fontWeight: 'bold',
marginBottom: windowHeight * 0.03,
marginLeft: windowWidth * 0.05,
fontFamily: Fonts.ARIAL,
},
flatListContainer: {
flexGrow: 1,
paddingHorizontal: windowWidth * 0.05,
paddingBottom: windowHeight * 0.05,
},
ticketItem: {
paddingHorizontal: windowWidth * 0.05,
paddingVertical: windowHeight * 0.06,
marginBottom: windowHeight * 0.02,
borderRadius: windowWidth * 0.05,
backgroundColor: '#fff',
shadowColor: Colors.ACCENT,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
ticketContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
ticketName: {
flex: 1,
fontSize: windowWidth * 0.08,
fontFamily: Fonts.ARIAL,
textAlign: 'center',
},
icon: {
marginLeft: windowWidth * 0.02,
},
iconContainer: {
alignItems: 'center',
marginTop: windowHeight * 0.02,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
stationList: {
fontSize: windowWidth * 0.05,
color: 'gray',
marginTop: 5,
},
stationTitle: {
fontSize: windowWidth * 0.06,
fontWeight: 'bold',
marginBottom: 5,
},
stationContainer: {
marginTop: 30,
},
});
+256
View File
@@ -0,0 +1,256 @@
import React, { useEffect, useRef, useState } from "react";
import {
View,
StyleSheet,
Animated,
Text,
FlatList,
TouchableOpacity,
Platform,
Alert,
} from "react-native";
import WelcomeMessage from "../components/WelcomeMessage";
import { useFonts } from "expo-font";
const { Fonts } = require("../constants/fonts");
const { Assets } = require("../constants/assets");
const { Colors } = require("../constants/colors");
import { Ionicons } from "@expo/vector-icons";
import AssignedNumberAlert from "../components/AssignedNumberAlert";
//import * as Notifications from "expo-notifications";
//import * as Device from "expo-device";
import { getExpoToken, setExpoToken } from "../utils/tokenUtils";
import { generateTicket } from "../services/fetchData";
export default function WelcomeScreen({ route }: { route: any }) {
const { details } = route.params;
let { services } = route.params;
let { branchID } = route.params;
let { name, welcomeMessage, font, logoUrl } = details;
/*
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
*/
let [fontsLoaded] = useFonts({
[Fonts.ARIAL]: Assets.ARIAL,
[Fonts.TIMES_NEW_ROMAN]: Assets.TIMES_NEW_ROMAN,
[Fonts.VERDANA]: Assets.VERDANA,
[Fonts.HELVETICA]: Assets.HELVETICA,
[Fonts.MONTSERRAT]: Assets.MONTSERRAT,
[Fonts.CALIBRI]: Assets.CALIBRI,
[Fonts.FUTURA]: Assets.FUTURA,
[Fonts.BODONI]: Assets.BODONI,
[Fonts.ROCKWELL]: Assets.ROCKWELL,
[Fonts.COMIC_SANS_MS]: Assets.COMIC_SANS_MS,
});
const fadeAnim = React.useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 2500,
useNativeDriver: true,
}).start();
}, [fadeAnim]);
/*
useEffect(() => {
getToken();
}, []);
async function getToken() {
try {
await registerForPushNotificationsAsync().then((token) => {
if (token != undefined) setExpoToken(token);
});
} catch (error) {
console.error("Failed to fetch data in TasksScreen:", error);
}
}
*/
const [modalVisible, setModalVisible] = useState(false);
const [ticket, setTicket] = useState(0);
const openModal = () => {
setModalVisible(true);
};
const closeModal = () => {
setModalVisible(false);
};
const renderItem = ({ item }: { item: any }) => (
<TouchableOpacity
style={styles.branchItem}
onPress={() => handlePress(item.id)}
>
<View style={styles.branchInfoContainer}>
<Ionicons
name="information-circle"
size={24}
color={Colors.ACCENT}
style={styles.icon}
/>
<Text style={styles.branchName}>{item.name}</Text>
<Ionicons
name="chevron-forward"
size={24}
color={Colors.ACCENT}
style={styles.icon}
/>
</View>
</TouchableOpacity>
);
async function handlePress(serviceID: any) {
const assignTicket = async () => {
try {
const fetchedTicket = await generateTicket(
getExpoToken(),
branchID,
serviceID
);
setTicket(fetchedTicket.number);
} catch (error) {
console.error("Error fetching tickets:", error);
}
};
assignTicket();
openModal();
}
if (!fontsLoaded) return <Text>Loading...</Text>;
return (
<View style={styles.container}>
<View style={styles.logoContainer}>
<Animated.Image
style={[styles.logo, { opacity: fadeAnim }]}
source={{ uri: logoUrl }}
/>
</View>
<WelcomeMessage name={name} font={font} welcome={welcomeMessage} />
<FlatList
data={services}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.flatListContainer}
/>
<AssignedNumberAlert
visible={modalVisible}
number={ticket}
onClose={closeModal}
/>
</View>
);
}
/*
async function registerForPushNotificationsAsync() {
let token;
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}
if (Device.isDevice) {
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
alert("Failed to get push token for push notification!");
return;
}
token = (await Notifications.getExpoPushTokenAsync()).data;
} else {
alert("Must use physical device for Push Notifications");
}
return token;
}
*/
export const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.BACKGROUND,
alignItems: "center",
justifyContent: "center",
},
titleContainer: {
alignSelf: "flex-start",
width: "100%",
},
logoContainer: {
marginBottom: 20, // Add margin to separate logo from title
fontFamily: Fonts.ARIAL,
color: Colors.TEXT_PRIMARY,
},
logo: {
marginTop: 80, // Add margin to separate logo from title
width: "40%", // Adjust the logo size to fit better on all screens
height: undefined, // Allow height to adjust automatically based on width
aspectRatio: 1, // Maintain aspect ratio
resizeMode: "contain",
},
title: {
fontSize: 42,
textAlign: "center",
marginTop: 20,
fontFamily: Fonts.ARIAL,
color: Colors.TEXT_PRIMARY,
},
flatListContainer: {
flexGrow: 1,
paddingHorizontal: 20,
paddingTop: 10, // Add padding to the top of the list to separate from title
},
branchItem: {
marginTop: 10, // Add margin between items
paddingVertical: 15,
paddingHorizontal: 20,
borderRadius: 10,
borderColor: Colors.ACCENT,
borderWidth: 1,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
width: "100%", // Make items take full width of the screen
},
branchInfoContainer: {
flexDirection: "row", // Arrange items horizontally
alignItems: "center", // Center items vertically
},
branchName: {
fontSize: 30,
fontFamily: Fonts.ARIAL,
color: Colors.TEXT_PRIMARY,
marginLeft: 10, // Add some space between text and icon
},
icon: {
marginRight: 10, // Add some space between icon and text
},
});
+323
View File
@@ -0,0 +1,323 @@
import * as FileSystem from "expo-file-system";
import { Buffer } from "buffer";
import * as Sharing from "expo-sharing";
import { Platform, PermissionsAndroid } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
const { SERVER_URL } = require("../constants/api");
const { Dialogs } = require("../constants/dialogs");
const { Fonts } = require("../constants/fonts");
const { Assets } = require("../constants/assets");
const { validateCodeFormat } = require("../utils/validation");
interface CompanyData {
name: string;
welcomeMessage: string;
font: string;
logoUrl: string;
}
interface BranchData {
id: string;
name: string;
tellerStations: Array<Int32Array>;
}
interface ServiceData {
id: string;
name: string;
}
interface TicketData {
id: string;
name: string;
date: Date;
number: number;
stations: Array<String>;
}
async function getCompanyDetails(code: string) {
if (!validateCodeFormat(code)) throw new Error(Dialogs.ERROR.INVALID_CODE);
let details: CompanyData = await fetch(
`${SERVER_URL}/api/v1/tenants/${code}`,
{ method: "GET" }
)
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then((data) => {
const companyData: CompanyData = {
name: data.name,
welcomeMessage: data.welcomeMessage,
font: data.font,
logoUrl: data.logo.base64Logo,
};
if (!companyData.font) companyData.font = Fonts.DEFAULT_FONT;
if (!companyData.logoUrl) companyData.logoUrl = Assets.DEFAULT_LOGO;
return companyData;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return details;
}
async function getCompanyBranches(code: any) {
const branchList: BranchData[] = [];
let branches: BranchData[] = await fetch(
`${SERVER_URL}/api/v1/branches/${code}`,
{ method: "GET" }
)
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then((data) => {
data.forEach((branch: any) => {
const tellerList: Int32Array[] = [];
branch.tellerStations.forEach((teller: any) => {
tellerList.push(teller);
});
const branchData: BranchData = {
id: branch.id,
name: branch.name,
tellerStations: tellerList,
};
branchList.push(branchData);
});
return branchList;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return branches;
}
async function getBranchServices(code: string, id: string) {
const serviceList: ServiceData[] = [];
let services: ServiceData[] = await fetch(
`${SERVER_URL}/api/v1/branches/${code}/${id}/services`,
{ method: "GET" }
)
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then((data) => {
data.forEach((service: any) => {
const serviceData: ServiceData = {
id: service.id,
name: service.name,
};
serviceList.push(serviceData);
});
return serviceList;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return services;
}
/*
async function getTickets(token: string) {
const ticketList: TicketData[] = [];
let tickets: TicketData[] = await fetch(
`${SERVER_URL}/tickets/devices/${token}`,
{ method: "GET" }
)
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then((data) => {
let ticket = data;
const stationList: String[] = [];
ticket.stations.forEach((station: any) => {
stationList.push(station.name);
});
const ticketData: TicketData = {
id: ticket.ticket.id,
name: ticket.ticket.branch.name,
date: new Date(ticket.ticket.createdAt),
stations: stationList,
number: ticket.ticket.number,
};
ticketList.push(ticketData);
return ticketList;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return tickets;
}
*/
async function generateTicket(
token: string,
branchId: number,
serviceId: number
) {
let tickets: TicketData = await fetch(`${SERVER_URL}/api/v1/tickets`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
branchId: `${branchId}`,
serviceId: `${serviceId}`,
deviceToken: token,
}),
})
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then(async (data) => {
await printTicket(data.ticket.id);
console.log(data.ticket.id);
return data.ticket;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return tickets;
}
function printTicket(ticketId: number) {
console.log("Printing ticket...");
fetch(`${SERVER_URL}/api/v1/tickets/${ticketId}/print`, {
method: "GET",
})
.then((response) => {
if (!response.ok) {
throw new Error(
`Failed to fetch PDF: ${response.status} - ${response.statusText}`
);
}
return response.arrayBuffer();
})
.then((pdfByteArray) => {
const filename = `ticket_${ticketId}`;
return savePdfToFile(pdfByteArray, filename);
})
.then((filePath) => {
if (filePath) {
console.log("PDF saved successfully at: ", filePath);
} else {
console.log("Failed to save PDF.");
}
})
.catch((error) => {
console.error("Error printing ticket: ", error);
});
}
async function savePdfToFile(pdfByteArray: ArrayBuffer, filename: string) {
try {
const folderPath = await FileSystem.documentDirectory + "pdfs/";
await FileSystem.makeDirectoryAsync(folderPath, { intermediates: true });
const filePath = folderPath + filename + ".pdf";
const buffer = Buffer.from(pdfByteArray);
const base64String = buffer.toString("base64");
await FileSystem.writeAsStringAsync(filePath, base64String, {
encoding: FileSystem.EncodingType.Base64,
});
saveFile(filePath, filename, "application/pdf");
return filePath;
} catch (error) {
console.error("Error saving PDF: ", error);
return null;
}
}
async function saveFile(uri: string, filename: string, mimetype: string) {
// await AsyncStorage.removeItem("permission");
if (Platform.OS === "android") {
const permission = await AsyncStorage.getItem("permission");
console.log("PERMISSION : " + permission);
if (!permission) {
// Check if permission is already granted
//let x = await Permissions.askAsync(Permissions.MANAGE_EXTERNAL_STORAGE);
const permissions =
await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
console.log(permissions);
if (permissions.granted)
await AsyncStorage.setItem("permission", permissions.directoryUri);
if (permissions.granted) {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
filename,
mimetype
)
.then(async (uri) => {
await FileSystem.writeAsStringAsync(uri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
})
.catch((e) => console.log(e));
} else {
Sharing.shareAsync(uri);
}
} else {
// Permission already granted
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
if (permission !== null)
await FileSystem.StorageAccessFramework.createFileAsync(
permission,
filename,
mimetype
)
.then(async (uri) => {
await FileSystem.writeAsStringAsync(uri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
})
.catch((e) => console.log(e));
}
} else {
Sharing.shareAsync(uri);
}
}
export {
getCompanyDetails,
getBranchServices,
getCompanyBranches,
generateTicket,
};
+16
View File
@@ -0,0 +1,16 @@
const { validateCodeFormat } = require("../utils/validation");
const { Dialogs } = require("../constants/dialogs");
describe('Code validation tests', () => {
test('Valid and invalid code formats', () => {
// Valid code formats
expect(validateCodeFormat('ABCD')).toBe(true);
expect(validateCodeFormat('WXYZ')).toBe(true);
expect(validateCodeFormat('1234')).toBe(true);
// Invalid code formats
expect(validateCodeFormat('ABCD1')).toBe(false);
expect(validateCodeFormat('ABCD!')).toBe(false);
expect(validateCodeFormat('ABC')).toBe(false);
});
});
+9
View File
@@ -0,0 +1,9 @@
let apiToken = '';
export function setExpoToken(token: string) {
apiToken = token;
}
export function getExpoToken() {
return apiToken;
}
+5
View File
@@ -0,0 +1,5 @@
function validateCodeFormat(code: string): boolean {
return /^[A-Z0-9]{4}$/.test(code);
}
export { validateCodeFormat };
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}