Remove native modules, add stubs for notifications and database

- Remove expo-sqlite, expo-local-authentication, expo-notifications
- Create AsyncStorage-based database fallback
- Add stub NotificationService implementation
- Update useNotifications hook with stub methods
- Remove native plugins from app.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
user 2026-02-04 09:06:14 +00:00
parent 87d9bda46a
commit 3aecbeb78f
6 changed files with 42 additions and 847 deletions

View File

@ -88,20 +88,7 @@
"plugins": [
"expo-router",
"expo-secure-store",
"expo-localization",
[
"expo-local-authentication",
{
"faceIDPermission": "Allow BUS-Tickets to use Face ID for authentication"
}
],
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#e94560"
}
]
"expo-localization"
],
"experiments": {
"typedRoutes": true

182
package-lock.json generated
View File

@ -25,14 +25,11 @@
"expo-font": "~12.0.0",
"expo-image": "~1.13.0",
"expo-linking": "~6.3.0",
"expo-local-authentication": "~14.0.0",
"expo-localization": "~15.0.0",
"expo-network": "~6.0.0",
"expo-notifications": "~0.28.0",
"expo-router": "~3.5.0",
"expo-secure-store": "~13.0.0",
"expo-splash-screen": "~0.27.0",
"expo-sqlite": "~14.0.0",
"expo-status-bar": "~1.12.0",
"expo-system-ui": "~3.0.7",
"expo-web-browser": "~13.0.0",
@ -3230,19 +3227,6 @@
"react-native": "*"
}
},
"node_modules/@expo/websql": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@expo/websql/-/websql-1.0.1.tgz",
"integrity": "sha512-H9/t1V7XXyKC343FJz/LwaVBfDhs6IqhDtSYWpt8LNSQDVjf5NvVJLc5wp+KCpRidZx8+0+YeHJN45HOXmqjFA==",
"license": "Apache-2.0",
"dependencies": {
"argsarray": "^0.0.1",
"immediate": "^3.2.2",
"noop-fn": "^1.0.0",
"pouchdb-collections": "^1.0.1",
"tiny-queue": "^0.2.1"
}
},
"node_modules/@expo/xcpretty": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.0.tgz",
@ -3319,12 +3303,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@ide/backoff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -6949,12 +6927,6 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/argsarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/argsarray/-/argsarray-0.0.1.tgz",
"integrity": "sha512-u96dg2GcAKtpTrBdDoFIM7PjcBA+6rSP0OR94MOReNRyUECL6MtQt5XXmRr4qrftYaef9+l5hcpO5te7sML1Cg==",
"license": "WTFPL"
},
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@ -7128,19 +7100,6 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.2",
"is-nan": "^1.3.2",
"object-is": "^1.1.5",
"object.assign": "^4.1.4",
"util": "^0.12.5"
}
},
"node_modules/ast-types": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz",
@ -7464,12 +7423,6 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -10062,18 +10015,6 @@
"invariant": "^2.2.4"
}
},
"node_modules/expo-local-authentication": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-14.0.1.tgz",
"integrity": "sha512-kAwUD1wEqj1fhwQgIHlP4H/JV9AcX+NO3BJwhPM2HuCFS0kgx2wvcHisnKBSTRyl8u5Jt4odzMyQkDJystwUTg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-localization": {
"version": "15.0.3",
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-15.0.3.tgz",
@ -10158,61 +10099,6 @@
"expo": "*"
}
},
"node_modules/expo-notifications": {
"version": "0.28.19",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.28.19.tgz",
"integrity": "sha512-rKKTnVQQ9XNQyTNwKmI9OlchhVu0XOZfRpImMqPFCJg6IwECM1izdas2SLCbE/GApg2Tw3U5R2fd26OnCtUU/w==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.5.0",
"@ide/backoff": "^1.0.0",
"abort-controller": "^3.0.0",
"assert": "^2.0.0",
"badgin": "^1.1.5",
"expo-application": "~5.9.0",
"expo-constants": "~16.0.0",
"fs-extra": "^9.1.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-notifications/node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"license": "MIT",
"dependencies": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/expo-notifications/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/expo-notifications/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/expo-router": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-3.5.24.tgz",
@ -10272,18 +10158,6 @@
"expo": "*"
}
},
"node_modules/expo-sqlite": {
"version": "14.0.6",
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-14.0.6.tgz",
"integrity": "sha512-T3YNx7LT7lM4UQRgi8ml+cj0Wf3Ep09+B4CVaWtUCjdyYJIZjsHDT65hypKG+r6btTLLEd11hjlrstNQhzt5gQ==",
"license": "MIT",
"dependencies": {
"@expo/websql": "^1.0.1"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-status-bar": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-1.12.1.tgz",
@ -11493,12 +11367,6 @@
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz",
"integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -11981,22 +11849,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
@ -15328,12 +15180,6 @@
"url": "https://github.com/sponsors/antelle"
}
},
"node_modules/noop-fn": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/noop-fn/-/noop-fn-1.0.0.tgz",
"integrity": "sha512-pQ8vODlgXt2e7A3mIbFDlizkr46r75V+BJxVAyat8Jl7YmI513gG5cfyRL0FedKraoZ+VAouI1h4/IWpus5pcQ==",
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -15443,22 +15289,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@ -16168,12 +15998,6 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/pouchdb-collections": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pouchdb-collections/-/pouchdb-collections-1.0.1.tgz",
"integrity": "sha512-31db6JRg4+4D5Yzc2nqsRqsA2oOkZS8DpFav3jf/qVNBxusKa2ClkEIZ2bJNpaDbMfWtnuSq59p6Bn+CipPMdg==",
"license": "Apache 2"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -18796,12 +18620,6 @@
"xtend": "~4.0.1"
}
},
"node_modules/tiny-queue": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/tiny-queue/-/tiny-queue-0.2.1.tgz",
"integrity": "sha512-EijGsv7kzd9I9g0ByCl6h42BWNGUZrlCSejfrb3AKeHC33SGbASu1VDf5O3rRiiUOhAC9CHdZxFPbZu0HmR70A==",
"license": "Apache 2"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@ -41,14 +41,11 @@
"expo-font": "~12.0.0",
"expo-image": "~1.13.0",
"expo-linking": "~6.3.0",
"expo-local-authentication": "~14.0.0",
"expo-localization": "~15.0.0",
"expo-network": "~6.0.0",
"expo-notifications": "~0.28.0",
"expo-router": "~3.5.0",
"expo-secure-store": "~13.0.0",
"expo-splash-screen": "~0.27.0",
"expo-sqlite": "~14.0.0",
"expo-status-bar": "~1.12.0",
"expo-system-ui": "~3.0.7",
"expo-web-browser": "~13.0.0",

