Sprint2 3. Architectural Design - ISIS3510-Group-27/Documentation GitHub Wiki

3. Architectural design

For the Flutter frontend in particular

Scope: the Flutter app (SENECApp-Frontend-Flutter) and the backend it shares with the Kotlin app (SENECApp-Backend).


3a. Architecture: overall structure and components

System overview

flowchart TB
    student(["Student"])
    organizer(["Organizer<br/>(group admin)"])
    admin(["Platform admin<br/>(Student Affairs)"])

    subgraph phone["Android phone"]
        app["SENECApp Flutter app"]
        sensors["GPS · Camera"]
    end

    subgraph firebase["Firebase (Google)"]
        fauth["Firebase Authentication"]
        fcm["Firebase Cloud Messaging"]
    end

    subgraph server["Backend host (Docker Compose)"]
        api["SENECApp API<br/>FastAPI + background jobs"]
        db[("PostgreSQL 16")]
    end

    kotlin["Kotlin app<br/>(same API)"]

    student --> app
    organizer --> app
    app --> sensors
    app -- "email/password sign-in,<br/>ID token" --> fauth
    app -- "REST/JSON over HTTP(S)<br/>Bearer ID token" --> api
    kotlin -.-> api
    api -- "verify token signature<br/>(Google public keys)" --> fauth
    api -- "send pushes" --> fcm
    fcm -- "push notifications" --> app
    api --> db
    admin -- "dashboard, group review" --> api
Loading

Rationale

  • Client-server, one backend for two apps. All business rules live in the API: ranking, check-in validation, join rules, review, analytics. The Flutter and Kotlin apps can't drift apart, and a rule changes in one place. Each app is a presentation and device layer.
  • Firebase for what we shouldn't build. Identity (password storage, email verification) and push delivery are delegated to Firebase. The backend only verifies ID tokens against Google's public keys. It never sees passwords, and needs no Firebase secret for sign-in.
  • Sensors stay on the phone. GPS and camera are read in the app. Only what the backend needs is sent: a scanned code, and coordinates only with consent. The backend stores the nearest building or a distance, never a trail of positions.
  • Docker Compose runs the API and database identically on every team member's laptop. Migrations and seed data are applied on start.

Flutter app: layers and components

flowchart TB
    subgraph features["Presentation: lib/features/"]
        direction LR
        f1["auth<br/>sign-in, verify email"]
        f2["discover · recommendations<br/>filters sheet"]
        f3["rso_detail<br/>join form"]
        f4["events · event_detail<br/>free_now · check_in"]
        f5["my_rsos · profile<br/>schedule · notifications<br/>create_rso"]
        f6["shell<br/>HomeShell (tab navigators)<br/>TrackScreen"]
    end

    subgraph state["State: lib/state/ (Provider + ChangeNotifier)"]
        direction LR
        s1["SessionController<br/>who is signed in"]
        s2["AppState<br/>memberships, saves,<br/>inbox, attendance"]
        s3["PushRegistration"]
    end

    subgraph data["Data: lib/data/"]
        direction LR
        repos["Repositories<br/>Me · Groups · Events ·<br/>Notifications · Catalog ·<br/>Recommendations"]
        apiclient["ApiClient<br/>+ ClientContext"]
        analytics["Analytics<br/>(queue)"]
        ports["Device services<br/>AuthService · LocationService ·<br/>PushService · QrCamera"]
        models["Models<br/>Rso, CampusEvent, ..."]
    end

    subgraph platform["Platform plugins"]
        direction LR
        p1["http"]
        p2["firebase_auth<br/>firebase_messaging"]
        p3["geolocator<br/>mobile_scanner"]
        p4["shared_preferences<br/>device_info_plus"]
    end

    root["AppServices<br/>(composition root, main.dart)"]

    features --> state
    features --> repos
    features --> analytics
    state --> repos
    repos --> apiclient
    repos --> models
    analytics --> apiclient
    apiclient --> p1
    ports --> p2
    ports --> p3
    analytics --> p4
    root -. "creates and injects" .-> data
Loading

Rationale

  • Three layers, one direction. Screens never call HTTP or plugins directly. They go through state objects and repositories, which go through ApiClient. Changing an endpoint touches one repository; changing a screen touches no data code.
  • Device capabilities sit behind interfaces (AuthService, LocationService, PushService, QrCameraBuilder). The real implementations wrap Firebase, geolocator and mobile_scanner. Development and test implementations swap in without touching screens, which is what lets 111 widget tests run without a phone, a camera or a network.
  • Shared state only where needed. SessionController decides which screens exist: signed out, verifying email, loading, or in the app. AppState holds what several tabs show (memberships, saves, inbox), so a join on one screen is reflected everywhere. Lists that belong to one screen stay in that screen's own state.
  • One composition root. AppServices builds every long-lived object once at launch, choosing dev or Firebase sign-in and real or disabled push from build settings (--dart-define). Tests build their own with fakes.

