Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45e57181a9 | |||
| ce01ab763c | |||
| dcf65840c0 | |||
| 194d598d6d | |||
| d76434014f | |||
| 266802088a | |||
| 6c98e4a469 | |||
| 5d2898dd13 | |||
| 694b68cbc5 | |||
| 0aa633a483 | |||
| 8404bcafd0 |
@@ -82,3 +82,117 @@ jobs:
|
||||
ssh -i ~/.ssh/key \
|
||||
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
|
||||
|
||||
mobile-release:
|
||||
name: Mobile Release
|
||||
needs: backend-unit-tests
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Accept Android SDK licenses
|
||||
run: yes | sdkmanager --licenses > /dev/null || true
|
||||
|
||||
- name: Determine next mobile version
|
||||
id: version
|
||||
run: |
|
||||
git fetch --tags
|
||||
LAST=$(git tag -l 'mobile-v*' | sed 's/mobile-v//' | sort -n | tail -1)
|
||||
NEXT=$(( ${LAST:-0} + 1 ))
|
||||
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=mobile-v$NEXT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stamp app version
|
||||
run: node scripts/set-mobile-version.js ${{ steps.version.outputs.next }}
|
||||
|
||||
- name: Restore Firebase config
|
||||
run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > google-services.json
|
||||
|
||||
- name: Restore env
|
||||
run: echo "EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }}" > .env
|
||||
|
||||
- name: Restore signing keystore
|
||||
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ksan.dev.keystore
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Prebuild Android project
|
||||
run: npx expo prebuild -p android --no-install
|
||||
|
||||
- name: Increase Gradle memory
|
||||
run: |
|
||||
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> android/gradle.properties
|
||||
|
||||
|
||||
- name: Build signed release APK
|
||||
working-directory: frontend/android
|
||||
run: |
|
||||
./gradlew assembleRelease --no-daemon \
|
||||
-Pandroid.injected.signing.store.file="$PWD/../ksan.dev.keystore" \
|
||||
-Pandroid.injected.signing.store.password="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
|
||||
-Pandroid.injected.signing.key.alias="${{ secrets.ANDROID_KEY_ALIAS }}" \
|
||||
-Pandroid.injected.signing.key.password="${{ secrets.ANDROID_KEY_PASSWORD }}"
|
||||
|
||||
- name: Locate APK
|
||||
id: apk
|
||||
run: |
|
||||
APK=$(find android/app/build/outputs/apk/release -name "*.apk" | head -n1)
|
||||
OUT="etfoglasi-${{ steps.version.outputs.tag }}.apk"
|
||||
cp "$APK" "$OUT"
|
||||
echo "path=$OUT" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$OUT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Gitea release
|
||||
run: |
|
||||
API="https://git.${{ secrets.DOMAIN }}/api/v1/repos/${{ secrets.REGISTRY_USER }}/etf-oglasi"
|
||||
AUTH="Authorization: token ${{ secrets.GITEARELEASES_TOKEN }}"
|
||||
|
||||
HTTP_CODE=$(curl -s -o release.json -w "%{http_code}" -X POST \
|
||||
-H "$AUTH" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${{ steps.version.outputs.tag }}\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"${{ steps.version.outputs.tag }}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"$API/releases")
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
cat release.json
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ]; then
|
||||
echo "Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_ID=$(node -pe "JSON.parse(require('fs').readFileSync('release.json','utf8')).id")
|
||||
|
||||
HTTP_CODE=$(curl -s -o asset.json -w "%{http_code}" -X POST \
|
||||
-H "$AUTH" \
|
||||
-F "attachment=@${{ steps.apk.outputs.path }}" \
|
||||
"$API/releases/$RELEASE_ID/assets?name=${{ steps.apk.outputs.name }}")
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
cat asset.json
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ]; then
|
||||
echo "Failed to upload asset"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,91 +1,47 @@
|
||||
# Full-Stack Learning Project
|
||||
# ETF Oglasi
|
||||
|
||||
This repository is a full-stack learning project focused on building and understanding modern application development practices end-to-end.
|
||||
<img src="logo.png" alt="ETF Oglasi logo" width="150" />
|
||||
|
||||
It demonstrates how to design, build, and deploy a real-world application using a backend API, mobile frontend, database integration, and CI/CD workflows.
|
||||
A hobby full-stack project I built to learn modern app development end to end.
|
||||
|
||||
---
|
||||
It scrapes the announcement boards of the Faculty of Electrical Engineering,
|
||||
University of Banja Luka, stores the announcements in PostgreSQL, and lets
|
||||
students browse and subscribe to subjects from a mobile app. Subscribers get
|
||||
push notifications when new announcements are posted.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Spring Boot
|
||||
- RESTful API architecture
|
||||
- PostgreSQL database
|
||||
- **Backend:** Spring Boot 3.5 (Java 21), REST API, PostgreSQL
|
||||
- **Frontend:** React Native / Expo (Expo Router, NativeWind)
|
||||
- **Notifications:** Firebase Cloud Messaging
|
||||
- **Tooling:** Docker, Gitea Actions for CI/CD, deployed to a self-hosted home server
|
||||
|
||||
### Frontend
|
||||
- React Native (Expo)
|
||||
## Project Structure
|
||||
|
||||
### DevOps / Tooling
|
||||
- Docker
|
||||
- CI/CD pipelines (Gitea Actions or similar)
|
||||
- Git version control
|
||||
- `backend/` — Spring Boot REST API and the scraper
|
||||
- `frontend/` — Expo mobile app
|
||||
|
||||
---
|
||||
## Backend
|
||||
|
||||
## Purpose
|
||||
```bash
|
||||
cd backend
|
||||
cp src/main/resources/application.properties.example src/main/resources/application.properties # fill in datasource + jwt.secret
|
||||
docker compose up -d # local Postgres
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
The goal of this project is to gain hands-on experience with:
|
||||
Run tests: `./gradlew test`
|
||||
|
||||
- Full-stack application architecture
|
||||
- Backend API development with Spring Boot
|
||||
- Mobile development with React Native
|
||||
- Database design and integration
|
||||
- Authentication and security concepts
|
||||
- Containerization with Docker
|
||||
- Continuous Integration / Continuous Deployment (CI/CD)
|
||||
## Frontend
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
The application is deployed on a self-hosted home server for development and testing purposes.
|
||||
|
||||
---
|
||||
|
||||
## How to Run (Backend)
|
||||
|
||||
To be added.
|
||||
|
||||
Planned steps:
|
||||
- Install Java and Gradle
|
||||
- Configure PostgreSQL database
|
||||
- Set environment variables
|
||||
- Run Spring Boot application
|
||||
- Expose API on localhost or network
|
||||
- I usually run eveything with traefik but i dont know should i include that here???
|
||||
|
||||
---
|
||||
|
||||
## How to Run (Frontend)
|
||||
|
||||
To be added...
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
## CI/CD
|
||||
|
||||
To be added.
|
||||
|
||||
Planned steps:
|
||||
- Configure Gitea Actions / CI pipeline
|
||||
- Build backend and frontend automatically
|
||||
- Deploy to home server
|
||||
- Automate testing and builds
|
||||
|
||||
---
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
# set EXPO_PUBLIC_API_URL in .env to the backend base URL
|
||||
npm start
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
This project is currently under semi-active development as a learning and experimentation environment.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
|
||||
|
||||
|
||||
need to made .creds .env and init directory with subjects.txt
|
||||
Semi-actively developed as a learning project. It is an app I actually use, and
|
||||
maybe others will find it useful too.
|
||||
|
||||
@@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS public.users
|
||||
email character varying(255) COLLATE pg_catalog."default" NOT NULL,
|
||||
password character varying(255) COLLATE pg_catalog."default" NOT NULL,
|
||||
reg_time timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'NO_NOTIFICATION'::character varying,
|
||||
notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'PUSH_NOTIFICATION'::character varying,
|
||||
CONSTRAINT users_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT unique_email UNIQUE (email)
|
||||
);
|
||||
|
||||
@@ -24,8 +24,20 @@ public class NotificationService {
|
||||
|
||||
for (DeviceToken token : tokens) {
|
||||
|
||||
// Include a notification block so Android's system tray displays it
|
||||
// even when the app is killed/dozing (a data-only message is only
|
||||
// ever shown by the app's own JS handler, which won't run when the
|
||||
// OS has force-stopped the app). HIGH priority tells FCM to deliver
|
||||
// promptly and wake the device out of Doze.
|
||||
Message message = Message.builder()
|
||||
.setToken(token.getFcmToken())
|
||||
.setNotification(Notification.builder()
|
||||
.setTitle(title)
|
||||
.setBody(body)
|
||||
.build())
|
||||
.setAndroidConfig(AndroidConfig.builder()
|
||||
.setPriority(AndroidConfig.Priority.HIGH)
|
||||
.build())
|
||||
.putData("title", title)
|
||||
.putData("body", body)
|
||||
.build();
|
||||
|
||||
@@ -74,6 +74,11 @@ public class SubjectService {
|
||||
@Async
|
||||
public void notifyAsync(Entry entry) {
|
||||
|
||||
// The scraper couldn't always match an announcement to a known subject, in
|
||||
// which case there's nobody to notify. Guard against the NPE that would
|
||||
// otherwise be thrown (and silently swallowed) on this async thread.
|
||||
if (entry.getSubject() == null) return;
|
||||
|
||||
List<User> users =
|
||||
userService.findUsersBySubjectId(entry.getSubject().getId());
|
||||
|
||||
|
||||
@@ -139,14 +139,15 @@ class EntryControllerTest {
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteEntry_authenticated_returns200() throws Exception {
|
||||
doNothing().when(entryService).deleteEntry("entry-1");
|
||||
|
||||
mvc.perform(delete("/api/entries/entry-1"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
//we removed this from controller so test is pointless
|
||||
// @Test
|
||||
// @WithMockUser
|
||||
// void deleteEntry_authenticated_returns200() throws Exception {
|
||||
// doNothing().when(entryService).deleteEntry("entry-1");
|
||||
//
|
||||
// mvc.perform(delete("/api/entries/entry-1"))
|
||||
// .andExpect(status().isOk());
|
||||
// }
|
||||
|
||||
@Test
|
||||
void deleteEntry_unauthenticated_returns401or403() throws Exception {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"android": {
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#1C1918",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "../globals.css";
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import {
|
||||
@@ -7,19 +7,15 @@ import {
|
||||
onMessage,
|
||||
onNotificationOpenedApp,
|
||||
getInitialNotification,
|
||||
setBackgroundMessageHandler,
|
||||
} from '@react-native-firebase/messaging';
|
||||
import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } from '@/services/notifications';
|
||||
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
|
||||
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||
import { useUpdatecheck } from '@/hooks/useUpdatecheck';
|
||||
import { UpdatePrompt } from '@/components/UpdatePrompt';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
// Registered at module scope so it's installed as soon as this entry file
|
||||
// loads, which is required for it to fire while the app is backgrounded/killed.
|
||||
setBackgroundMessageHandler(getMessaging(), async (remoteMessage) => {
|
||||
const title = remoteMessage.data?.title as string | undefined;
|
||||
const body = remoteMessage.data?.body as string | undefined;
|
||||
await displayLocalNotification(title, body);
|
||||
});
|
||||
// The FCM background handler now lives in index.js (registered before this
|
||||
// module loads) so it fires reliably while the app is backgrounded/killed.
|
||||
|
||||
function NotificationSetup() {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -60,12 +56,22 @@ function NotificationSetup() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function UpdateCheck() {
|
||||
const { updateInfo } = useUpdatecheck();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (!updateInfo || dismissed) return null;
|
||||
|
||||
return <UpdatePrompt updateInfo={updateInfo} onDismiss={() => setDismissed(true)} />;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SafeAreaProvider>
|
||||
<NotificationSetup />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
<UpdateCheck />
|
||||
<Toast />
|
||||
</SafeAreaProvider>
|
||||
</AuthProvider>
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 916 KiB |
@@ -1,46 +0,0 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import messaging from "@react-native-firebase/messaging";
|
||||
import notifee, { AndroidImportance, EventType } from "@notifee/react-native";
|
||||
|
||||
|
||||
async function displayLocalNotification(title?: string, body?: string) {
|
||||
if (!title && !body) return;
|
||||
|
||||
const channelId = await notifee.createChannel({
|
||||
id: "default",
|
||||
name: "General",
|
||||
importance: AndroidImportance.HIGH,
|
||||
});
|
||||
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId,
|
||||
pressAction: { id: "default" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function usePushNotifications() {
|
||||
useEffect(() => {
|
||||
const unsubscribeMessage = messaging().onMessage(async remoteMessage => {
|
||||
const title = remoteMessage.data?.title as string | undefined;
|
||||
const body = remoteMessage.data?.body as string | undefined;
|
||||
await displayLocalNotification(title, body);
|
||||
});
|
||||
|
||||
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
|
||||
if (type === EventType.PRESS) {
|
||||
console.log("Notification tapped:", detail.notification);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeMessage();
|
||||
unsubscribeNotifee();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Custom entry point. The FCM background handler must be registered in the very
|
||||
// first module that loads — before the router/app mounts — so it reliably fires
|
||||
// when the app is backgrounded or fully quit. Registering it here (rather than in
|
||||
// app/_layout.tsx) is what react-native-firebase requires for the killed state.
|
||||
import messaging from '@react-native-firebase/messaging';
|
||||
import notifee, { AndroidImportance } from '@notifee/react-native';
|
||||
|
||||
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
|
||||
const title = remoteMessage.data?.title;
|
||||
const body = remoteMessage.data?.body;
|
||||
if (!title && !body) return;
|
||||
|
||||
const channelId = await notifee.createChannel({
|
||||
id: 'default',
|
||||
name: 'General',
|
||||
importance: AndroidImportance.HIGH,
|
||||
});
|
||||
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: { channelId, pressAction: { id: 'default' } },
|
||||
});
|
||||
});
|
||||
|
||||
// Hand off to expo-router, which mounts app/_layout.tsx and the routes.
|
||||
import 'expo-router/entry';
|
||||
@@ -1,166 +0,0 @@
|
||||
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { enableScreens } from "react-native-screens";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import messaging from "@react-native-firebase/messaging";
|
||||
import notifee, { AndroidImportance } from "@notifee/react-native";
|
||||
|
||||
import SubscribedFeed from "@/screens/SubscribedFeed";
|
||||
import AllFeed from "@/screens/AllFeed";
|
||||
import Profile from "@/screens/Profile";
|
||||
import AuthGate from "@/screens/AuthGate";
|
||||
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||
import { useUpdatecheck } from "./hooks/useUpdatecheck";
|
||||
import { UpdatePrompt } from "./components/UpdatePrompt";
|
||||
import { usePushNotifications } from "./hooks/usePushNotifications";
|
||||
|
||||
// Handles data-only FCM messages when the app is in the background or closed.
|
||||
messaging().setBackgroundMessageHandler(async remoteMessage => {
|
||||
const title = remoteMessage.data?.title as string | undefined;
|
||||
const body = remoteMessage.data?.body as string | undefined;
|
||||
|
||||
if (!title && !body) return;
|
||||
|
||||
const channelId = await notifee.createChannel({
|
||||
id: "default",
|
||||
name: "General",
|
||||
importance: AndroidImportance.HIGH,
|
||||
});
|
||||
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId,
|
||||
pressAction: { id: "default" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Must be called before any navigator renders
|
||||
enableScreens();
|
||||
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
const LIGHT_TAB = {
|
||||
bg: "#FFFFFF",
|
||||
border: "#E8E2D5",
|
||||
active: "#C4622D",
|
||||
inactive: "#8A8278",
|
||||
label: "#1A1714",
|
||||
};
|
||||
|
||||
const DARK_TAB = {
|
||||
bg: "#161513",
|
||||
border: "#2C2A27",
|
||||
active: "#E07B45",
|
||||
inactive: "#706D67",
|
||||
label: "#F0EDE8",
|
||||
};
|
||||
|
||||
function ProfileTab() {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return user ? <Profile /> : <AuthGate />;
|
||||
}
|
||||
|
||||
|
||||
export default function App() {
|
||||
const scheme = useColorScheme();
|
||||
const dark = scheme === "dark";
|
||||
const tab = dark ? DARK_TAB : LIGHT_TAB;
|
||||
|
||||
const navTheme = dark
|
||||
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
||||
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
||||
|
||||
const { updateInfo } = useUpdatecheck();
|
||||
const [updateDismissed, setUpdateDismissed] = useState(false);
|
||||
|
||||
usePushNotifications();
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SafeAreaProvider>
|
||||
<NavigationContainer theme={navTheme}>
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
headerShown: false,
|
||||
|
||||
tabBarStyle: {
|
||||
backgroundColor: tab.bg,
|
||||
borderTopColor: tab.border,
|
||||
borderTopWidth: 1,
|
||||
height: 60,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 6,
|
||||
},
|
||||
|
||||
tabBarActiveTintColor: tab.active,
|
||||
tabBarInactiveTintColor: tab.inactive,
|
||||
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
const icons: Record<
|
||||
string,
|
||||
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
|
||||
> = {
|
||||
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
|
||||
"Discover": { active: "compass", inactive: "compass-outline" },
|
||||
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
|
||||
};
|
||||
const set = icons[route.name];
|
||||
|
||||
return (
|
||||
<Ionicons
|
||||
name={focused ? set.active : set.inactive}
|
||||
size={focused ? size + 1 : size}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Subscribed"
|
||||
component={SubscribedFeed}
|
||||
options={{ title: "My Feed" }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Discover"
|
||||
component={AllFeed}
|
||||
options={{ title: "Discover" }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Profile"
|
||||
component={ProfileTab}
|
||||
options={{ title: "Profile" }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
|
||||
{/* Overlays the entire app including the tab bar */}
|
||||
{updateInfo && !updateDismissed && (
|
||||
<UpdatePrompt
|
||||
updateInfo={updateInfo}
|
||||
onDismiss={() => setUpdateDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</SafeAreaProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "etfoglasi",
|
||||
"main": "expo-router/entry",
|
||||
"main": "index.js",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Used by CI to stamp a mobile release build with its version number.
|
||||
// Updates version.json (read by hooks/useUpdatecheck.tsx) and app.json's
|
||||
// android.versionCode/versionName so the build, the in-app update check,
|
||||
// and the Gitea release tag (mobile-vN) all agree on the same number.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const version = parseInt(process.argv[2], 10);
|
||||
if (!Number.isInteger(version) || version <= 0) {
|
||||
console.error('Usage: node set-mobile-version.js <positive integer>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'version.json'),
|
||||
JSON.stringify({ version }, null, 2) + '\n',
|
||||
);
|
||||
|
||||
const appJsonPath = path.join(root, 'app.json');
|
||||
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
|
||||
appJson.expo.version = `1.0.${version}`;
|
||||
appJson.expo.android = appJson.expo.android ?? {};
|
||||
appJson.expo.android.versionCode = version;
|
||||
fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
|
||||
|
||||
console.log(`Stamped mobile version ${version}`);
|
||||