View File

@ -1,17 +1,14 @@
/**
* BUS-Tickets - SQLite Database
* BUS-Tickets - Database (AsyncStorage fallback)
* SQLite disabled in this build - using AsyncStorage
* Copyright (c) 2024-2026 IT Enterprise
*/
import * as SQLite from 'expo-sqlite';
const DB_NAME = 'bus_tickets.db';
const DB_VERSION = 1;
import AsyncStorage from '@react-native-async-storage/async-storage';
class Database {
private static instance: Database;
private db: SQLite.SQLiteDatabase | null = null;
private initialized: boolean = false;
private initialized = false;
private constructor() {}
@ -22,161 +19,22 @@ class Database {
return Database.instance;
}
/**
* Get database instance
*/
async getDb(): Promise<SQLite.SQLiteDatabase> {
if (!this.db) {
this.db = await SQLite.openDatabaseAsync(DB_NAME);
}
return this.db;
}
/**
* Initialize database schema
*/
async initialize(): Promise<void> {
if (this.initialized) return;
const db = await this.getDb();
// Create tables
await db.execAsync(`
-- User table for offline access
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT,
name TEXT,
phone TEXT,
avatar TEXT,
synced_at INTEGER
);
-- Cached trips
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY,
route_id INTEGER,
route_name TEXT,
origin_city TEXT,
origin_country TEXT,
destination_city TEXT,
destination_country TEXT,
departure_time TEXT,
arrival_time TEXT,
bus_name TEXT,
bus_plate TEXT,
bus_capacity INTEGER,
bus_amenities TEXT,
available_seats INTEGER,
total_seats INTEGER,
price_amount REAL,
price_currency TEXT,
status TEXT,
synced_at INTEGER
);
-- User tickets (most important for offline)
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY,
ticket_number TEXT UNIQUE,
trip_id INTEGER,
passenger_name TEXT,
passenger_email TEXT,
passenger_phone TEXT,
seat INTEGER,
price_amount REAL,
price_currency TEXT,
status TEXT,
qr_code TEXT,
purchased_at TEXT,
checked_in_at TEXT,
synced_at INTEGER,
FOREIGN KEY (trip_id) REFERENCES trips(id)
);
-- Search history
CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT,
destination TEXT,
search_date TEXT,
passengers INTEGER,
created_at INTEGER
);
-- Offline actions queue
CREATE TABLE IF NOT EXISTS offline_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_type TEXT,
entity_type TEXT,
entity_id INTEGER,
payload TEXT,
created_at INTEGER,
retry_count INTEGER DEFAULT 0,
last_error TEXT
);
-- Cached routes for autocomplete
CREATE TABLE IF NOT EXISTS routes (
id INTEGER PRIMARY KEY,
name TEXT,
origin_city TEXT,
destination_city TEXT,
synced_at INTEGER
);
-- App metadata
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_trip ON tickets(trip_id);
CREATE INDEX IF NOT EXISTS idx_trips_departure ON trips(departure_time);
CREATE INDEX IF NOT EXISTS idx_offline_queue_created ON offline_queue(created_at);
`);
// Store database version
await this.setMetadata('db_version', String(DB_VERSION));
console.log('Database initialized (AsyncStorage mode)');
this.initialized = true;
console.log('Database initialized');
}
/**
* Get metadata value
*/
async getMetadata(key: string): Promise<string | null> {
const db = await this.getDb();
const result = await db.getFirstAsync<{ value: string }>(
'SELECT value FROM metadata WHERE key = ?',
[key]
);
return result?.value || null;
return AsyncStorage.getItem(`@db_meta_${key}`);
}
/**
* Set metadata value
*/
async setMetadata(key: string, value: string): Promise<void> {
const db = await this.getDb();
await db.runAsync(
'INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)',
[key, value]
);
await AsyncStorage.setItem(`@db_meta_${key}`, value);
}
/**
* Close database connection
*/
async close(): Promise<void> {
if (this.db) {
await this.db.closeAsync();
this.db = null;
this.initialized = false;
}
// No-op for AsyncStorage
}
}