Backend: layers and components

flowchart TB
    subgraph api["HTTP layer: app/api/"]
        routes["Routers<br/>me · groups · events · recommendations ·<br/>notifications · catalog · analytics · admin"]
        deps["Dependencies<br/>current user (token verification),<br/>client context (X-* headers), DB session"]
    end

    subgraph domain["Service layer: app/services/"]
        s_groups["groups · group_review"]
        s_events["events (check-in)"]
        s_rec["recommendations<br/>(free-now, group ranking)"]
        s_notif["notifications · push"]
        s_reeng["reengagement"]
        s_analytics["analytics (track, ingest)"]
    end

    subgraph core["Domain logic and support"]
        reco["app/recommendations/<br/>features, scoring, training<br/>(pure Python)"]
        bqs["app/analytics/questions.py<br/>BQ1-BQ14"]
        auth["app/auth/verifiers<br/>Firebase | Dev"]
        jobs["app/jobs/<br/>registry + APScheduler"]
    end

    subgraph persistence["Persistence"]
        orm["app/models/ (SQLAlchemy 2)<br/>+ Alembic migrations"]
        pg[("PostgreSQL")]
    end

    routes --> deps
    routes --> domain
    deps --> auth
    s_rec --> reco
    routes --> bqs
    jobs --> s_reeng
    jobs --> s_rec
    domain --> orm --> pg
    bqs --> pg
    s_notif -- "FCM (firebase-admin)" --> fcmx["Firebase Cloud Messaging"]
Loading

Rationale

  • Thin routers, thick services. Routers only parse input and shape output. Rules live in services, so a background job (e.g. re-engagement) reuses exactly the same logic as an HTTP request.
  • Cross-cutting concerns as dependencies. FastAPI dependencies resolve, once per request, who the caller is, whether they're an admin, the client context, and the DB session. No endpoint can forget to check authentication.
  • The recommender is pure Python (no DB access), so its features, scoring and training are unit-tested in isolation. The nightly job only feeds it rows and stores the weights it returns.
  • Analytics questions are isolated in one module of SQL queries, read-only on the same database. A new business question is one new function.

Deployment

flowchart LR
    subgraph laptop["Developer laptop"]
        subgraph compose["docker compose"]
            apic["senecapp-api :8000<br/>migrate -> seed -> uvicorn + scheduler"]
            dbc[("senecapp-db :5433<br/>volume pgdata")]
            sec["./secrets (read-only mount)<br/>FCM service account"]
        end
        emu["Android emulator<br/>reaches the laptop at 10.0.2.2"]
    end
    emu -- "http://10.0.2.2:8000/api/v1" --> apic
    apic --> dbc
    sec -.-> apic
    emu -- "HTTPS" --> fb["Firebase"]
    apic -- "HTTPS" --> fb
Loading

Rationale

  • Same setup for the whole team. The API container applies migrations and an idempotent seed on start, so every laptop has the same groups and the same ~12 weeks of simulated usage behind the business questions.
  • Secrets stay out of the repo. The FCM service-account key lives in a git-ignored folder mounted read-only. Settings (.env) switch between dev tokens and Firebase without code changes.
  • Plain HTTP only where safe. Debug builds of the app allow plain HTTP to reach the local backend; release builds require HTTPS.

3b. Architecture design: how the components interact

Every API request

sequenceDiagram
    participant S as Screen
    participant R as Repository
    participant C as ApiClient
    participant A as AuthService
    participant API as FastAPI deps
    participant SV as Service
    participant DB as PostgreSQL

    S->>R: e.g. groups.search(filters)
    R->>C: get('/groups', query)
    C->>A: idToken()
    A-->>C: Firebase ID token (refreshed hourly)
    C->>API: GET /groups + Bearer token + X-Session-Id, X-App, X-App-Version, X-Platform, X-Device-Model, X-OS-Version
    API->>API: verify token, allowed domain, verified email
    API->>SV: search(user, filters, client context)
    SV->>DB: query + track('group_searched') in one transaction
    DB-->>SV: rows
    SV-->>C: 200 JSON
    C-->>R: decoded JSON
    R-->>S: List<Rso>
    Note over C,API: On 401 the client retries once with a fresh token,<br/>then signs the student out.<br/>Network errors and timeouts become a readable ApiException.
