diff --git a/backend/.env b/backend/.env index 3dc3cfd..bf9c67d 100644 --- a/backend/.env +++ b/backend/.env @@ -2,6 +2,9 @@ DB_HOST = "db" DB_USER = "maia" DB_PASSWORD = "maia" DB_NAME = "maia" + +REDIS_URL = "redis://redis:6379" + PEPPER = "LsD7%" JWT_SECRET_KEY="1c8cf3ca6972b365f8108dad247e61abdcb6faff5a6c8ba00cb6fa17396702bf" GOOGLE_API_KEY="AIzaSyBrte_mETZJce8qE6cRTSz_fHOjdjlShBk" diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 2e5bbd5..cf7ae5d 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -2,7 +2,6 @@ import os import sys from logging.config import fileConfig -from sqlalchemy import engine_from_config from sqlalchemy import pool from sqlalchemy import create_engine # Add create_engine import diff --git a/backend/alembic/versions/69069d6184b3_initial_migration_with_existing_tables.py b/backend/alembic/versions/69069d6184b3_initial_migration_with_existing_tables.py deleted file mode 100644 index be37ff4..0000000 --- a/backend/alembic/versions/69069d6184b3_initial_migration_with_existing_tables.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Initial migration with existing tables - -Revision ID: 69069d6184b3 -Revises: -Create Date: 2025-04-21 01:14:33.233195 - -""" - -from typing import Sequence, Union - - -# revision identifiers, used by Alembic. -revision: str = "69069d6184b3" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/backend/alembic/versions/9a82960db482_add_todo_table.py b/backend/alembic/versions/9a82960db482_add_todo_table.py deleted file mode 100644 index d38d4fd..0000000 --- a/backend/alembic/versions/9a82960db482_add_todo_table.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Add todo table - -Revision ID: 9a82960db482 -Revises: 69069d6184b3 -Create Date: 2025-04-21 20:33:27.028529 - -""" - -from typing import Sequence, Union - - -# revision identifiers, used by Alembic. -revision: str = "9a82960db482" -down_revision: Union[str, None] = "69069d6184b3" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/backend/alembic/versions/a34d847510da_add_all_day_column_to_calendar_events.py b/backend/alembic/versions/a34d847510da_add_all_day_column_to_calendar_events.py deleted file mode 100644 index 71e9dd9..0000000 --- a/backend/alembic/versions/a34d847510da_add_all_day_column_to_calendar_events.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Add all_day column to calendar_events - -Revision ID: a34d847510da -Revises: 9a82960db482 -Create Date: 2025-04-26 11:09:35.400748 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = 'a34d847510da' -down_revision: Union[str, None] = '9a82960db482' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('calendar_events') - op.drop_table('users') - op.drop_index('ix_todos_id', table_name='todos') - op.drop_index('ix_todos_task', table_name='todos') - op.drop_table('todos') - op.drop_table('token_blacklist') - op.drop_index('ix_chat_messages_id', table_name='chat_messages') - op.drop_index('ix_chat_messages_user_id', table_name='chat_messages') - op.drop_table('chat_messages') - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('chat_messages', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column('sender', postgresql.ENUM('USER', 'AI', name='messagesender'), autoincrement=False, nullable=False), - sa.Column('text', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('timestamp', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='chat_messages_user_id_fkey'), - sa.PrimaryKeyConstraint('id', name='chat_messages_pkey') - ) - op.create_index('ix_chat_messages_user_id', 'chat_messages', ['user_id'], unique=False) - op.create_index('ix_chat_messages_id', 'chat_messages', ['id'], unique=False) - op.create_table('token_blacklist', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('token', sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column('expires_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), - sa.PrimaryKeyConstraint('id', name='token_blacklist_pkey'), - sa.UniqueConstraint('token', name='token_blacklist_token_key') - ) - op.create_table('todos', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('task', sa.VARCHAR(), autoincrement=False, nullable=False), - sa.Column('date', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), - sa.Column('remind', sa.BOOLEAN(), autoincrement=False, nullable=True), - sa.Column('complete', sa.BOOLEAN(), autoincrement=False, nullable=True), - sa.Column('owner_id', sa.INTEGER(), autoincrement=False, nullable=True), - sa.ForeignKeyConstraint(['owner_id'], ['users.id'], name='todos_owner_id_fkey'), - sa.PrimaryKeyConstraint('id', name='todos_pkey') - ) - op.create_index('ix_todos_task', 'todos', ['task'], unique=False) - op.create_index('ix_todos_id', 'todos', ['id'], unique=False) - op.create_table('users', - sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('users_id_seq'::regclass)"), autoincrement=True, nullable=False), - sa.Column('uuid', sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column('username', sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column('role', postgresql.ENUM('ADMIN', 'USER', name='userrole'), autoincrement=False, nullable=False), - sa.Column('hashed_password', sa.VARCHAR(), autoincrement=False, nullable=True), - sa.PrimaryKeyConstraint('id', name='users_pkey'), - sa.UniqueConstraint('username', name='users_username_key'), - sa.UniqueConstraint('uuid', name='users_uuid_key'), - postgresql_ignore_search_path=False - ) - op.create_table('calendar_events', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('title', sa.VARCHAR(), autoincrement=False, nullable=False), - sa.Column('description', sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column('start', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), - sa.Column('end', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), - sa.Column('location', sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column('tags', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True), - sa.Column('color', sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='calendar_events_user_id_fkey'), - sa.PrimaryKeyConstraint('id', name='calendar_events_pkey') - ) - # ### end Alembic commands ### diff --git a/backend/core/__pycache__/celery_app.cpython-312.pyc b/backend/core/__pycache__/celery_app.cpython-312.pyc index 1a911f9..cd90706 100644 Binary files a/backend/core/__pycache__/celery_app.cpython-312.pyc and b/backend/core/__pycache__/celery_app.cpython-312.pyc differ diff --git a/backend/core/__pycache__/config.cpython-312.pyc b/backend/core/__pycache__/config.cpython-312.pyc index bf23a90..1fe6175 100644 Binary files a/backend/core/__pycache__/config.cpython-312.pyc and b/backend/core/__pycache__/config.cpython-312.pyc differ diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 9eced90..1cbdbe9 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -1,6 +1,7 @@ # core/celery_app.py from celery import Celery from core.config import settings + celery_app = Celery( "worker", broker=settings.REDIS_URL, @@ -8,5 +9,15 @@ celery_app = Celery( include=[ "modules.auth.tasks", "modules.admin.tasks", + "modules.calendar.tasks", # Add calendar tasks ], -) \ No newline at end of file +) + +# Optional: Configure Celery Beat if you need periodic tasks later +# celery_app.conf.beat_schedule = { +# 'check-something-every-5-minutes': { +# 'task': 'your_app.tasks.check_something', +# 'schedule': timedelta(minutes=5), +# }, +# } +celery_app.conf.timezone = "UTC" # Recommended to use UTC diff --git a/backend/core/config.py b/backend/core/config.py index dce8491..87d2b2a 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): # Other settings GOOGLE_API_KEY: str + EXPO_PUSH_API_URL: str = "https://exp.host/--/api/v2/push/send" class Config: # Tell pydantic-settings to load variables from a .env file diff --git a/backend/core/database.py b/backend/core/database.py index 3425083..9b18088 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -11,9 +11,10 @@ _SessionLocal = None settings.DB_URL = f"postgresql://{settings.DB_USER}:{settings.DB_PASSWORD}@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}" + def get_engine(): global _engine - if (_engine is None): + if _engine is None: if not settings.DB_URL: raise ValueError("DB_URL is not set in Settings.") print(f"Connecting to database at {settings.DB_URL}") diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 223e02a..5ea2929 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -47,7 +47,7 @@ services: image: postgres:15 # Use a specific version container_name: MAIA-DB volumes: - - ./db:/var/lib/postgresql/data # Persist data using a named volume + - db:/var/lib/postgresql/data # Persist data using a named volume environment: - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} @@ -63,11 +63,17 @@ services: image: redis:7 # Use a specific version container_name: MAIA-Redis volumes: - - ./redis_data:/data + - redis_data:/data networks: - maia_network restart: unless-stopped +volumes: + db: # Named volume for PostgreSQL data + driver: local + redis_data: # Named volume for Redis data + driver: local + # ----- Network Definition ----- networks: maia_network: # Define a custom bridge network diff --git a/backend/main.py b/backend/main.py index 71d32c1..6fdce33 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,6 @@ app.add_middleware( "https://maia.depaoli.id.au", "http://localhost:8081", ], - allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) diff --git a/backend/modules/admin/__pycache__/api.cpython-312.pyc b/backend/modules/admin/__pycache__/api.cpython-312.pyc index 63ae70c..b5d6c10 100644 Binary files a/backend/modules/admin/__pycache__/api.cpython-312.pyc and b/backend/modules/admin/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/admin/api.py b/backend/modules/admin/api.py index 876c80b..6b02c73 100644 --- a/backend/modules/admin/api.py +++ b/backend/modules/admin/api.py @@ -1,10 +1,12 @@ # modules/admin/api.py -from typing import Annotated -from fastapi import APIRouter, Depends +from typing import Annotated, Optional +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session from core.database import get_db from modules.auth.dependencies import admin_only +from modules.auth.models import User +from modules.notifications.service import send_push_notification from .tasks import cleardb router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(admin_only)]) @@ -14,6 +16,13 @@ class ClearDbRequest(BaseModel): hard: bool +class SendNotificationRequest(BaseModel): + username: str + title: str + body: str + data: Optional[dict] = None + + @router.get("/") def read_admin(): return {"message": "Admin route"} @@ -29,3 +38,43 @@ def clear_db(payload: ClearDbRequest, db: Annotated[Session, Depends(get_db)]): hard = payload.hard cleardb.delay(hard) return {"message": "Clearing database in the background", "hard": hard} + + +@router.post("/send-notification", status_code=status.HTTP_200_OK) +async def send_user_notification( + payload: SendNotificationRequest, + db: Annotated[Session, Depends(get_db)], +): + """ + Admin endpoint to send a push notification to a specific user by username. + """ + target_user = db.query(User).filter(User.username == payload.username).first() + + if not target_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with username '{payload.username}' not found.", + ) + + if not target_user.expo_push_token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"User '{payload.username}' does not have a registered push token.", + ) + + success = await send_push_notification( + push_token=target_user.expo_push_token, + title=payload.title, + body=payload.body, + data=payload.data, + ) + + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to send push notification via Expo service.", + ) + + return { + "message": f"Push notification sent successfully to user '{payload.username}'" + } diff --git a/backend/modules/auth/__pycache__/models.cpython-312.pyc b/backend/modules/auth/__pycache__/models.cpython-312.pyc index 20a253c..d1a2b9e 100644 Binary files a/backend/modules/auth/__pycache__/models.cpython-312.pyc and b/backend/modules/auth/__pycache__/models.cpython-312.pyc differ diff --git a/backend/modules/auth/models.py b/backend/modules/auth/models.py index 03e3bc0..8814144 100644 --- a/backend/modules/auth/models.py +++ b/backend/modules/auth/models.py @@ -1,6 +1,6 @@ # modules/auth/models.py from core.database import Base -from sqlalchemy import Column, Integer, String, Enum, DateTime +from sqlalchemy import Column, Integer, String, Enum, DateTime, Text from sqlalchemy.orm import relationship from enum import Enum as PyEnum @@ -18,6 +18,7 @@ class User(Base): name = Column(String) role = Column(Enum(UserRole), nullable=False, default=UserRole.USER) hashed_password = Column(String) + expo_push_token = Column(Text, nullable=True) calendar_events = relationship("CalendarEvent", back_populates="user") diff --git a/backend/modules/calendar/__pycache__/models.cpython-312.pyc b/backend/modules/calendar/__pycache__/models.cpython-312.pyc index ffabf7b..07aaa4f 100644 Binary files a/backend/modules/calendar/__pycache__/models.cpython-312.pyc and b/backend/modules/calendar/__pycache__/models.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/service.cpython-312.pyc b/backend/modules/calendar/__pycache__/service.cpython-312.pyc index 95f0381..480f004 100644 Binary files a/backend/modules/calendar/__pycache__/service.cpython-312.pyc and b/backend/modules/calendar/__pycache__/service.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/tasks.cpython-312.pyc b/backend/modules/calendar/__pycache__/tasks.cpython-312.pyc new file mode 100644 index 0000000..5b748d3 Binary files /dev/null and b/backend/modules/calendar/__pycache__/tasks.cpython-312.pyc differ diff --git a/backend/modules/calendar/models.py b/backend/modules/calendar/models.py index f5d1554..a589709 100644 --- a/backend/modules/calendar/models.py +++ b/backend/modules/calendar/models.py @@ -7,7 +7,7 @@ from sqlalchemy import ( ForeignKey, JSON, Boolean, -) # Add Boolean +) from sqlalchemy.orm import relationship from core.database import Base @@ -18,15 +18,12 @@ class CalendarEvent(Base): id = Column(Integer, primary_key=True) title = Column(String, nullable=False) description = Column(String) - start = Column(DateTime, nullable=False) - end = Column(DateTime) + start = Column(DateTime(timezone=True), nullable=False) + end = Column(DateTime(timezone=True)) location = Column(String) - all_day = Column(Boolean, default=False) # Add all_day column + all_day = Column(Boolean, default=False) tags = Column(JSON) - color = Column(String) # hex code for color - user_id = Column( - Integer, ForeignKey("users.id"), nullable=False - ) # <-- Relationship + color = Column(String) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - # Bi-directional relationship (for eager loading) user = relationship("User", back_populates="calendar_events") diff --git a/backend/modules/calendar/service.py b/backend/modules/calendar/service.py index 9884fa5..6dfb871 100644 --- a/backend/modules/calendar/service.py +++ b/backend/modules/calendar/service.py @@ -7,7 +7,13 @@ from core.exceptions import not_found_exception from modules.calendar.schemas import ( CalendarEventCreate, CalendarEventUpdate, -) # Import schemas +) + +# Import the celery app instance instead of the task functions directly +from core.celery_app import celery_app + +# Keep task imports if cancel_event_notifications is still called directly and synchronously +from modules.calendar.tasks import cancel_event_notifications def create_calendar_event(db: Session, user_id: int, event_data: CalendarEventCreate): @@ -23,6 +29,11 @@ def create_calendar_event(db: Session, user_id: int, event_data: CalendarEventCr db.add(event) db.commit() db.refresh(event) + # Schedule notifications using send_task + celery_app.send_task( + "modules.calendar.tasks.schedule_event_notifications", # Task name as string + args=[event.id], + ) return event @@ -114,10 +125,17 @@ def update_calendar_event( db.commit() db.refresh(event) + # Re-schedule notifications using send_task + celery_app.send_task( + "modules.calendar.tasks.schedule_event_notifications", args=[event.id] + ) return event def delete_calendar_event(db: Session, user_id: int, event_id: int): event = get_calendar_event_by_id(db, user_id, event_id) # Reuse get_by_id for check + # Cancel any scheduled notifications before deleting + # Run synchronously here or make cancel_event_notifications an async task + cancel_event_notifications(event_id) db.delete(event) db.commit() diff --git a/backend/modules/calendar/tasks.py b/backend/modules/calendar/tasks.py new file mode 100644 index 0000000..a052595 --- /dev/null +++ b/backend/modules/calendar/tasks.py @@ -0,0 +1,233 @@ +# backend/modules/calendar/tasks.py +import logging +import asyncio +from datetime import datetime, timedelta, time, timezone + +from celery import shared_task +from celery.exceptions import Ignore + +from core.celery_app import celery_app +from core.database import get_db +from modules.calendar.models import CalendarEvent +from modules.notifications.service import send_push_notification +from modules.auth.models import User # Assuming user model is in modules/user/models.py + +logger = logging.getLogger(__name__) + +# Key prefix for storing scheduled task IDs in Redis (or Celery backend) +SCHEDULED_TASK_KEY_PREFIX = "calendar_event_tasks:" + + +def get_scheduled_task_key(event_id: int) -> str: + return f"{SCHEDULED_TASK_KEY_PREFIX}{event_id}" + + +@shared_task(bind=True) +def schedule_event_notifications(self, event_id: int): + """Schedules reminder notifications for a calendar event.""" + db_gen = get_db() + db = next(db_gen) + try: + event = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() + if not event: + logger.warning( + f"Calendar event {event_id} not found for scheduling notifications." + ) + raise Ignore() # Don't retry if event doesn't exist + + user = db.query(User).filter(User.id == event.user_id).first() + if not user or not user.expo_push_token: + logger.warning( + f"User {event.user_id} or their push token not found for event {event_id}. Skipping notification scheduling." + ) + # Cancel any potentially existing tasks for this event if user/token is now invalid + cancel_event_notifications(event_id) + raise Ignore() # Don't retry if user/token missing + + # Cancel any existing notifications for this event first + cancel_event_notifications(event_id) # Run synchronously within this task + + scheduled_task_ids = [] + now_utc = datetime.now(timezone.utc) + + if event.all_day: + # Schedule one notification at 6:00 AM in the event's original timezone (or UTC if naive) + event_start_date = event.start.date() + notification_time_local = datetime.combine( + event_start_date, time(6, 0), tzinfo=event.start.tzinfo + ) + # Convert scheduled time to UTC for Celery ETA + notification_time_utc = notification_time_local.astimezone(timezone.utc) + + if notification_time_utc > now_utc: + task = send_event_notification.apply_async( + args=[event.id, user.expo_push_token, "all_day"], + eta=notification_time_utc, + ) + scheduled_task_ids.append(task.id) + logger.info( + f"Scheduled all-day notification for event {event_id} at {notification_time_utc} (Task ID: {task.id})" + ) + else: + logger.info( + f"All-day notification time {notification_time_utc} for event {event_id} is in the past. Skipping." + ) + + else: + # Ensure event start time is timezone-aware (assume UTC if naive) + event_start_utc = event.start + if event_start_utc.tzinfo is None: + event_start_utc = event_start_utc.replace(tzinfo=timezone.utc) + else: + event_start_utc = event_start_utc.astimezone(timezone.utc) + + times_before = { + "1_hour": timedelta(hours=1), + "30_min": timedelta(minutes=30), + } + + for label, delta in times_before.items(): + notification_time_utc = event_start_utc - delta + if notification_time_utc > now_utc: + task = send_event_notification.apply_async( + args=[event.id, user.expo_push_token, label], + eta=notification_time_utc, + ) + scheduled_task_ids.append(task.id) + logger.info( + f"Scheduled {label} notification for event {event_id} at {notification_time_utc} (Task ID: {task.id})" + ) + else: + logger.info( + f"{label} notification time {notification_time_utc} for event {event_id} is in the past. Skipping." + ) + + # Store the new task IDs using Celery backend (Redis) + if scheduled_task_ids: + key = get_scheduled_task_key(event_id) + # Store as a simple comma-separated string + celery_app.backend.set(key, ",".join(scheduled_task_ids)) + logger.debug(f"Stored task IDs for event {event_id}: {scheduled_task_ids}") + + except Exception as e: + logger.exception(f"Error scheduling notifications for event {event_id}: {e}") + # Optional: Add retry logic if appropriate + # self.retry(exc=e, countdown=60) + finally: + next(db_gen, None) # Ensure db session is closed + + +# Note: This task calls an async function. Ensure your Celery worker +# is configured to handle async tasks (e.g., using gevent, eventlet, or uvicorn worker). +@shared_task(bind=True) +def send_event_notification( + self, event_id: int, user_push_token: str, notification_type: str +): + """Sends a single reminder notification for a calendar event.""" + db_gen = get_db() + db = next(db_gen) + try: + event = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() + if not event: + logger.warning( + f"Calendar event {event_id} not found for sending {notification_type} notification." + ) + raise Ignore() # Don't retry if event doesn't exist + + # Double-check user and token validity at the time of sending + user = db.query(User).filter(User.id == event.user_id).first() + if not user or user.expo_push_token != user_push_token: + logger.warning( + f"User {event.user_id} token mismatch or user not found for event {event_id} at notification time. Skipping." + ) + raise Ignore() + + title = f"Upcoming: {event.title}" + if notification_type == "all_day": + body = f"Today: {event.title}" + if event.description: + body += f" - {event.description[:50]}" # Add part of description + elif notification_type == "1_hour": + local_start_time = event.start.astimezone().strftime( + "%I:%M %p" + ) # Convert to local time for display + body = f"Starts at {local_start_time} (in 1 hour)" + elif notification_type == "30_min": + local_start_time = event.start.astimezone().strftime("%I:%M %p") + body = f"Starts at {local_start_time} (in 30 mins)" + else: + body = "Check your calendar for details." # Fallback + + logger.info( + f"Sending {notification_type} notification for event {event_id} to token {user_push_token[:10]}..." + ) + try: + # Call the async notification service + success = asyncio.run( + send_push_notification( + push_token=user_push_token, + title=title, + body=body, + data={"eventId": event.id, "type": "calendar_reminder"}, + ) + ) + if not success: + logger.error( + f"Failed to send {notification_type} notification for event {event_id} via service." + ) + # Optional: self.retry(countdown=60) # Retry sending if failed + else: + logger.info( + f"Successfully sent {notification_type} notification for event {event_id}." + ) + except Exception as e: + logger.exception( + f"Error calling send_push_notification for event {event_id}: {e}" + ) + # Optional: self.retry(exc=e, countdown=60) + + except Exception as e: + logger.exception( + f"General error sending {notification_type} notification for event {event_id}: {e}" + ) + # Optional: self.retry(exc=e, countdown=60) + finally: + next(db_gen, None) # Ensure db session is closed + + +# This is run synchronously when called, or can be called as a task itself +# @shared_task # Uncomment if you want to call this asynchronously e.g., .delay() +def cancel_event_notifications(event_id: int): + """Cancels all scheduled reminder notifications for a calendar event.""" + key = get_scheduled_task_key(event_id) + try: + task_ids_bytes = celery_app.backend.get(key) + + if task_ids_bytes: + # Decode from bytes (assuming Redis backend) + task_ids_str = task_ids_bytes.decode("utf-8") + task_ids = task_ids_str.split(",") + logger.info(f"Cancelling scheduled tasks for event {event_id}: {task_ids}") + revoked_count = 0 + for task_id in task_ids: + if task_id: # Ensure not empty string + try: + celery_app.control.revoke( + task_id.strip(), terminate=True, signal="SIGKILL" + ) + revoked_count += 1 + except Exception as revoke_err: + logger.error( + f"Error revoking task {task_id} for event {event_id}: {revoke_err}" + ) + # Delete the key from Redis after attempting revocation + celery_app.backend.delete(key) + logger.debug( + f"Revoked {revoked_count} tasks and removed task ID key {key} from backend for event {event_id}." + ) + else: + logger.debug( + f"No scheduled tasks found in backend to cancel for event {event_id} (key: {key})." + ) + except Exception as e: + logger.exception(f"Error cancelling notifications for event {event_id}: {e}") diff --git a/backend/modules/notifications/__init__.py b/backend/modules/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/notifications/__pycache__/__init__.cpython-312.pyc b/backend/modules/notifications/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..d33e830 Binary files /dev/null and b/backend/modules/notifications/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/modules/notifications/__pycache__/service.cpython-312.pyc b/backend/modules/notifications/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000..092672a Binary files /dev/null and b/backend/modules/notifications/__pycache__/service.cpython-312.pyc differ diff --git a/backend/modules/notifications/service.py b/backend/modules/notifications/service.py new file mode 100644 index 0000000..2540aab --- /dev/null +++ b/backend/modules/notifications/service.py @@ -0,0 +1,111 @@ +import httpx +import logging +from typing import Optional, Dict, Any + +from core.config import settings + +logger = logging.getLogger(__name__) + + +async def send_push_notification( + push_token: str, title: str, body: str, data: Optional[Dict[str, Any]] = None +) -> bool: + """ + Sends a push notification to a specific Expo push token. + + Args: + push_token: The recipient's Expo push token. + title: The title of the notification. + body: The main message content of the notification. + data: Optional dictionary containing extra data to send with the notification. + + Returns: + True if the notification was sent successfully (according to Expo API), False otherwise. + """ + if not push_token: + logger.warning("Attempted to send notification but no push token provided.") + return False + + message = { + "to": push_token, + "sound": "default", + "title": title, + "body": body, + "priority": "high", + "channelId": "default", + } + if data: + message["data"] = data + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + settings.EXPO_PUSH_API_URL, + headers={ + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate", + "Content-Type": "application/json", + }, + json=message, + timeout=10.0, + ) + response.raise_for_status() # Raise exception for 4xx/5xx responses + + response_data = response.json() + logger.debug(f"Expo push API response: {response_data}") + + # Check for top-level errors first + if "errors" in response_data: + error_messages = [ + err.get("message", "Unknown error") + for err in response_data["errors"] + ] + logger.error( + f"Expo API returned errors for {push_token[:10]}...: {'; '.join(error_messages)}" + ) + return False + + # Check the status in the data field + receipt = response_data.get("data") + + # if receipts is a list + if receipt: + status = receipt.get("status") + + if status == "ok": + logger.info( + f"Successfully sent push notification to token: {push_token[:10]}..." + ) + return True + else: + # Log details if the status is not 'ok' + error_details = receipt.get("details") + error_message = receipt.get("message") + logger.error( + f"Failed to send push notification to {push_token[:10]}... " + f"Expo status: {status}, Message: {error_message}, Details: {error_details}" + ) + return False + else: + # Log if 'data' is missing, not a list, or an empty list + logger.error( + f"Unexpected Expo API response format or empty 'data' field for {push_token[:10]}... " + f"Response: {response_data}" + ) + return False + + except httpx.HTTPStatusError as e: + logger.error( + f"HTTP error sending push notification to {push_token[:10]}...: {e.response.status_code} - {e.response.text}" + ) + return False + except httpx.RequestError as e: + logger.error( + f"Network error sending push notification to {push_token[:10]}...: {e}" + ) + return False + except Exception as e: + logger.exception( + f"Unexpected error sending push notification to {push_token[:10]}...: {e}" + ) + return False diff --git a/backend/modules/todo/models.py b/backend/modules/todo/models.py index f416e83..8a6bfd7 100644 --- a/backend/modules/todo/models.py +++ b/backend/modules/todo/models.py @@ -14,6 +14,4 @@ class Todo(Base): complete = Column(Boolean, default=False) owner_id = Column(Integer, ForeignKey("users.id")) - owner = relationship( - "User" - ) + owner = relationship("User") diff --git a/backend/modules/user/__pycache__/api.cpython-312.pyc b/backend/modules/user/__pycache__/api.cpython-312.pyc index ca97248..f2e94ab 100644 Binary files a/backend/modules/user/__pycache__/api.cpython-312.pyc and b/backend/modules/user/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/user/api.py b/backend/modules/user/api.py index 6f54cfa..bae8c7e 100644 --- a/backend/modules/user/api.py +++ b/backend/modules/user/api.py @@ -1,6 +1,7 @@ -from typing import Annotated -from fastapi import APIRouter, Depends +from typing import Annotated, Optional +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session +from pydantic import BaseModel from core.database import get_db from core.exceptions import not_found_exception, forbidden_exception @@ -11,6 +12,41 @@ from modules.auth.models import User router = APIRouter(prefix="/user", tags=["user"]) +# --- Pydantic Schema for Push Token --- # +class PushTokenData(BaseModel): + token: str + device_name: Optional[str] = None + token_type: str # Expecting 'expo' + + +@router.post("/push-token", status_code=status.HTTP_200_OK) +def save_push_token( + token_data: PushTokenData, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + """ + Save the Expo push token for the current user. + Requires user to be logged in. + """ + if token_data.token_type != "expo": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid token_type. Only 'expo' is supported.", + ) + + # Update the user's push token + current_user.expo_push_token = token_data.token + # Optionally, you could store device_name somewhere if needed, perhaps in a separate table + # For now, we just update the token on the user model + + db.add(current_user) + db.commit() + db.refresh(current_user) + + return {"message": "Push token saved successfully"} + + @router.get("/me", response_model=UserResponse) def me( db: Annotated[Session, Depends(get_db)], diff --git a/backend/requirements.in b/backend/requirements.in index 11d6226..54110da 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -14,4 +14,5 @@ python-multipart redis SQLAlchemy starlette -uvicorn \ No newline at end of file +uvicorn +eventlet diff --git a/backend/requirements.txt b/backend/requirements.txt index cf3a264..230c15a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -47,8 +47,12 @@ click-plugins==1.1.1 # via celery click-repl==0.3.0 # via celery +dnspython==2.7.0 + # via eventlet ecdsa==0.19.1 # via python-jose +eventlet==0.39.1 + # via -r requirements.in fastapi==0.115.12 # via -r requirements.in gevent==25.4.1 @@ -61,6 +65,7 @@ google-genai==1.11.0 # via -r requirements.in greenlet==3.2.0 # via + # eventlet # gevent # sqlalchemy h11==0.14.0 diff --git a/interfaces/nativeapp/.env b/interfaces/nativeapp/.env index 2f6627c..a680d26 100644 --- a/interfaces/nativeapp/.env +++ b/interfaces/nativeapp/.env @@ -1 +1,2 @@ -EXPO_PUBLIC_API_URL='http://localhost:8000/api' \ No newline at end of file +EXPO_PUBLIC_API_URL='http://192.168.21.221:8000/api' +EXPO_PROJECT_ID='au.com.seedeep.maia' \ No newline at end of file diff --git a/interfaces/nativeapp/App.tsx b/interfaces/nativeapp/App.tsx index 151bfe8..43d9a7e 100644 --- a/interfaces/nativeapp/App.tsx +++ b/interfaces/nativeapp/App.tsx @@ -1,5 +1,5 @@ // App.tsx -import React, { useCallback } from 'react'; // Removed useEffect, useState as they are implicitly used by useFonts +import React, { useCallback, useEffect } from 'react'; // Add useEffect import { Platform, View } from 'react-native'; import { Provider as PaperProvider } from 'react-native-paper'; import { NavigationContainer, DarkTheme as NavigationDarkTheme } from '@react-navigation/native'; // Import NavigationDarkTheme @@ -8,10 +8,14 @@ import { StatusBar } from 'expo-status-bar'; import * as SplashScreen from 'expo-splash-screen'; import { useFonts } from 'expo-font'; -import { AuthProvider } from './src/contexts/AuthContext'; +import { AuthProvider, useAuth } from './src/contexts/AuthContext'; // Import useAuth import RootNavigator from './src/navigation/RootNavigator'; -import theme from './src/constants/theme'; // This is the Paper theme -// Removed CombinedDarkTheme import as we'll use NavigationDarkTheme directly for NavigationContainer +import theme from './src/constants/theme'; +import { + registerForPushNotificationsAsync, + sendPushTokenToBackend, + setupNotificationHandlers +} from './src/services/notificationService'; // Import notification functions // Keep the splash screen visible while we fetch resourcesDone, please go ahead with the changes. SplashScreen.preventAutoHideAsync(); @@ -30,6 +34,43 @@ const navigationTheme = { }, }; +// Wrapper component to handle notification logic after auth state is known +function AppContent() { + const { user } = useAuth(); // Get user state + + useEffect(() => { + // Setup notification handlers (listeners) + const cleanupNotificationHandlers = setupNotificationHandlers(); + + // Register for push notifications only if user is logged in + const registerAndSendToken = async () => { + if (user) { // Only register if logged in + console.log('[App] User logged in, attempting to register for push notifications...'); + const token = await registerForPushNotificationsAsync(); + if (token) { + console.log('[App] Push token obtained, sending to backend...'); + await sendPushTokenToBackend(token); + } else { + console.log('[App] Could not get push token.'); + } + } else { + console.log('[App] User not logged in, skipping push notification registration.'); + // Optionally: If you need to clear the token on the backend when logged out, + // you might need a separate API call here or handle it server-side based on user activity. + } + }; + + registerAndSendToken(); + + // Cleanup listeners on component unmount + return () => { + cleanupNotificationHandlers(); + }; + }, [user]); // Re-run when user logs in or out + + return ; +} + export default function App() { const [fontsLoaded, fontError] = useFonts({ 'Inter-Regular': require('./src/assets/fonts/Inter-Regular.ttf'), @@ -63,7 +104,8 @@ export default function App() { {/* NavigationContainer uses the simplified navigationTheme */} - + {/* Use AppContent which contains RootNavigator and notification logic */} + + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} + +apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/debug.keystore b/interfaces/nativeapp/android/app/debug.keystore new file mode 100644 index 0000000..364e105 Binary files /dev/null and b/interfaces/nativeapp/android/app/debug.keystore differ diff --git a/interfaces/nativeapp/android/app/google-services.json b/interfaces/nativeapp/android/app/google-services.json new file mode 100644 index 0000000..562bfb9 --- /dev/null +++ b/interfaces/nativeapp/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "190108602323", + "project_id": "maia-4ddcf", + "storage_bucket": "maia-4ddcf.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:190108602323:android:dd073dd13774d87d64a926", + "android_client_info": { + "package_name": "au.com.seedeep.maia" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBrKtXnwNq_fX3B5ak3kKWFZ4V87-llsEo" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/proguard-rules.pro b/interfaces/nativeapp/android/app/proguard-rules.pro new file mode 100644 index 0000000..551eb41 --- /dev/null +++ b/interfaces/nativeapp/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/interfaces/nativeapp/android/app/src/debug/AndroidManifest.xml b/interfaces/nativeapp/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/interfaces/nativeapp/android/app/src/main/AndroidManifest.xml b/interfaces/nativeapp/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2bdb63d --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/src/main/java/au/com/seedeep/maia/MainActivity.kt b/interfaces/nativeapp/android/app/src/main/java/au/com/seedeep/maia/MainActivity.kt new file mode 100644 index 0000000..fc7ee08 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/java/au/com/seedeep/maia/MainActivity.kt @@ -0,0 +1,65 @@ +package au.com.seedeep.maia +import expo.modules.splashscreen.SplashScreenManager + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + // setTheme(R.style.AppTheme); + // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af + SplashScreenManager.registerOnActivity(this) + // @generated end expo-splashscreen + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/interfaces/nativeapp/android/app/src/main/java/au/com/seedeep/maia/MainApplication.kt b/interfaces/nativeapp/android/app/src/main/java/au/com/seedeep/maia/MainApplication.kt new file mode 100644 index 0000000..5471244 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/java/au/com/seedeep/maia/MainApplication.kt @@ -0,0 +1,57 @@ +package au.com.seedeep.maia + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.soloader.SoLoader + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List { + val packages = PackageList(this).packages + // Packages that cannot be autolinked yet can be added manually here, for example: + // packages.add(new MyReactNativePackage()); + return packages + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/interfaces/nativeapp/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/interfaces/nativeapp/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..31df827 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/interfaces/nativeapp/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/interfaces/nativeapp/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..ef243aa Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/interfaces/nativeapp/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/interfaces/nativeapp/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..e9d5474 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/interfaces/nativeapp/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/interfaces/nativeapp/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..d61da15 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/interfaces/nativeapp/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/interfaces/nativeapp/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..4aeed11 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/interfaces/nativeapp/android/app/src/main/res/drawable/ic_launcher_background.xml b/interfaces/nativeapp/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/src/main/res/drawable/rn_edit_text_material.xml b/interfaces/nativeapp/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/interfaces/nativeapp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/interfaces/nativeapp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..7fae0cc Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ac03dbf Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..afa0a4e Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..78aaf45 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..e1173a9 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c4f6e10 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..7a0f085 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ff086fd Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6c2d40b Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..730e3fa Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f7f1d06 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3452615 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..b11a322 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..49a464e Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b51fd15 Binary files /dev/null and b/interfaces/nativeapp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/interfaces/nativeapp/android/app/src/main/res/values-night/colors.xml b/interfaces/nativeapp/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..3c05de5 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/src/main/res/values/colors.xml b/interfaces/nativeapp/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f387b90 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #ffffff + #ffffff + #023c69 + #ffffff + \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/src/main/res/values/strings.xml b/interfaces/nativeapp/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2428f67 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + webapp + contain + false + \ No newline at end of file diff --git a/interfaces/nativeapp/android/app/src/main/res/values/styles.xml b/interfaces/nativeapp/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..6bc0170 --- /dev/null +++ b/interfaces/nativeapp/android/app/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/interfaces/nativeapp/android/build.gradle b/interfaces/nativeapp/android/build.gradle new file mode 100644 index 0000000..47ef030 --- /dev/null +++ b/interfaces/nativeapp/android/build.gradle @@ -0,0 +1,42 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext { + buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0' + minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') + compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35') + targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') + kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' + + ndkVersion = "26.1.10909125" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.google.gms:google-services:4.4.1' + classpath('com.android.tools.build:gradle') + classpath('com.facebook.react:react-native-gradle-plugin') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + } +} + +apply plugin: "com.facebook.react.rootproject" + +allprojects { + repositories { + maven { + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android')) + } + maven { + // Android JSC is installed from npm + url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist')) + } + + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} diff --git a/interfaces/nativeapp/android/gradle.properties b/interfaces/nativeapp/android/gradle.properties new file mode 100644 index 0000000..7531e9e --- /dev/null +++ b/interfaces/nativeapp/android/gradle.properties @@ -0,0 +1,56 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false diff --git a/interfaces/nativeapp/android/gradle/wrapper/gradle-wrapper.jar b/interfaces/nativeapp/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/interfaces/nativeapp/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/interfaces/nativeapp/android/gradle/wrapper/gradle-wrapper.properties b/interfaces/nativeapp/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..79eb9d0 --- /dev/null +++ b/interfaces/nativeapp/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/interfaces/nativeapp/android/gradlew b/interfaces/nativeapp/android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/interfaces/nativeapp/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/interfaces/nativeapp/android/gradlew.bat b/interfaces/nativeapp/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/interfaces/nativeapp/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/interfaces/nativeapp/android/settings.gradle b/interfaces/nativeapp/android/settings.gradle new file mode 100644 index 0000000..fc2c55a --- /dev/null +++ b/interfaces/nativeapp/android/settings.gradle @@ -0,0 +1,38 @@ +pluginManagement { + includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString()) +} +plugins { id("com.facebook.react.settings") } + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + def command = [ + 'node', + '--no-warnings', + '--eval', + 'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))', + 'react-native-config', + '--json', + '--platform', + 'android' + ].toList() + ex.autolinkLibrariesFromCommand(command) + } +} + +rootProject.name = 'webapp' + +dependencyResolutionManagement { + versionCatalogs { + reactAndroidLibs { + from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml"))) + } + } +} + +apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle"); +useExpoModules() + +include ':app' +includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile()) diff --git a/interfaces/nativeapp/app.json b/interfaces/nativeapp/app.json index 6c5c812..335b119 100644 --- a/interfaces/nativeapp/app.json +++ b/interfaces/nativeapp/app.json @@ -21,7 +21,8 @@ "backgroundColor": "#ffffff" }, "softwareKeyboardLayoutMode": "resize", - "package": "com.seedeep.maia" + "package": "au.com.seedeep.maia", + "googleServicesFile": "./google-services.json" }, "web": { "favicon": "./assets/favicon.png" diff --git a/interfaces/nativeapp/google-services.json b/interfaces/nativeapp/google-services.json new file mode 100644 index 0000000..562bfb9 --- /dev/null +++ b/interfaces/nativeapp/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "190108602323", + "project_id": "maia-4ddcf", + "storage_bucket": "maia-4ddcf.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:190108602323:android:dd073dd13774d87d64a926", + "android_client_info": { + "package_name": "au.com.seedeep.maia" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBrKtXnwNq_fX3B5ak3kKWFZ4V87-llsEo" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/interfaces/nativeapp/maia-firebase-private-key.json b/interfaces/nativeapp/maia-firebase-private-key.json new file mode 100644 index 0000000..a3cc2ca --- /dev/null +++ b/interfaces/nativeapp/maia-firebase-private-key.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "maia-4ddcf", + "private_key_id": "8ea1d5b1110f712c1ea863442a267e8b35b2aca7", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDkpd2/2cXAhhtg\n8ogpg6zp4LRQ4+YrHbnMRI4nccHxf8/YGgfi5hEs6OXDLT4bb9FbHMIsq8h6pJXe\nWnkdNaEaAqeebQ83pT7bQKsTDCx/YXenJ31rrwTzq4cjcBhwd04fIfH1bu7vd7ru\nJHFlsf7/Zb93yahfCV0yyP22FIeskIhqUutWY7RTpm6zUFlKs8QKIKiWVOTJiKvo\nNAcUK4BDmeDRKF/2wdFjgkXl7R6Ev9UzWf2+gE19RJY8ml25lGzG+fWLnnhx092x\naClGim3G0FRhQr5iyN++2Q82stWyRS7R85jRb8s/b3LT0knVrPrAAQasBHVcSSfp\n6MO4flp7AgMBAAECggEAGqyE9ZQ0vzSF7iXtH5a2beRidMtZdy81FTDsOorJWuCT\nwTysLdq0Jz6WS1I0XCQL0urEdkymCzS3LST12yP+AthLcK59Z3r2HcLqEkNJz6Rx\nvoTbW1wkIj8g+U/i8f/hE720ifLimfooSw7iUcBVpLrcft9+LnQbtMiA3KSBfW54\nmzYLWanXgBhKVMiGyR3FpnzDoLcO3xbItsLhaF/DlNN5FTvDCNQCQQwrlzkTTC+Q\npBf/Va+UMljIOYWaNfhgwzsiO86KpmKyWiVd+lhnrZfj/KEZjX+e8InSYd/D3dqn\nwnXY84pwRi2THCY0Hs2iDiX9uEnnq6fwh1I4B2xUIQKBgQD4msFXpl6+CU0iepmz\n2xpvo9AFX/VoQYoDz73DnCjcbFxldX6lWy8gEryQCPbB3Ir48xO+/OdVS2xCiSlx\nj+RqlIGf7oPHxEAJyJpiu93n/Zug/EJovjX5PxyD6Ye6ECr23yQfK20YRM/mdlJp\nm/0cZ7jEkXQLermDK1BAtUGd2wKBgQDrcyG47mjwZj9vG/Besl0VX+OTvlxrd2Gx\nAC7e27xkgNViSd8gZTGna0+Kp+sk6iP9h1KAqbFqpQejGPPvxtLkDuFbffjOWNoa\nKd9ERBxf0MEP2/dWiyusDDm+FvhSYAnKfHmtEQc+DMJ+5bNujDuRRcfrXxnmNEdt\n/WcpZ8bn4QKBgA8LXnPtb4JUkcRqYu7NbZYf9bC9k95RSQbeBX/W7WoZbKX/LEDZ\necqZF6wnvrcQn6BdJW7DY0R4If8MyeNDb/E7N3T0PClUqQNujlk3QUCOymI9oc8w\n45dHyHP7J+mMnOz/p/Hy8NEtKN+rfWVCuViEtlu+6aTgMmXLszmXPndNAoGAXh6Z\n/mkffeoBtZK/lbtLRn4cZTUVkMgaPz1Jf0DroGl342CQV0zceoaFN3JEp28JkBGG\nQ3SSPYVW9jXFXbZnG09verlyuln+ZbMTUyC/DvZOFt7hkrDzdkU01+4quhM2FsGH\nik1iTcWgAkYkYi6gqUPx1P8hRUrkuu0vTff0JUECgYBUf3Jhoh6XqLMMdnQvEj1Z\ndcrzdKFoSCB9sVuBqaEFu5sHQwc3HIodXGW1LT0eA7N0UAs4AZViNxCfMKCYoH13\nUIP2+EGy+a2RNkoezEANG0wwRa49yot8aDYQRNvKORIdkD10RIVORb0RJPldTpGP\nl9FKkEe5IAsEbwyn3pNmSQ==\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@maia-4ddcf.iam.gserviceaccount.com", + "client_id": "100360447602089015870", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40maia-4ddcf.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/interfaces/nativeapp/package-lock.json b/interfaces/nativeapp/package-lock.json index f262723..0c95621 100644 --- a/interfaces/nativeapp/package-lock.json +++ b/interfaces/nativeapp/package-lock.json @@ -19,7 +19,9 @@ "date-fns": "^4.1.0", "expo": "^52.0.46", "expo-dev-client": "~5.0.20", + "expo-device": "~7.0.3", "expo-font": "~13.0.4", + "expo-notifications": "~0.29.14", "expo-secure-store": "~14.0.1", "expo-splash-screen": "~0.29.24", "expo-status-bar": "~2.0.1", @@ -2682,6 +2684,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "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==" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3868,6 +3875,18 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "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==", + "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", @@ -3906,6 +3925,20 @@ "node": ">= 4.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.8.4", "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", @@ -4111,6 +4144,11 @@ "@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==" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4330,6 +4368,23 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4342,6 +4397,21 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caller-callsite": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", @@ -4882,6 +4952,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -4890,6 +4976,22 @@ "node": ">=8" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/del": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", @@ -5294,6 +5396,14 @@ } } }, + "node_modules/expo-application": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-6.0.2.tgz", + "integrity": "sha512-qcj6kGq3mc7x5yIb5KxESurFTJCoEKwNEL34RdPEvTB/xhl7SeVZlu05sZBqxB1V4Ryzq/LsCb7NHNfBbb3L7A==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.0.5.tgz", @@ -5371,6 +5481,42 @@ "expo": "*" } }, + "node_modules/expo-device": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-7.0.3.tgz", + "integrity": "sha512-uNGhDYmpDj/3GySWZmRiYSt52Phdim11p0pXfgpCq/nMks0+UPZwl3D0vin5N8/gpVe5yzb13GYuFxiVoDyniw==", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.40", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.40.tgz", + "integrity": "sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-file-system": { "version": "18.0.12", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz", @@ -5480,6 +5626,25 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-notifications": { + "version": "0.29.14", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.29.14.tgz", + "integrity": "sha512-AVduNx9mKOgcAqBfrXS1OHC9VAQZrDQLbVbcorMjPDGXW7m0Q5Q+BG6FYM/saVviF2eO8fhQRsTT40yYv5/bhQ==", + "dependencies": { + "@expo/image-utils": "^0.6.5", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~6.0.2", + "expo-constants": "~17.0.8" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-secure-store": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-14.0.1.tgz", @@ -5713,6 +5878,20 @@ "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5989,6 +6168,17 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6245,6 +6435,21 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6255,6 +6460,17 @@ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -6307,6 +6523,23 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6318,6 +6551,21 @@ "node": ">=0.10.0" } }, + "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==", + "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-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6361,6 +6609,23 @@ "node": ">=0.10.0" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -6369,6 +6634,20 @@ "node": ">=0.10.0" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -7913,6 +8192,48 @@ "node": ">=0.10.0" } }, + "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==", + "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", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -8352,6 +8673,14 @@ "node": ">=4.0.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -9298,6 +9627,22 @@ } ] }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -9487,6 +9832,22 @@ "node": ">= 0.8" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -10426,6 +10787,18 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -10546,6 +10919,26 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wonka": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", diff --git a/interfaces/nativeapp/package.json b/interfaces/nativeapp/package.json index 4c66651..caf273d 100644 --- a/interfaces/nativeapp/package.json +++ b/interfaces/nativeapp/package.json @@ -4,8 +4,8 @@ "main": "index.ts", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web" }, "dependencies": { @@ -34,7 +34,9 @@ "react-native-screens": "~4.4.0", "react-native-vector-icons": "^10.2.0", "react-native-web": "~0.19.13", - "expo-dev-client": "~5.0.20" + "expo-dev-client": "~5.0.20", + "expo-notifications": "~0.29.14", + "expo-device": "~7.0.3" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/interfaces/nativeapp/src/api/client.ts b/interfaces/nativeapp/src/api/client.ts index 1b0ce6b..1e83507 100644 --- a/interfaces/nativeapp/src/api/client.ts +++ b/interfaces/nativeapp/src/api/client.ts @@ -9,8 +9,6 @@ const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://maia.depaoli.id const ACCESS_TOKEN_KEY = 'maia_access_token'; const REFRESH_TOKEN_KEY = 'maia_refresh_token'; -console.log("Using API Base URL:", API_BASE_URL); - // Helper functions for storage const storeToken = async (key: string, token: string): Promise => { if (Platform.OS === 'web') { @@ -163,6 +161,7 @@ apiClient.interceptors.response.use( } // End of 401 handling } else if (error.request) { + console.log("Using API Base URL:", API_BASE_URL); console.error('[API Client] Network Error or No Response:', error.message); if (error.message.toLowerCase().includes('network error') && Platform.OS === 'web') { console.warn('[API Client] Hint: A "Network Error" on web often masks a CORS issue. Check browser console & backend CORS config.'); diff --git a/interfaces/nativeapp/src/screens/AdminScreen.tsx b/interfaces/nativeapp/src/screens/AdminScreen.tsx index 06d0e87..882874c 100644 --- a/interfaces/nativeapp/src/screens/AdminScreen.tsx +++ b/interfaces/nativeapp/src/screens/AdminScreen.tsx @@ -1,52 +1,99 @@ import React, { useState } from 'react'; import { View, StyleSheet } from 'react-native'; -import { Button, Checkbox, Text, ActivityIndicator, Snackbar } from 'react-native-paper'; +import { Button, Checkbox, Text, ActivityIndicator, Snackbar, TextInput, Divider, useTheme } from 'react-native-paper'; // Added TextInput, Divider, useTheme import { clearDatabase } from '../api/admin'; -// Remove useNavigation import if no longer needed elsewhere in this file -// import { useNavigation } from '@react-navigation/native'; -import { useAuth } from '../contexts/AuthContext'; // Import useAuth +import apiClient from '../api/client'; // Import apiClient +import { useAuth } from '../contexts/AuthContext'; const AdminScreen = () => { - const [isHardClear, setIsHardClear] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [snackbarVisible, setSnackbarVisible] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(''); - // const navigation = useNavigation(); // Remove if not used elsewhere - const { logout } = useAuth(); // Get the logout function from context + const theme = useTheme(); // Get theme for styling if needed + // --- State for Clear DB --- + const [isHardClear, setIsHardClear] = useState(false); + const [isClearingDb, setIsClearingDb] = useState(false); // Renamed from isLoading + const [clearDbSnackbarVisible, setClearDbSnackbarVisible] = useState(false); // Renamed + const [clearDbSnackbarMessage, setClearDbSnackbarMessage] = useState(''); // Renamed + + // --- State for Send Notification --- + const [username, setUsername] = useState(''); + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [isSendingNotification, setIsSendingNotification] = useState(false); // New loading state + const [notificationError, setNotificationError] = useState(null); // New error state + const [notificationSuccess, setNotificationSuccess] = useState(null); // New success state + + const { logout } = useAuth(); + + // --- Clear DB Handler --- const handleClearDb = async () => { - setIsLoading(true); - setSnackbarVisible(false); + setIsClearingDb(true); // Use renamed state + setClearDbSnackbarVisible(false); try { const response = await clearDatabase(isHardClear); - setSnackbarMessage(response.message || 'Database cleared successfully.'); - setSnackbarVisible(true); + setClearDbSnackbarMessage(response.message || 'Database cleared successfully.'); + setClearDbSnackbarVisible(true); - // If hard clear was successful, trigger the logout process from AuthContext if (isHardClear) { console.log('Hard clear successful, calling logout...'); - await logout(); // Call the logout function from AuthContext - // The RootNavigator will automatically switch to the AuthFlow - // No need to manually navigate or set loading to false here - return; // Exit early + await logout(); + return; } } catch (error: any) { console.error("Error clearing database:", error); - setSnackbarMessage(error.response?.data?.detail || 'Failed to clear database.'); - setSnackbarVisible(true); + setClearDbSnackbarMessage(error.response?.data?.detail || 'Failed to clear database.'); + setClearDbSnackbarVisible(true); } finally { - // Only set loading to false if it wasn't a hard clear (as logout handles navigation) if (!isHardClear) { - setIsLoading(false); + setIsClearingDb(false); // Use renamed state } } }; + // --- Send Notification Handler --- + const handleSendNotification = async () => { + if (!username || !title || !body) { + setNotificationError('Username, Title, and Body are required.'); + setNotificationSuccess(null); + return; + } + + setIsSendingNotification(true); + setNotificationError(null); + setNotificationSuccess(null); + + try { + const response = await apiClient.post('/admin/send-notification', { + username, + title, + body, + // data: {} // Add optional data payload if needed + }); + + if (response.status === 200) { + setNotificationSuccess(response.data.message || 'Notification sent successfully!'); + // Clear fields after success + setUsername(''); + setTitle(''); + setBody(''); + } else { + setNotificationError(response.data?.detail || 'Failed to send notification.'); + } + } catch (err: any) { + console.error("Error sending notification:", err.response?.data || err.message); + setNotificationError(err.response?.data?.detail || 'An error occurred while sending the notification.'); + } finally { + setIsSendingNotification(false); + } + }; + + return ( Admin Controls + {/* --- Clear Database Section --- */} + Clear Database { /> setIsHardClear(!isHardClear)}>Hard Clear (Delete all data) - - setSnackbarVisible(false)} + visible={clearDbSnackbarVisible} // Use renamed state + onDismiss={() => setClearDbSnackbarVisible(false)} duration={Snackbar.DURATION_SHORT} > - {snackbarMessage} + {clearDbSnackbarMessage} {/* Use renamed state */} + + + + {/* --- Send Notification Section --- */} + Send Push Notification + + {notificationError && {notificationError}} + {notificationSuccess && {notificationSuccess}} + + + + + + ); }; @@ -80,19 +171,37 @@ const styles = StyleSheet.create({ container: { flex: 1, padding: 20, - justifyContent: 'center', - alignItems: 'center', + // Removed justifyContent and alignItems to allow scrolling if content overflows }, title: { - marginBottom: 30, + marginBottom: 20, // Reduced margin + textAlign: 'center', + }, + sectionTitle: { + marginBottom: 15, + marginTop: 10, // Add some space before the title + textAlign: 'center', }, checkboxContainer: { flexDirection: 'row', alignItems: 'center', - marginBottom: 20, + marginBottom: 10, // Reduced margin + justifyContent: 'center', // Center checkbox }, button: { marginTop: 10, + marginBottom: 10, // Add margin below button + }, + input: { + marginBottom: 15, + }, + message: { + marginBottom: 15, + textAlign: 'center', + fontWeight: 'bold', + }, + divider: { + marginVertical: 30, // Add vertical space around the divider }, }); diff --git a/interfaces/nativeapp/src/screens/EventFormScreen.tsx b/interfaces/nativeapp/src/screens/EventFormScreen.tsx index 9bafc01..4520b0a 100644 --- a/interfaces/nativeapp/src/screens/EventFormScreen.tsx +++ b/interfaces/nativeapp/src/screens/EventFormScreen.tsx @@ -143,13 +143,6 @@ const EventFormScreen = () => { const handleStartDateConfirm = (date: Date) => { setStartDate(date); setWebStartDateInput(formatForWebInput(date)); // Update web input state - // Optional: Auto-set end date if it's before start date or null - if (!endDate || endDate < date) { - const newEndDate = new Date(date); - newEndDate.setHours(date.getHours() + 1); // Default to 1 hour later - setEndDate(newEndDate); - setWebEndDateInput(formatForWebInput(newEndDate)); // Update web input state - } validateForm({ start: date }); // Validate after setting hideStartDatePicker(); }; @@ -189,13 +182,6 @@ const EventFormScreen = () => { if (isValid(parsedDate) && text.length >= 15) { // Basic length check for 'yyyy-MM-dd HH:mm' if (type === 'start') { setStartDate(parsedDate); - // Optional: Auto-set end date - if (!endDate || endDate < parsedDate) { - const newEndDate = new Date(parsedDate); - newEndDate.setHours(parsedDate.getHours() + 1); - setEndDate(newEndDate); - setWebEndDateInput(formatForWebInput(newEndDate)); // Update other web input too - } validateForm({ start: parsedDate }); // Validate with the actual Date } else { setEndDate(parsedDate); diff --git a/interfaces/nativeapp/src/services/notificationService.ts b/interfaces/nativeapp/src/services/notificationService.ts new file mode 100644 index 0000000..b0c1114 --- /dev/null +++ b/interfaces/nativeapp/src/services/notificationService.ts @@ -0,0 +1,149 @@ +import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; +import apiClient from '../api/client'; +import Constants from 'expo-constants'; + +// Define the structure of the push token data expected by the backend +interface PushTokenData { + token: string; + device_name?: string; + token_type: 'expo'; // Indicate the type of token +} + +// --- Android Notification Channel Setup --- +async function setupNotificationChannelsAndroid() { + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'Default', + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#FF231F7C', + }); + console.log('[Notifications] Default Android channel set up.'); + } +} + +// --- Request Permissions and Get Token --- +export async function registerForPushNotificationsAsync(): Promise { + if (Platform.OS !== 'android' && Platform.OS !== 'ios') { + console.warn('[Notifications] Push notifications are only supported on Android and iOS.'); + return null; + } + let token: string | null = null; + + if (!Device.isDevice) { + console.warn('[Notifications] Push notifications require a physical device.'); + alert('Must use physical device for Push Notifications'); + return null; + } + + // 1. Setup Android Channels + await setupNotificationChannelsAndroid(); + + // 2. Request Permissions + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== 'granted') { + console.log('[Notifications] Requesting notification permissions...'); + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== 'granted') { + console.warn('[Notifications] Failed to get push token: Permission not granted.'); + alert('Failed to get push token for push notification!'); + return null; + } + + // 3. Get Expo Push Token + try { + // Use the default experience ID + const projectId = process.env.EXPO_PROJECT_ID || Constants.expoConfig?.extra?.eas?.projectId; + if (!projectId) { + console.error('[Notifications] EAS project ID not found in app config. Cannot get push token.'); + alert('Configuration error: Project ID missing. Cannot get push token.'); + return null; + } + console.log(`[Notifications] Getting Expo push token with projectId: ${projectId}`); + const expoPushToken = await Notifications.getExpoPushTokenAsync({ projectId }); + token = expoPushToken.data; + console.log('[Notifications] Received Expo Push Token:', token); + } catch (error) { + console.error('[Notifications] Error getting Expo push token:', error); + alert(`Error getting push token: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + + return token; +} + +// --- Send Token to Backend --- +export async function sendPushTokenToBackend(expoPushToken: string): Promise { + if (!expoPushToken) { + console.warn('[Notifications] No push token provided to send to backend.'); + return false; + } + + const tokenData: PushTokenData = { + token: expoPushToken, + device_name: Device.deviceName ?? undefined, + token_type: 'expo', + }; + + try { + console.log('[Notifications] Sending push token to backend:', tokenData); + const response = await apiClient.post('/user/push-token', tokenData); + + if (response.status === 200 || response.status === 201) { + console.log('[Notifications] Push token successfully sent to backend.'); + return true; + } else { + console.warn(`[Notifications] Backend returned status ${response.status} when sending push token.`); + return false; + } + } catch (error: any) { + console.error('[Notifications] Error sending push token to backend:', error.response?.data || error.message); + return false; + } +} + +// --- Notification Handling Setup --- +export function setupNotificationHandlers() { + // Handle notifications that arrive while the app is foregrounded + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); + + // Handle user interaction with notifications (tapping) when app is foregrounded/backgrounded + const foregroundInteractionSubscription = Notifications.addNotificationResponseReceivedListener(response => { + console.log('[Notifications] User interacted with notification (foreground/background):', response.notification.request.content); + + // const data = response.notification.request.content.data; + // if (data?.screen) { + // navigation.navigate(data.screen); + // } + }); + + // Handle user interaction with notifications (tapping) when app was killed/not running + // This requires careful setup, potentially using Linking or initial URL handling + // Notifications.getLastNotificationResponseAsync().then(response => { + // if (response) { + // console.log('[Notifications] User opened app via notification (killed state):', response.notification.request.content); + // // Handle navigation or action based on response.notification.request.content.data + // } + // }); + + + console.log('[Notifications] Notification handlers set up.'); + + // Return cleanup function for useEffect + return () => { + console.log('[Notifications] Removing notification listeners.'); + Notifications.removeNotificationSubscription(foregroundInteractionSubscription); + }; +}