View File

@ -1,16 +1,15 @@
/**
* BUS-Tickets - Notification Hook
* BUS-Tickets - Notification Hook (Stub)
* Push notifications disabled in this build
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';
import { useState, useCallback } from 'react';
import { notificationService, NotificationSettings } from '../services/NotificationService';
interface UseNotificationsReturn {
expoPushToken: string | null;
notification: Notifications.Notification | null;
notification: null;
settings: NotificationSettings;
updateSettings: (settings: Partial<NotificationSettings>) => Promise<void>;
scheduleReminder: (
@ -25,98 +24,10 @@ interface UseNotificationsReturn {
}
export function useNotifications(): UseNotificationsReturn {
const router = useRouter();
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
const [notification, setNotification] = useState<Notifications.Notification | null>(null);
const [settings, setSettings] = useState<NotificationSettings>(
notificationService.getSettings()
);
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
// Initialize notification service
const init = async () => {
await notificationService.initialize();
const token = notificationService.getPushToken();
setExpoPushToken(token);
setSettings(notificationService.getSettings());
};
init();
// Listen for incoming notifications while app is foregrounded
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
setNotification(notification);
console.log('Notification received:', notification);
}
);
// Listen for notification responses (user tapped on notification)
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
handleNotificationResponse(response);
}
);
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current);
}
};
}, []);
/**
* Handle notification tap
*/
const handleNotificationResponse = (
response: Notifications.NotificationResponse
) => {
const data = response.notification.request.content.data;
console.log('Notification tapped:', data);
// Navigate based on notification type
switch (data.type) {
case 'trip_reminder':
case 'booking_confirmation':
if (data.ticketId) {
router.push({
pathname: '/ticket/[ticketId]',
params: { ticketId: String(data.ticketId) },
});
}
break;
case 'trip_update':
if (data.tripId) {
router.push({
pathname: '/booking/[tripId]',
params: { tripId: String(data.tripId) },
});
}
break;
case 'promotion':
router.push('/');
break;
default:
// Navigate to tickets by default
router.push('/(tabs)/tickets');
}
// Clear badge
notificationService.clearBadge();
};
/**
* Update notification settings
*/
const updateSettings = useCallback(
async (newSettings: Partial<NotificationSettings>) => {
await notificationService.updateSettings(newSettings);
@ -125,9 +36,6 @@ export function useNotifications(): UseNotificationsReturn {
[]
);
/**
* Schedule trip reminder
*/
const scheduleReminder = useCallback(
async (
ticketId: number,
@ -136,34 +44,22 @@ export function useNotifications(): UseNotificationsReturn {
destination: string,
departureTime: Date
): Promise<string | null> => {
return notificationService.scheduleTripReminder(
ticketId,
tripId,
origin,
destination,
departureTime
);
return null;
},
[]
);
/**
* Cancel scheduled reminder
*/
const cancelReminder = useCallback(async (notificationId: string) => {
await notificationService.cancelNotification(notificationId);
// No-op
}, []);
/**
* Clear badge count
*/
const clearBadge = useCallback(async () => {
await notificationService.clearBadge();
// No-op
}, []);
return {
expoPushToken,
notification,
expoPushToken: null,
notification: null,
settings,
updateSettings,
scheduleReminder,

View File

@ -1,54 +1,17 @@
/**
* BUS-Tickets - Push Notification Service
* BUS-Tickets - Notification Service (Stub)
* Push notifications disabled in this build
* Copyright (c) 2024-2026 IT Enterprise
*/
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Check if we're on web
const isWeb = Platform.OS === 'web';
// Conditionally import native modules
let Notifications: typeof import('expo-notifications') | null = null;
let Device: typeof import('expo-device') | null = null;
let Constants: typeof import('expo-constants').default | null = null;
if (!isWeb) {
Notifications = require('expo-notifications');
Device = require('expo-device');
Constants = require('expo-constants').default;
}
const PUSH_TOKEN_KEY = '@bus_tickets_push_token';
const NOTIFICATION_SETTINGS_KEY = '@bus_tickets_notification_settings';
export interface NotificationSettings {
enabled: boolean;
tripReminders: boolean;
tripReminderMinutes: number; // Minutes before departure
bookingConfirmations: boolean;
promotions: boolean;
tripUpdates: boolean;
sound: boolean;
vibration: boolean;
bookingUpdates: boolean;
reminderTime: number;
}
const DEFAULT_SETTINGS: NotificationSettings = {
enabled: true,
tripReminders: true,
tripReminderMinutes: 60, // 1 hour before
bookingConfirmations: true,
promotions: false,
tripUpdates: true,
sound: true,
vibration: true,
};
export interface NotificationData {
type: 'trip_reminder' | 'booking_confirmation' | 'trip_update' | 'promotion' | 'general';
ticketId?: number;
tripId?: number;
title: string;
body: string;
data?: Record<string, unknown>;
@ -56,9 +19,12 @@ export interface NotificationData {
class NotificationService {
private static instance: NotificationService;
private pushToken: string | null = null;
private settings: NotificationSettings = DEFAULT_SETTINGS;
private initialized: boolean = false;
private settings: NotificationSettings = {
tripReminders: true,
promotions: false,
bookingUpdates: true,
reminderTime: 60,
};
private constructor() {}
@ -69,220 +35,26 @@ class NotificationService {
return NotificationService.instance;
}
/**
* Initialize notification service
*/
async initialize(): Promise<void> {
if (this.initialized) return;
// Skip push notifications on web (requires VAPID key setup)
if (isWeb) {
await this.loadSettings();
this.initialized = true;
return;
console.log('Notifications disabled in this build');
}
// Configure notification handler (native only)
if (Notifications) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: this.settings.sound,
shouldSetBadge: true,
}),
});
}
// Load settings
await this.loadSettings();
// Request permissions and get push token (native only)
if (this.settings.enabled && !isWeb) {
await this.registerForPushNotifications();
}
this.initialized = true;
}
/**
* Request permission and register for push notifications
*/
async registerForPushNotifications(): Promise<string | null> {
// Skip on web - would require VAPID key
if (isWeb || !Notifications || !Device || !Constants) {
console.log('Push notifications not available on web');
return null;
}
if (!Device.isDevice) {
console.log('Push notifications require a physical device');
return null;
}
try {
// Check existing permission
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request permission if not granted
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Push notification permission denied');
return null;
}
// Get push token
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId,
});
this.pushToken = tokenData.data;
// Save token locally
await AsyncStorage.setItem(PUSH_TOKEN_KEY, this.pushToken);
// Configure Android channel
if (Platform.OS === 'android') {
await this.setupAndroidChannels();
}
console.log('Push token:', this.pushToken);
return this.pushToken;
} catch (error) {
console.error('Error registering for push notifications:', error);
return null;
}
}
/**
* Setup Android notification channels
*/
private async setupAndroidChannels(): Promise<void> {
if (!Notifications) return;
// Trip reminders channel
await Notifications.setNotificationChannelAsync('trip-reminders', {
name: 'Trip Reminders',
description: 'Reminders about upcoming trips',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#e94560',
sound: 'default',
});
// Booking confirmations channel
await Notifications.setNotificationChannelAsync('booking-confirmations', {
name: 'Booking Confirmations',
description: 'Notifications about booking status',
importance: Notifications.AndroidImportance.HIGH,
sound: 'default',
});
// Trip updates channel
await Notifications.setNotificationChannelAsync('trip-updates', {
name: 'Trip Updates',
description: 'Updates about trip changes or delays',
importance: Notifications.AndroidImportance.HIGH,
sound: 'default',
});
// Promotions channel
await Notifications.setNotificationChannelAsync('promotions', {
name: 'Promotions & Offers',
description: 'Special offers and discounts',
importance: Notifications.AndroidImportance.DEFAULT,
sound: 'default',
});
// General channel
await Notifications.setNotificationChannelAsync('general', {
name: 'General',
description: 'General notifications',
importance: Notifications.AndroidImportance.DEFAULT,
});
}
/**
* Get push token
*/
getPushToken(): string | null {
return this.pushToken;
return null;
}
getSettings(): NotificationSettings {
return this.settings;
}
async updateSettings(newSettings: Partial<NotificationSettings>): Promise<void> {
this.settings = { ...this.settings, ...newSettings };
}
/**
* Register token with backend
*/
async registerTokenWithBackend(apiUrl: string, userId: number): Promise<void> {
if (!this.pushToken) {
console.log('No push token to register');
return;
// No-op
}
try {
const response = await fetch(`${apiUrl}/api/v1/push-tokens/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: this.pushToken,
user_id: userId,
platform: Platform.OS,
device_name: Device?.deviceName || 'Unknown',
}),
});
if (!response.ok) {
throw new Error('Failed to register push token');
}
console.log('Push token registered with backend');
} catch (error) {
console.error('Error registering token with backend:', error);
}
}
/**
* Schedule local notification
*/
async scheduleLocalNotification(
notification: NotificationData,
trigger: unknown
): Promise<string> {
if (isWeb || !Notifications) {
console.log('Local notifications not available on web');
return '';
}
const channelId = this.getChannelForType(notification.type);
const notificationId = await Notifications.scheduleNotificationAsync({
content: {
title: notification.title,
body: notification.body,
data: {
type: notification.type,
ticketId: notification.ticketId,
tripId: notification.tripId,
...notification.data,
},
sound: this.settings.sound ? 'default' : undefined,
...(Platform.OS === 'android' && { channelId }),
},
trigger: trigger as Parameters<typeof Notifications.scheduleNotificationAsync>[0]['trigger'],
});
return notificationId;
}
/**
* Schedule trip reminder
*/
async scheduleTripReminder(
ticketId: number,
tripId: number,
@ -290,148 +62,15 @@ class NotificationService {
destination: string,
departureTime: Date
): Promise<string | null> {
if (!this.settings.tripReminders) {
return null;
}
const reminderTime = new Date(departureTime);
reminderTime.setMinutes(
reminderTime.getMinutes() - this.settings.tripReminderMinutes
);
// Don't schedule if reminder time is in the past
if (reminderTime <= new Date()) {
return null;
}
return this.scheduleLocalNotification(
{
type: 'trip_reminder',
ticketId,
tripId,
title: 'Trip Reminder',
body: `Your trip from ${origin} to ${destination} departs in ${this.settings.tripReminderMinutes} minutes`,
},
{
date: reminderTime,
}
);
}
/**
* Send instant local notification
*/
async sendLocalNotification(notification: NotificationData): Promise<void> {
await this.scheduleLocalNotification(notification, null);
}
/**
* Cancel scheduled notification
*/
async cancelNotification(notificationId: string): Promise<void> {
if (isWeb || !Notifications) return;
await Notifications.cancelScheduledNotificationAsync(notificationId);
// No-op
}
/**
* Cancel all scheduled notifications
*/
async cancelAllNotifications(): Promise<void> {
if (isWeb || !Notifications) return;
await Notifications.cancelAllScheduledNotificationsAsync();
}
/**
* Get all scheduled notifications
*/
async getScheduledNotifications(): Promise<unknown[]> {
if (isWeb || !Notifications) return [];
return Notifications.getAllScheduledNotificationsAsync();
}
/**
* Get badge count
*/
async getBadgeCount(): Promise<number> {
if (isWeb || !Notifications) return 0;
return Notifications.getBadgeCountAsync();
}
/**
* Set badge count
*/
async setBadgeCount(count: number): Promise<void> {
if (isWeb || !Notifications) return;
await Notifications.setBadgeCountAsync(count);
}
/**
* Clear badge
*/
async clearBadge(): Promise<void> {
if (isWeb || !Notifications) return;
await Notifications.setBadgeCountAsync(0);
}
/**
* Get notification settings
*/
getSettings(): NotificationSettings {
return { ...this.settings };
}
/**
* Update notification settings
*/
async updateSettings(newSettings: Partial<NotificationSettings>): Promise<void> {
this.settings = { ...this.settings, ...newSettings };
await AsyncStorage.setItem(
NOTIFICATION_SETTINGS_KEY,
JSON.stringify(this.settings)
);
// Re-register if enabling notifications
if (newSettings.enabled && !this.pushToken) {
await this.registerForPushNotifications();
}
}
/**
* Load settings from storage
*/
private async loadSettings(): Promise<void> {
try {
const stored = await AsyncStorage.getItem(NOTIFICATION_SETTINGS_KEY);
if (stored) {
this.settings = { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
}
// Load stored token
const storedToken = await AsyncStorage.getItem(PUSH_TOKEN_KEY);
if (storedToken) {
this.pushToken = storedToken;
}
} catch (error) {
console.error('Error loading notification settings:', error);
}
}
/**
* Get Android channel for notification type
*/
private getChannelForType(type: NotificationData['type']): string {
switch (type) {
case 'trip_reminder':
return 'trip-reminders';
case 'booking_confirmation':
return 'booking-confirmations';
case 'trip_update':
return 'trip-updates';
case 'promotion':
return 'promotions';
default:
return 'general';
}
// No-op
}
}