diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java b/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java
index 1cb8aeb..76844fd 100644
--- a/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java
+++ b/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java
@@ -56,7 +56,7 @@ public class SecurityConfig {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
- "http://localhost:3000",
+ "http://localhost:8081",
"https://etf-oglasi.ksan.dev"
));
diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java
index d8809cb..2715cf4 100644
--- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java
+++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java
@@ -31,9 +31,11 @@ public class NotificationService {
.build();
try {
- firebaseMessaging.send(message);
+ String id = firebaseMessaging.send(message);
+ System.out.println("Send:" + id);
} catch (FirebaseMessagingException ex) {
+ ex.printStackTrace();
if (ex.getMessagingErrorCode()
== MessagingErrorCode.UNREGISTERED) {
deviceTokenRepo.delete(token);
diff --git a/frontend/components/UpdatePrompt.tsx b/frontend/components/UpdatePrompt.tsx
new file mode 100644
index 0000000..1305c5c
--- /dev/null
+++ b/frontend/components/UpdatePrompt.tsx
@@ -0,0 +1,138 @@
+// components/UpdatePrompt.tsx
+// ─────────────────────────────────────────────────────────────────────────────
+// Modal that appears when a newer build is available.
+// "Update now" opens the Gitea release page in the browser.
+// "Later" dismisses it for the current session (it reappears next launch).
+// ─────────────────────────────────────────────────────────────────────────────
+
+import React from 'react';
+import {
+ Linking,
+ Modal,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import type { UpdateInfo } from '../hooks/useUpdatecheck';
+import { version as currentVersion } from '../version.json';
+
+interface Props {
+ updateInfo: UpdateInfo;
+ onDismiss: () => void;
+}
+
+export function UpdatePrompt({ updateInfo, onDismiss }: Props) {
+ function openRelease() {
+ Linking.openURL(updateInfo.releaseUrl).catch(() => {
+ // If the device can't open the URL, just dismiss so the app isn't stuck.
+ onDismiss();
+ });
+ }
+
+ return (
+
+
+
+ Update available
+
+
+ A new version of the app is available.{'\n'}
+ You are on build {currentVersion},
+ the latest is build{' '}
+ {updateInfo.latestVersion}.
+
+
+
+
+ Later
+
+
+
+ Update now
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 24,
+ },
+ card: {
+ width: '100%',
+ maxWidth: 380,
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 24,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.15,
+ shadowRadius: 12,
+ elevation: 8,
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#111',
+ marginBottom: 12,
+ },
+ body: {
+ fontSize: 15,
+ color: '#444',
+ lineHeight: 22,
+ marginBottom: 24,
+ },
+ bold: {
+ fontWeight: '700',
+ color: '#111',
+ },
+ actions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: 12,
+ },
+ btn: {
+ paddingVertical: 10,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ },
+ btnPrimary: {
+ backgroundColor: '#2563EB',
+ },
+ btnPrimaryText: {
+ color: '#fff',
+ fontWeight: '600',
+ fontSize: 15,
+ },
+ btnSecondary: {
+ backgroundColor: '#F3F4F6',
+ },
+ btnSecondaryText: {
+ color: '#374151',
+ fontWeight: '600',
+ fontSize: 15,
+ },
+});
\ No newline at end of file
diff --git a/frontend/config/gitea.tsx b/frontend/config/gitea.tsx
new file mode 100644
index 0000000..ef56ecc
--- /dev/null
+++ b/frontend/config/gitea.tsx
@@ -0,0 +1,10 @@
+export const GITEA_URL = 'https://git.ksan.dev';
+
+export const GITEA_OWNER = 'ksan';
+
+export const GITEA_REPO = 'etf-oglasi';
+
+
+export const RELEASE_TAG_PREFIX = 'mobile-v';
+
+export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
diff --git a/frontend/hooks/usePushNotifications.tsx b/frontend/hooks/usePushNotifications.tsx
new file mode 100644
index 0000000..43415f3
--- /dev/null
+++ b/frontend/hooks/usePushNotifications.tsx
@@ -0,0 +1,46 @@
+
+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();
+ };
+ }, []);
+}
diff --git a/frontend/hooks/useUpdatecheck.tsx b/frontend/hooks/useUpdatecheck.tsx
new file mode 100644
index 0000000..72b4147
--- /dev/null
+++ b/frontend/hooks/useUpdatecheck.tsx
@@ -0,0 +1,137 @@
+
+import { useEffect, useState } from 'react';
+import {
+ GITEA_URL,
+ GITEA_OWNER,
+ GITEA_REPO,
+ RELEASE_TAG_PREFIX,
+ UPDATE_CHECK_TIMEOUT_MS,
+} from '../config/gitea';
+import { version as currentVersion } from '../version.json';
+
+export interface UpdateInfo {
+ /** Build number of the latest release (same unit as currentVersion). */
+ latestVersion: number;
+ /** Direct URL the user can open to read the release notes / download the APK. */
+ releaseUrl: string;
+ /** Tag name as stored on Gitea, e.g. "mobile-v42". */
+ tagName: string;
+}
+
+export interface UseUpdateCheckResult {
+ /** True while the API call is in flight. */
+ checking: boolean;
+ /** Populated once the check completes and a newer build exists. */
+ updateInfo: UpdateInfo | null;
+ /** Non-null when the check failed (network error, timeout, bad response). */
+ error: Error | null;
+ /** Re-run the check manually (e.g. from a "check again" button). */
+ recheck: () => void;
+}
+
+// ─── Gitea releases API ──────────────────────────────────────────────────────
+
+interface GiteaRelease {
+ id: number;
+ tag_name: string;
+ name: string;
+ html_url: string;
+ draft: boolean;
+ prerelease: boolean;
+}
+
+async function fetchLatestRelease(): Promise {
+ const url = `${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
+
+ try {
+ const res = await fetch(url, { signal: controller.signal });
+
+ if (!res.ok) {
+ throw new Error(`Gitea API returned HTTP ${res.status}`);
+ }
+
+ return (await res.json()) as GiteaRelease;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** Parse the integer build number out of a tag like "mobile-v42". Returns NaN if it can't. */
+function parseBuildNumber(tagName: string): number {
+ if (!tagName.startsWith(RELEASE_TAG_PREFIX)) return NaN;
+ return parseInt(tagName.slice(RELEASE_TAG_PREFIX.length), 10);
+}
+
+
+export function useUpdatecheck(): UseUpdateCheckResult {
+ const [checking, setChecking] = useState(false);
+ const [updateInfo, setUpdateInfo] = useState(null);
+ const [error, setError] = useState(null);
+ // A counter we bump to re-trigger the effect without changing the dep array shape.
+ const [trigger, setTrigger] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function check() {
+ setChecking(true);
+ setError(null);
+
+ try {
+ const release = await fetchLatestRelease();
+
+ if (cancelled) return;
+
+ // Skip drafts and pre-releases.
+ if (release.draft || release.prerelease) {
+ setUpdateInfo(null);
+ return;
+ }
+
+ const latestVersion = parseBuildNumber(release.tag_name);
+
+ if (isNaN(latestVersion)) {
+ // The tag doesn't follow our scheme — ignore it silently.
+ setUpdateInfo(null);
+ return;
+ }
+
+ if (latestVersion > currentVersion) {
+ setUpdateInfo({
+ latestVersion,
+ releaseUrl: release.html_url,
+ tagName: release.tag_name,
+ });
+ } else {
+ setUpdateInfo(null);
+ }
+ } catch (err) {
+ if (cancelled) return;
+ // Don't surface AbortError as a real error — it's just a timeout.
+ if (err instanceof Error && err.name === 'AbortError') {
+ setError(new Error('Update check timed out. Check your connection.'));
+ } else {
+ setError(err instanceof Error ? err : new Error(String(err)));
+ }
+ } finally {
+ if (!cancelled) setChecking(false);
+ }
+ }
+
+ check();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [trigger]);
+
+ return {
+ checking,
+ updateInfo,
+ error,
+ recheck: () => setTrigger(t => t + 1),
+ };
+}
\ No newline at end of file
diff --git a/frontend/index.tsx b/frontend/index.tsx
index c373b3d..e863d41 100644
--- a/frontend/index.tsx
+++ b/frontend/index.tsx
@@ -2,7 +2,7 @@
import "./globals.css";
-import React from "react";
+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";
@@ -16,7 +16,11 @@ import {SafeAreaProvider} from "react-native-safe-area-context";
import AuthGate from "@/screens/AuthGate";
import {AuthProvider, useAuth} from "@/context/AuthContext";
import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging";
-import {getMessaging} from "@firebase/messaging";
+
+import { useUpdatecheck } from "./hooks/useUpdatecheck";
+import { UpdatePrompt } from './components/UpdatePrompt';
+import { usePushNotifications } from "./hooks/usePushNotifications";
+
messaging().setBackgroundMessageHandler(async () => {});
// Must be called before any navigator renders
enableScreens();
@@ -45,6 +49,7 @@ function ProfileTab() {
return user ? : ;
}
+
export default function App() {
const scheme = useColorScheme();
const dark = scheme === "dark";
@@ -54,72 +59,86 @@ export default function App() {
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
+ const { updateInfo } = useUpdatecheck();
+ const [updateDismissed, setUpdateDismissed] = useState(false);
+
+
+ usePushNotifications();
+
return (
-
-
- ({
- headerShown: false,
+
+
+ ({
+ headerShown: false,
- tabBarStyle: {
- backgroundColor: tab.bg,
- borderTopColor: tab.border,
- borderTopWidth: 1,
- height: 60,
- paddingBottom: 8,
- paddingTop: 6,
- },
+ tabBarStyle: {
+ backgroundColor: tab.bg,
+ borderTopColor: tab.border,
+ borderTopWidth: 1,
+ height: 60,
+ paddingBottom: 8,
+ paddingTop: 6,
+ },
- tabBarActiveTintColor: tab.active,
- tabBarInactiveTintColor: tab.inactive,
+ tabBarActiveTintColor: tab.active,
+ tabBarInactiveTintColor: tab.inactive,
- tabBarLabelStyle: {
- fontSize: 10,
- fontWeight: "600",
- letterSpacing: 0.3,
- },
+ 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];
+ 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 (
-
- );
- },
- })}
- >
-
+ );
+ },
+ })}
+ >
+
+
+
+
+
+
+ {/* Overlays the entire app, including the nav bar */}
+ {updateInfo && !updateDismissed && (
+ setUpdateDismissed(true)}
/>
-
-
-
-
-
+ )}
-
+
+
);
-}
\ No newline at end of file
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 512fa3c..cf93d01 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0",
@@ -3583,6 +3584,15 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@notifee/react-native": {
+ "version": "9.1.8",
+ "resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz",
+ "integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native": "*"
+ }
+ },
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c3e52cf..1bc0660 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0",
diff --git a/frontend/version.json b/frontend/version.json
new file mode 100644
index 0000000..0e33e3f
--- /dev/null
+++ b/frontend/version.json
@@ -0,0 +1,3 @@
+{
+ "version": 0
+}
\ No newline at end of file