An iPhone, rebuilt in Python.
A pixel-drawn iOS home screen running on pygame — Dynamic Island, app grid, dock, widgets and all — with two complete, database-backed applications installed on it.
~13,150 lines · 25 screens · 2 apps · 5 SQLite tables · 0 UI frameworks
There is no GUI toolkit here. No Tkinter, no Qt, no Kivy, no web view. Every button, input field, dropdown, table, card, ring and animation is drawn from pygame primitives, one shape at a time — and then wired into two applications with real logins, real persistence and real business logic.
The phone. A 420×850 window styled as an iPhone: a status bar with a pill-shaped Dynamic Island, live clock, battery and signal glyphs; a four-across app grid over a three-stop sunset gradient computed per scanline; a translucent dock with a notification badge; a full-width weather widget; page dots; and a rounded black bezel composited over the top. Tapping an icon plays a splash animation — the icon holds on a full-bleed colour field, then cross-fades out as the app takes over the screen.
Two apps that actually work. Not mockups. They log you in, remember you after you quit, read and write a SQLite database, and enforce who is allowed to see what.
|
Getting in
|
Once you are in
|
Booking an appointment is a four-stage flow: browse doctors → doctor detail with specialty and consultation fee → pick from eight time slots for a chosen day → confirm. Slots already taken are queried from the database and shown as unavailable, and availability is re-checked at the moment of booking, so two patients cannot take the same slot.
Filling in the form does not create an account. It sends a six-digit code, which arrives the way a real one would: as a system notification that slides down from under the Dynamic Island, holds, and slides away. Type it into the six boxes on the verify screen and the account is created and signed in.
Nothing is written to the database until the code checks out. Codes expire after ten minutes, are burned after five wrong guesses, and cannot be resent for thirty seconds. A wrong code leaves no trace behind.
The notification banner is a general component — components/notification.py —
not a one-off for this flow.
The same app serves two roles, and knows the difference.
| Administrator | Doctor | |
|---|---|---|
| Dashboard | Hospital-wide statistics, doctor overview | Own patients, own schedule, today's list |
| Doctors | Full CRUD | — |
| Patients | Every patient | Only their assigned patients |
| Appointments | All, filterable by status | Only their own |
| Reports | Export | — |
- Doctors and patients get full list → view → add → edit flows with per-field validation and dropdowns for specialty and disease. Doctors are soft-deleted, so their appointment history survives.
- Appointments can be marked complete or cancelled, and filtered by status.
- Scoping is enforced in the queries, not the UI. When a doctor is logged in, the patients screen searches only their assigned patients and the appointments screen loads only their own — different SQL, not a hidden button.
- Reports export doctors, patients, appointments or hospital statistics to
PDF (reportlab) or Excel (openpyxl), written to
reports/.
122 drawing routines build the interface. Rounded rectangles are
composited through a per-pixel alpha mask rather than pygame's built-in
border_radius, so corners stay smooth at any radius; circles go through
pygame.gfxdraw for anti-aliasing. Icons are loaded from 33 icon assets,
scaled with smoothscale, cached after first use, and fall back to a
generated rounded-square if a file is missing.
A 64-method SQLite layer (apps/shared/database.py) covers five tables —
admins, doctors, patients, appointments, sessions — with foreign keys,
soft deletes, joined queries for appointment listings, availability checks,
and statistics rollups for each of the three dashboards.
Sessions persist to disk. Close the window mid-session and reopen it: the app checks the session table, confirms the user still exists, and drops you back where you were. Delete that user and the stale session is cleaned up instead.
Passwords are hashed with bcrypt — see Password storage.
560 tests and a CI matrix across Python 3.10–3.13.
Requires Python 3.10 or newer.
git clone https://github.com/sam-a1a/PyPhone.git
cd PyPhone
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python main.pyTap Health (row 1) or Admin (row 4). Press Esc to go back, and again
to quit.
The database starts empty. Every account comes from signing up:
- Health → Sign Up creates a patient.
- Admin → Sign Up creates a doctor or an administrator — pick which with the segmented control at the top of the form.
Both go through email verification before the account exists.
pip install -r requirements.txt
pytest560 passed
Name Stmts Miss Cover
----------------------------------------------------
apps/shared/database.py 547 0 100%
apps/shared/models.py 79 0 100%
apps/shared/security.py 39 0 100%
apps/shared/validators.py 51 0 100%
apps/shared/verification.py 68 0 100%
apps/verify_screen.py 140 0 100%
components/notification.py 130 0 100%
config.py 37 7 81%
utils.py 40 0 100%
----------------------------------------------------
TOTAL 1135 7 99%
CI fails the build if that total drops below 95%.
The suite covers password hashing, the whole SQLite layer, the models, the
validators, and the drawing helpers that can render to an off-screen surface.
The pygame screens are drawing code and are not unit tested — but every one of
them is imported by tests/test_imports.py, so a module that no longer even
loads fails the build.
It needs no display: tests/conftest.py points SDL at its dummy driver before
pygame is imported, which is also how it runs in CI. Every test gets a
throwaway database in a temp directory, so pytest never touches your
hospital.db.
pytest --cov=apps.shared --cov=components.notification \
--cov=apps.verify_screen --cov=config --cov=utils --cov-report=term-missingmain.py home screen: app grid, dock, widget, click routing
config.py screen size, colour palette, fonts
utils.py sunset gradient, alpha-masked rounded rectangles
components/ status bar + Dynamic Island, icons, dock, widgets,
the notification banner
apps/
base_app.py 60fps run loop, event dispatch, header and bezel
app_manager.py name → app class registry
splash_screen.py the hold-and-fade animation when an icon is tapped
shared/ everything that is not drawing code
database.py 64 methods: CRUD, queries, statistics, sessions
models.py Person → Patient / Doctor, plus Admin, Appointment
security.py bcrypt hashing and legacy migration
validators.py email, phone, name, age, password rules
verification.py one-time signup codes: expiry, attempts, cooldown
verify_screen.py the six-box code entry screen, shared by both apps
health/ patient app 11 screens + 4 component modules
health_admin/ staff app 13 screens + 6 component modules
tests/ 560 tests across 15 files
.github/workflows/ CI on Python 3.10, 3.11, 3.12, 3.13
Person is subclassed by Patient and Doctor, each extending to_dict() —
the models are dataclasses, so equality and from_dict() round-trips come for
free.
Database is a singleton: every screen calls Database() and gets the same
instance, sharing one connection path and one login session. Pass
Database(db_path=...) or set PYPHONE_DB_PATH to point it elsewhere — which
is how the tests keep out of your way.
Screens are self-contained objects with draw(), handle_event() and
update(), held in a dictionary by the app that owns them and swapped by name,
so adding a screen means adding one file and one dictionary entry.
Passwords are hashed with bcrypt, salted per password, work factor 12
(apps/shared/security.py).
Earlier versions stored bare SHA-256 digests. Those are unsalted — two accounts with the same password produced the same digest — and fast enough to brute force at billions of guesses per second. Any such digest still in a database is recognised, so nobody is locked out, and is replaced with a bcrypt hash the next time that account logs in successfully. No password reset needed.
Two details worth knowing if you touch that file: passwords are folded through
SHA-256 and base64 before bcrypt, because bcrypt ignores everything past 72
bytes and raises on longer input; and legacy digests are compared with
hmac.compare_digest rather than ==.
Pull requests are welcome. Please keep pytest green, add tests for anything
you change under apps/shared/, and match the surrounding style.
MIT © Bassam Ghazaleh