> building-sleepy-a-material-you-schedule-app-with-jetpack-compose

Building Sleepy: A Material You Schedule App with Jetpack Compose
// ─────────────────────────────────────────────────────────────
OUTPUT 001 SEED 1581663009 DATE 2026-06-16 CHARS 11,625 TAGS android kotlin jetpack-compose material-you algorithms reverse-engineering open-source

Why Another Schedule App?

I am a student at Harbin Engineering University. Every schedule app I tried was either bloated with ads, locked behind a paywall, or could not import from our university's academic system. So I built Sleepy — a clean, open-source, Material You schedule app in pure Kotlin + Jetpack Compose. 119 commits. 21 releases. 68 Kotlin source files totaling 16,460 LOC. Zero third-party SDKs.

The Stack

  • UI: Jetpack Compose with Material 3 — no XML layouts, no Fragment navigation. MainActivity extends ComponentActivity directly and hosts the entire UI tree as composable functions.
  • Architecture: MVVM — ScheduleViewModel holds a ScheduleState data class with 11 fields and 2 derived properties. ScheduleRepository is the single source of truth, wrapping Room DAOs with coroutine-based operations.
  • Persistence: Room 2.6.1 — 2 entities (CourseEntity 23 fields, TimeTableEntity 8 fields), 2 DAOs (12 + 10 methods), version 3 schema migration.
  • Widgets: Glance 1.1.0 for 3 declarative widgets + RemoteViews with manual Canvas Bitmap rendering for the WeekGrid widget.
  • Min SDK: 24 (Android 7.0)

1. ScheduleParser: Auto-Detection from 6 Formats

The import pipeline starts with ScheduleParser.parse() — 737 lines that sniff the input format from the first few characters and route to the appropriate parser. Six format detectors run in sequence:

