Sprint2 3. Architectural Design - ISIS3510-Group-27/Documentation GitHub Wiki
Scope: the Flutter app (SENECApp-Frontend-Flutter) and the backend it shares with the Kotlin app (SENECApp-Backend).
- 3a. Architecture: overall structure and components
- 3b. Architecture design: how the components interact
- 3c. Design patterns and tactics
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
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.
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
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.
SessionControllerdecides which screens exist: signed out, verifying email, loading, or in the app.AppStateholds 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.
AppServicesbuilds 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.
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"]
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.
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
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.
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.
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.
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.
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.
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
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.
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=...
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.
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
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.
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
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.
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
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.
| # | 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
Rationale. The class diagram shows how the main patterns fit together:
-
Strategy: the
AuthServiceandPushServiceinterfaces each have a real and an alternative implementation. -
Facade:
ApiClientis the only class that talks HTTP, and it gets its tokens from whicheverAuthServicewas chosen. -
Repository:
GroupsRepositoryand its siblings sit on top ofApiClient. -
Composition root:
AppServiceswires 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.
| # | 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) |
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.
System Architecture and Design Patterns

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

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