Loading

Rationale. One path for all traffic keeps authentication, context headers, timeouts and error messages consistent. Analytics come for free: because the server records events inside the request's own transaction, an event can't be recorded for an action that failed.

Sign-in and session start

sequenceDiagram
    participant U as Student
    participant App as SessionController
    participant FA as Firebase Auth
    participant API as Backend
    participant P as PushRegistration

    U->>App: email + password
    App->>FA: signInWithEmailAndPassword
    FA-->>App: account (emailVerified?)
    alt not verified
        App-->>U: "Check your inbox"<br/>(resend / I've verified -> reload + force-refresh token)
    else verified
        App->>API: GET /me (Bearer ID token)
        API->>API: verify signature against Google's public keys
        API-->>App: StudentProfile (created on first call)
        App-->>U: HomeShell, with AppState built for this student
        App->>P: register() in background
        P->>API: POST /me/devices {FCM token}
    end
    Note over App,API: Sign-out: flush the analytics queue, unregister the device, then Firebase sign-out,<br/>all while the token is still valid.
Loading

Rationale. The session is a small state machine (starting -> signedOut / unverified -> loadingProfile -> signedIn / profileFailed). Each state maps to exactly one screen, so there's no half-signed-in UI. The order of the sign-out steps ensures the next person using the phone doesn't inherit the previous student's events or pushes.

Explore, recommendations and joining (BQ2, BQ6, BQ7, BQ12, BQ13)

sequenceDiagram
    participant U as Student
    participant D as Discover
    participant API as Backend
    participant Q as Analytics queue

    D->>API: GET /recommendations/groups (once per visit)
    API-->>D: request_id + groups + reasons (impressions logged)
    U->>D: types "tennis" (400 ms debounce)
    D->>API: GET /groups?q=tennis
    Note right of API: group_searched (filters used)
    U->>D: opens a recommended group
    D->>API: GET /groups/3?entry_point=recommendation&rec_request_id=...
    Note right of API: group_viewed
    U->>D: Join RSO
    D->>Q: join_form_opened {group_id, join_attempt_id}
    U->>D: Join (optional note)
    D->>API: POST /groups/3/join {entry_point, rec_request_id, join_attempt_id, motivation}
    Note right of API: group_joined + membership.entry_point
Loading

Rationale. Every step carries the IDs that tie it to the previous one: rec_request_id from recommendation to view to join, and join_attempt_id from form to join. The business questions can then follow a student's path instead of guessing from timestamps. Recommendations are fetched once per visit because each fetch is logged as impressions; re-fetching on every rebuild would inflate BQ2's denominator.

"Free right now" (context-aware, BQ3)

sequenceDiagram
    participant U as Student
    participant F as FreeNowScreen
    participant L as LocationService (GPS)
    participant API as Backend

    alt first visit, no consent yet
        F-->>U: "Use your location?" (only the nearest building is stored)
        U->>F: Use my location
        F->>API: PATCH /me {location_opt_in: true}
    end
    F->>L: current() (OS permission prompt, 10 s limit)
    L-->>F: lat/lng, or why not (denied, off, no fix)
    F->>API: GET /recommendations/events/free-now?latitude=&longitude=
    API->>API: current/next free block from the class schedule,<br/>location: GPS (with consent) or building of last/next class,<br/>rank: interests, walking distance, how soon it starts
    API-->>F: free block, location source, suggestions + reasons, request_id
    U->>F: opens a suggestion
    F->>API: GET /events/{id}?entry_point=free_now&rec_request_id=...
Loading

Rationale. Context comes from three sources: the class schedule, the time of day, and the location. Each one degrades on its own. No GPS falls back to the class building; no schedule assumes a two-hour free block. The feature keeps working when the student refuses location. Consent is stored on the server, so the backend ignores coordinates from students who haven't opted in, even if an app sends them.

QR check-in (sensors: camera + GPS, BQ10)

sequenceDiagram
    participant O as Organizer's app
    participant A as Attendee's app
    participant Cam as Camera (mobile_scanner)
    participant L as GPS
    participant API as Backend

    O->>API: GET /events/{id}/check-in-code (group admins only)
    API-->>O: senecapp://check-in?event_id=..&code=..
    O-->>A: QR shown at the venue
    A->>Cam: scan
    Cam-->>A: payload, parsed and checked against the event
    A->>L: current() (only with location consent)
    A->>API: POST /events/{id}/check-in {code, latitude?, longitude?}
    API->>API: code matches, window (30 min before to 15 min after),<br/>within 500 m of the venue, not cancelled
    API-->>A: checked in / already checked in / reason refused
Loading

Rationale. The camera proves the student saw the code at the venue; GPS adds distance as a second factor when the student allows it. All validation is on the server, so the app can't be tricked into accepting a check-in, and "already checked in" makes repeated scans harmless. Attendance rows feed BQ10's weekly participation.

Notifications and push (BQ8)

sequenceDiagram
    participant API as Backend
    participant FCM as Firebase Cloud Messaging
    participant Shell as HomeShell
    participant St as AppState

    API->>API: store notification (inbox)
    API->>FCM: push {title, body, data: notification_id, type, group_id, event_id}
    alt app in background or closed
        FCM-->>Shell: student taps the push
        Shell->>St: openPushed(id) → POST /me/notifications/{id}/open
        Shell->>Shell: open the event (or group), entry_point=notification
    else app open
        FCM-->>Shell: arrival
        Shell->>St: refresh inbox
        Shell-->>Shell: banner with "Open"
    end
    Note over St,API: In the inbox: tap = open, swipe or "Mark all read" = dismiss
Loading

Rationale. Every notification is first stored as an inbox entry, so a student without push (no permission, no Firebase) loses nothing. Opening and dismissing are recorded as separate actions, which is exactly the comparison BQ8 makes. "Mark all read" dismisses rather than opens, so clearing the inbox isn't counted as engagement.

Group proposals and review

stateDiagram-v2
    [*] --> pending: Create RSO (POST /groups)<br/>creator becomes admin
    pending --> approved: admin approves<br/>creator notified, matching students notified
    pending --> rejected: admin rejects with reason<br/>creator notified
    approved --> [*]
    rejected --> [*]
    note right of pending
        visible only to its creator
        no joins, no events,
        not in search or recommendations
    end note
Loading

Rationale. Student Affairs reviews proposals before they become public. While pending, the group is filtered out of every list other students see, at the query level, so no screen can leak it. The app shows the creator its status in My RSOs and on the group page.


3c. Design patterns and tactics

Design patterns

# Pattern Where What it solves
P1 Repository Flutter lib/data/repositories/ (Groups, Events, Me, Notifications, Catalog, Recommendations) Screens ask for domain objects (Rso, CampusEvent) and never see URLs or JSON; one place per endpoint group.
P2 Facade Flutter ApiClient One simple get/post/put/patch/delete hiding tokens, the six context headers, retries, timeouts and error parsing.
P3 Strategy Flutter AuthService (Dev / Firebase), LocationService, PushService (Firebase / Disabled), QrCameraBuilder; backend TokenVerifier (Firebase / Dev), PushSender (FCM / Disabled) The algorithm or provider is chosen at start-up from configuration; callers depend on the interface only.
P4 Observer (Provider + ChangeNotifier) Flutter SessionController, AppState, TabSelection Screens rebuild when shared state changes; a join on one tab updates the others.
P5 Dependency injection / composition root Flutter AppServices + Provider; backend FastAPI Depends Objects get their collaborators instead of creating them, which makes every layer testable with fakes.
P6 State machine Flutter SessionStatus; backend group review_status Only valid states and transitions exist, each with exactly one screen or behavior.
P7 Layered architecture Both: presentation -> state -> data (app); routers -> services -> models (backend) Changes stay local; dependencies point one way.
P8 Data mapper / DTO Flutter fromJson factories; backend Pydantic schemas The wire format is converted at the edge, so the rest of the code uses typed models.
P9 Composite (reusable widget components) Flutter lib/core/widgets/ (AppCard, StatTile, SelectableChip, badges, RsoListTile, OrgImage) Screens are trees of small shared widgets, so a card or chip looks and behaves the same everywhere and changes in one place.
classDiagram
    direction LR
    class AuthService {
        <<interface>>
        +restore()
        +signIn(email, password)
        +register(email, password)
        +idToken(forceRefresh)
        +signOut()
    }
    class DevAuthService
    class FirebaseAuthService
    AuthService <|.. DevAuthService
    AuthService <|.. FirebaseAuthService

    class PushService {
        <<interface>>
        +token()
        +taps
        +arrivals
    }
    class FirebasePushService
    class DisabledPushService
    PushService <|.. FirebasePushService
    PushService <|.. DisabledPushService

    class ApiClient {
        +get() post() put() patch() delete()
        -retry once on 401
    }
    class GroupsRepository {
        +search(filters)
        +detail(id, entryPoint)
        +join(id, entryPoint, joinAttemptId)
    }
    class AppServices {
        <<composition root>>
    }

    ApiClient --> AuthService : token
    GroupsRepository --> ApiClient
    AppServices o-- AuthService
    AppServices o-- PushService
    AppServices o-- ApiClient
    AppServices o-- GroupsRepository
Loading

Rationale. The class diagram shows how the main patterns fit together:

  • Strategy: the AuthService and PushService interfaces each have a real and an alternative implementation.
  • Facade: ApiClient is the only class that talks HTTP, and it gets its tokens from whichever AuthService was chosen.
  • Repository: GroupsRepository and its siblings sit on top of ApiClient.
  • Composition root: AppServices wires the concrete choices together once.

Switching from dev sign-in to Firebase, or disabling push, is one configuration flag and no code changes anywhere else.

Architectural tactics

# Quality attribute Tactic Where
T1 Availability Offline queue with retry: analytics saved on the phone, sent in batches, retried later Analytics (queue, 30 s flush, max 500 per batch)
T2 Availability Retry with fresh credentials: on 401, mint a new token and retry once ApiClient
T3 Availability Graceful degradation: popular groups when the recommender fails; class building when GPS is unavailable; inbox when push is off Discover, free-now, notifications
T4 Availability Timeouts: every request (15 s), GPS fix (10 s), and the sign-out steps (5 s) are bounded ApiClient, LocationService, SessionController
T5 Performance Debouncing: search waits 400 ms after typing stops Discover
T6 Performance Caching: interests and buildings fetched once per launch; shared lists kept in AppState CatalogRepository, AppState
T7 Performance Batching: analytics sent in batches, not one request per event Analytics
T8 Usability / perceived performance Optimistic updates with rollback: the save heart and inbox actions change instantly and revert if the backend refuses AppState
T9 Security Authenticate every request: server verifies Firebase token signatures; university domain and verified email required; role checks for admins and organizers backend deps, verifiers
T10 Security / privacy Consent and data minimization: location only after opt-in, nearest building stored, no personal data in analytics, HTTPS outside debug builds app + backend
T11 Integrity Idempotency: events de-duplicated by event_id; join, save and check-in safe to repeat backend services
T12 Integrity Stale-response guard: only the latest search may update the list Discover
T13 Testability Fakes behind interfaces: fake backend, GPS, camera and push; 111 automated tests Flutter test/support
T14 Modifiability Build-time configuration: backend URL, sign-in mode and push from --dart-define; backend behavior from .env AppConfig, backend config
T15 Availability / usability Fallback content: a group without a photo (or whose image fails to load) shows a gradient in its own colour with its category icon instead of a broken image OrgImage
T16 Usability / performance State retention: each tab has its own navigator, kept alive, so switching tabs keeps scroll position, filters and loaded data instead of rebuilding and re-fetching HomeShell (per-tab navigators in an IndexedStack)

Who is responsible for each

Each member is in charge of one design pattern and one architectural tactic, matching the code they wrote (commits in SENECApp-Frontend-Flutter).

Member Design pattern (in charge) Architectural tactic (implemented)
Nicolas Salazar P1 Repository (with P2 Facade ApiClient) T1 Offline queue with retry (analytics)
José Fonseca P4 Observer (Provider + ChangeNotifier) T16 State retention (per-tab navigators)
Daniel Vargas P9 Composite (reusable widget components) T15 Fallback content (OrgImage)

The remaining patterns (P3 Strategy, P5 Dependency injection, P6 State machine, P7 Layered architecture, P8 Data mapper) and tactics (T2–T14), as well as the backend, were implemented by Nicolas Salazar.

Contributors: Nicolas Salazar, Jose Fonseca, Daniel Vargas.

For the Kotlin frontend

System Architecture and Design Patterns SENECApp-final-architecture-SENECApp Kotlin architecture drawio

We use the client server style, where Android handles the interface and FastAPI manages shared rules and data. We use MVVM to separate screen state from the interface. Camilo used it in Discover, Free Now, recommendations and session management, and Daniel used it in events and registration. We also implemented the Repository pattern. Camilo used it in OrganizationsRepository, FreeNowRepository and GroupRecommendationsRepository, and Daniel used it in CampusEventsRepository. This keeps data access separate from screen logic and makes the code easier to maintain.

Component Interaction and Data Flow mermaid-diagram

When a screen needs data, it asks the ViewModel, which calls the corresponding Repository. The Repository sends an authenticated request to FastAPI and converts the response into app data. The ViewModel then updates the screen with the results or an error. Camilo used this flow for organizations, Free Now and recommendations, and Daniel used it for upcoming events. This gives both implementations a clear separation of responsibilities.

Contributors: Camilo Castilla, Daniel Vergara

⚠️ **GitHub.com Fallback** ⚠️