FormatDetection fingerprint
WakeUp share textStarts with [WakeUp Schedule] prefix — extracted from the app's share-sheet format
WakeUp / Sleepy JSONValid JSON starting with {"course or [{" — the standard interchange format
ICS iCalendarLines containing BEGIN:VEVENT — the universal calendar exchange format
CSVTab-separated lines with header detection supporting both Chinese and English column headers, plus multi-week ranges like 2-5,7-9,11-14
HTML tablesContains tags — parsed via jsoup 1.18.1
Plain textTab or whitespace-delimited lines — the fallback parser

Each parser returns a ParseResult containing a course list, detected table name, and start date. The parse() function tries each detector in order and returns the first successful parse. ICS time-to-period mapping uses startNode = ((startMin - 480) / 55) + 1 — 8:00 AM = period 1, each 55-minute block maps to one period.

2. University Academic System Reverse Engineering

Chinese universities use wildly different academic systems. Sleepy supports 9 protocol variants across 5 families, implemented across 11 parser classes (6 of which fall back to a shared JwQzParser base).

2.1 Wisedu — the HEU protocol

The hardest protocol to reverse-engineer. HEU uses a modern Wisedu system with a POST endpoint at /jwapp/sys/wdkb/modules/xskcb/xskcb.do. The payload contains 7 key fields: KCM (course name), SKJS (teacher), JASMC (room), SKXQ (day of week), KSJC (start period), JSJC (end period), SKZC (week bitmap).

The week bitmap is a 0-indexed string of 0s and 1s — each character represents one teaching week. The frontend compresses this into weekRuns (single/double week intervals) for display. The login flow uses a WebView that injects a JavaScript bridge (WiseduBridge) and a 5-step fetch chain to extract schedule data, traversing iframe and frame elements to capture the full DOM.

2.2 URP — the JSON injection variant

The new URP protocol injects schedule data into the page as a JavaScript variable: var kbxx_json = {...};. The dateList field contains a 20-bit weekBits string of 0/1 characters, parsed into ranges using a stride-detection algorithm that identifies consecutive 1s in the bitmap.

2.3 Qiangzhi — the workhorse base class

JwQzParser serves as the base for 6 of the 11 parser classes. It parses HTML tables with class="displayTag", using tableName = "kbcontent" as the selector. The node = nodeCount * 2 - 1 formula maps HTML table rows to period numbers. Courses sharing the same time slot are separated by "-----" delimiters within a single table cell.

3. Gold Angle HSL Color Assignment

Each course in Sleepy gets a unique color, assigned automatically through the gold angle algorithm — not a fixed palette, not a random hash. The algorithm in CourseTableView.kt computes:

val hue = (courseId * 137.508) % 360

137.508° is the golden angle — 360 / phi^2. It guarantees that any two courses have a hue separation of at least ~27° for up to 13 courses, and the colors never cluster. The resulting hue is used in an HSL color with lightness and saturation adapted per theme (higher saturation in dark mode, lower in light).

An 8-rule keyword matcher overrides the computed color for well-known courses: English gets blue-green, Military Science gets olive, Physics gets orange, etc. These rules are hardcoded in WidgetContent.kt and apply consistently across all views and widgets. A 6-color hash fallback palette catches any course not matched by keyword rules.

4. Hybrid Widget System: Glance + RemoteViews

Sleepy ships 4 home-screen widgets on a 15-minute WorkManager refresh cycle. Three use Glance (Today, TwoDay, WeekList) — a Compose-like declarative API. The WeekGrid widget uses RemoteViews with manual Canvas Bitmap rendering because Glance 1.1.0 has a known bug: when converting to RemoteViews, the LinearLayout drops children for periods 11 and above.

4.1 WeekGrid Bitmap rendering pipeline

The WeekGridWidgetProvider renders the full weekly grid into a 360x600dp fixed-size Bitmap. The rendering process:

  1. Query Room for the current week's courses via ScheduleRepository
  2. Resolve the active table using WidgetTableResolver — 3-tier priority: user-set non-default table > default table with courses > any table with courses
  3. Draw the grid structure: 8 columns (time labels + 7 days), rows per time slot at 52dp cell height
  4. Place course cards using the same gold-angle color algorithm as the in-app view
  5. Determine text color (white or black) by checking system brightness via isDarkOn()
  6. Wrap the Bitmap in a RemoteViews ImageView and broadcast APPWIDGET_UPDATE

The WidgetUpdater must refresh both Glance widgets (via .update()) and the RemoteViews widget (via broadcast) — missing either path leaves one widget silently stale. A PinWidgetActivity handles the Android 12+ widget pinning flow for first-time setup.

5. SmartPeriodConfig: Auto-Derivation Engine

Manual time slot configuration is tedious: 12 periods × 2 time fields = 24 form fields. The SmartPeriodConfig system automates this with 4 parameters:

  • Total periods — how many periods per day
  • First period start — e.g. "08:00"
  • Period duration — minutes per period (e.g. 45)
  • Break duration — default minutes between periods (e.g. 5)

Plus optional specific breaks — longer gaps after certain periods (e.g. 15 minutes after period 4 for lunch). The engine computes all start times and end times sequentially, then serializes the result to TimeTableEntity.smartConfigJson as a JSON string for persistence. The TimeSlotEditor composable switches between Manual and Auto modes via a top tab bar.

6. CourseTableLayout: Box-with-Offset Grid

The week grid view was the most challenging Compose layout. The naive approach — a LazyColumn with row-based measuring — collapsed to a single cell because fillMaxSize() made content height equal to viewport height, leaving nothing to scroll.

The solution: CardsGridView wraps a BoxWithConstraints and uses absolute Modifier.offset() positioning for each course card. The grid structure is pre-computed from 5 layout constants (header height, time column width, slot height, row gap, column gap) and the actual cell dimensions are derived from BoxWithConstraints width divided by 8 columns.

Course cards that span multiple periods are rendered as taller rectangles with proportional padding. This avoids the Compose measurement pass collapsing the layout, at the cost of manual coordinate math for every card position.

7. Room Schema Design

The database has two entities and a JSON-backed config:

  • CourseEntity (23 fields): course name, teacher, room, note, startWeek, endWeek, dayOfWeek, startNode, step, ownTime flag, startTime, endTime, type (1=odd-week / 2=even-week / 0=every-week), color, groupId (UUID for batch operations), tableId (foreign key)
  • TimeTableEntity (8 fields): name, startDate, maxWeek, timeJson (12-period schedule as JSON array), smartConfigJson, isDefault flag

The groupId field on CourseEntity is a java.util.UUID.randomUUID().toString() — not derived from course name. This allows ScheduleRepository.getGroupCourses() to load all related courses for batch editing without relying on name collisions.

8. Import Pipeline: Preview, Conflict Detection, Apply

The import flow has three stages:

  1. Parse: ScheduleParser.parse()ParseResult with courses, table name, start date
  2. Preview: buildImportPreview() runs conflict detection — comparing incoming course time slots against existing courses. Returns ImportPreview with incomingCount, conflictCount, and cleanCount metrics
  3. Apply: Three ImportApplyMode options — ReplaceCurrent (clear all, insert new), ImportAsNew (create separate table), AppendNonConflict (skip time-conflicting courses)

Conflict detection uses a time range overlap check: coursesConflict(a, b) compares day-of-week intersection and minute-range overlap for each course pair. The preview dialog shows 3 metric cards (courses / conflicts / appendable) color-coded: red for conflicts, green for clean imports.

Open Source

Sleepy is GPL-3.0, 21 releases, supporting 3 ABIs (arm64-v8a / armeabi-v7a / x86_64). Available on GitHub Releases. No Play Store, no ads, no tracking.

Star on GitHub · Download APK

See also: Sleepy Operation Docs — step-by-step user guide for installation, import, widgets, themes, and troubleshooting.

← cd .. /