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);
+ };
+}